Category Archives: All About Software Technology

Scala Binary Search Tree

When I wrote about Scala linked list implementation a couple of years ago, I also did some quick ground work for implementing binary search trees (BST). Occupied by other R&D projects at the time, it was put aside and has since been patiently awaiting its turn to see the light of day. As much of the code is already there, I’m going to put it up in this blog post along with some narrative remarks.

First, we come up with an ADT (algebraic data type). Let’s call it BSTree, starting out with a base trait with generic type A for the data element to be stored inside the tree structure, to be extended by a case class BSBranch as tree branches and a case object BSLeaf as “null” tree nodes. The ADT’s overall structure resembles that of the one used in the linked list implementation described in the old post.

ADT BSTree

sealed trait BSTree[+A] { self =>
  // ...

  override def toString: String = self match {
    case BSLeaf => "-"
    case BSBranch(e, c, l, r) => (l, r) match {
      case (BSLeaf, BSLeaf) => s"TN($e[$c], -, -)"
      case (BSBranch(le, lc, _, _), BSLeaf) => s"TN($e[$c], $le[$lc], -)"
      case (BSLeaf, BSBranch(re, rc, _, _)) => s"TN($e[$c], -, $re[$rc])"
      case (BSBranch(le, lc, _, _), BSBranch(re, rc, _, _)) => s"TN($e[$c], $le[$lc], $re[$rc])"
    }
  }
}

case class BSBranch[+A](
    elem: A, count: Int = 1, left: BSTree[A] = BSLeaf, right: BSTree[A] = BSLeaf
  ) extends BSTree[A]

case object BSLeaf extends BSTree[Nothing]

A few notes:

  • Data elements (of generic type A) are stored only in branches and data of the same value will go to the same branch and be represented with a proper count value.
  • BSTree is covariant, or else BSLeaf can’t even be defined as a sub-type of BSTree[Nothing].
  • A toString method is created for simplified string output of a tree instance.

Populating a BSTree

One of the first things we need is a method to insert tree nodes into an existing BSTree. We start expanding the base trait with method insert(). That’s all great for adding a node one at a time, but we also need a way to create a BSTree and populate it from a readily available collection of data elements. It makes sense to delegate such a factory method to the companion object BSTree as its method apply().

sealed trait BSTree[+A] { self =>

  def insert[B >: A : Ordering](elem: B): BSTree[B] = {
    val ord = implicitly[Ordering[B]]
    import ord._
    self match {
      case BSLeaf => BSBranch(elem, 1, BSLeaf, BSLeaf)
      case t @ BSBranch(e, c, l, r) =>
        if (e > elem)
          t.copy(left = l.insert(elem))
        else if (e < elem)
          t.copy(right = r.insert(elem))
        else
          t.copy(count = c + 1)
    }
  }

  // ...
}

object BSTree {
  def apply[A : Ordering](elems: Vector[A]): BSTree[A] = {
    val ord = implicitly[Ordering[A]]
    import ord._
    elems.foldLeft[BSTree[A]](BSLeaf)(_.insert(_))
  }
}

Note that type parameter B for insert() needs to be a supertype of A because Function1 is contravariant over its parameter type. In addition, the context bound "B : Ordering" constrains type B to be capable of being ordered (i.e. compared) which is necessary for traversing a binary search tree.

Testing BSTree.apply():

val tree = BSTree(Vector(30, 10, 50, 20, 40, 70, 60, 80, 20, 50, 60))
// tree: BSTree[Int] = TN(30[1], 10[1], 50[2])

/*
tree:
           30
        /       \
    10            50
       \        /    \
        20     40     70
                    /    \
                   60    80
*/

Tree traversal and finding tree nodes

Next, we need methods for tree traversal and search. For brevity, we only include in-order traversal.

  def traverseInOrder: Unit = self match {
    case BSLeaf => ()
    case BSBranch(_, _, l, r) =>
      l.traverseInOrder
      println(self)
      r.traverseInOrder
  }

  def traverseByLevel: Unit = {
    def loop(tree: BSTree[A], level: Int): Unit = {
      if (level > 0)
        tree match {
          case BSLeaf =>
          case BSBranch(_, _, l, r) =>
            loop(l, level - 1)
            loop(r, level - 1)
        }
      else
        print(s"$tree  ")
    }
    for (l <- 0 until self.height) {
      loop(self, l)
      println()
    }
  }

  def find[B >: A : Ordering](elem: B): BSTree[B] = {
    val ord = implicitly[Ordering[B]]
    import ord._
    self match {
      case BSLeaf => BSLeaf
      case t @ BSBranch(e, c, l, r) =>
        if (e < elem)
          r.find(elem)
        else if (e > elem)
          l.find(elem)
        else
          t
    }
  }

  def height: Int = self match {
    case BSLeaf => 0
    case BSBranch(_, _, l, r) => (l.height max r.height) + 1
  }

Using the tree created above:

/*
tree:
           30
        /       \
    10            50
       \        /    \
        20     40     70
                    /    \
                   60    80
*/

tree.height  // 4

tree.traverseInOrder
/*
TN(10[1], -, 20[2])
TN(20[2], -, -)
TN(30[1], 10[1], 50[2])
TN(40[1], -, -)
TN(50[2], 40[1], 70[1])
TN(60[2], -, -)
TN(70[1], 60[2], 80[1])
TN(80[1], -, -)
*/

tree.traverseByLevel
/*
TN(30[1], 10[1], 50[2])
TN(10[1], -, 20[2])  TN(50[2], 40[1], 70[1])
TN(20[2], -, -)  TN(40[1], -, -)  TN(70[1], 60[2], 80[1])
TN(60[2], -, -)  TN(80[1], -, -)
*/

val tree50 = tree.find(50)  // TN(50[2], 40[1], 70[1])
/*
tree50:
           50
        /       \
    40            70
                /    \
               60     80
*/

Removing tree nodes

To be able to remove tree nodes that consist of a specific or range of element values, we include also the following few methods in the base trait.

Note that delete() may involve a little shuffling of the tree nodes. Once the tree node to be removed is located, that node may need to be filled with the node having the next-bigger element & count values from its right node (or equivalently, the node having the next-smaller element from its left node).

  def delete[B >: A : Ordering](elem: B): BSTree[B] = {
    val ord = implicitly[Ordering[B]]
    import ord._
    def leftMost(tree: BSTree[B], prev: BSTree[B]): BSTree[B] = tree match {
      case BSLeaf => prev
      case t @ BSBranch(_, _, l, _) => leftMost(l, t)
    }
    self match {
      case BSLeaf => BSLeaf
      case t @ BSBranch(e, c, l, r) =>
        if (e < elem)
          t.copy(right = r.delete(elem))
        else if (e > elem)
          t.copy(left = l.delete(elem))
        else {
          if (l == BSLeaf)
            r
          else if (r == BSLeaf)
            l
          else {
            val nextBigger = leftMost(r, r)
            nextBigger match { case BSBranch(enb, cnb, _, _) =>
              t.copy(elem = enb, count = cnb, right = r.delete(enb))
            }
          }
        }
    }
  }

  def trim[B >: A : Ordering](lower: B, upper: B): BSTree[B] = {
    val ord = implicitly[Ordering[B]]
    import ord._
    self match {
      case BSLeaf => BSLeaf
      case t @ BSBranch(e, _, l, r) =>
        val tt = t.copy(left = l.trim(lower, upper), right = r.trim(lower, upper)) 
        if (e < lower)
          tt match { case BSBranch(_, _, _, r) => r }
        else if (e > upper)
          tt match { case BSBranch(_, _, l, _) => l }
        else
          tt
    }
  }

  def cutOut[B >: A : Ordering](lower: B, upper: B): BSTree[B] = {
    val ord = implicitly[Ordering[B]]
    import ord._
    self match {
      case BSLeaf => BSLeaf
      case t @ BSBranch(e, _, l, r) =>
        val tt = t.copy(left = l.cutOut(lower, upper), right = r.cutOut(lower, upper)) 
        if (e >= lower && e <= upper)
          tt match { case BSBranch(e, _, _, _) => tt.delete(e) }
        else
          tt
    }
  }

Method trim() removes tree nodes with element values below or above the provided range. Meanwhile, method cutOut() does the opposite by cutting out tree nodes with values within the given range. It involves slightly more work than trim(), requiring the use of delete() for individual tree nodes.

Example:

val treeD50 = tree.delete(50)  // TN(30[1], 10[1], 60[2])
/*
treeD50:
           30
        /       \
    10            60
       \        /    \
        20     40     70
                         \
                          80
*/

val treeT15to65 = tree.trim(15, 65)  // TN(30[1], 20[2], 50[2])
/*
treeT15to65:
           30
        /       \
    20            50
                /    \
               40     60
*/

val treeC25to55 = tree.trimInRange(25, 55)  // TN(60[1], 10[1], 70[1])
/*
treeC25to55:
           60
        /       \
    10            70
       \             \
        20            80
*/

Rebuilding a binary search tree

A highly unbalanced binary search tree beats the purpose of using such a data structure. One of the most straight forward ways to rebuild a binary search tree is to "unpack" the individual tree nodes of the existing tree by traversing in-order into a list (e.g. a Vector or List) of elements, followed by reconstructing a new tree with nodes being assigned elements from recursively half-ing the in-order node list.

  def rebuild: BSTree[A] = {
    def loop(vs: Vector[(A, Int)], lower: Int, upper: Int): BSTree[A] = {
      if (upper >= lower) {
        val m = (lower + upper) / 2
        val (e, c) = vs(m)
        BSBranch(e, c, loop(vs, lower, m-1), loop(vs, m+1, upper))
      }
      else
        BSLeaf
    }
    self match {
      case BSLeaf =>
        BSLeaf
      case _ =>
        val vs = self.toElemVector
        loop(vs, 0, vs.size - 1)
    }
  }

  def isBalanced: Boolean = self match {
    case BSLeaf => true
    case BSBranch(_, _, l, r) =>
      if (math.abs(l.height - r.height) > 1)
        false
      else
        l.isBalanced && r.isBalanced
  }

  def toElemVector: Vector[(A, Int)] = self match {  // in-order
    case BSLeaf =>
      Vector.empty[(A, Int)]
    case BSBranch(e, c, l, r) =>
      (l.toElemVector :+ (e, c)) ++ r.toElemVector
  }

  def toVector: Vector[BSTree[A]] = self match {  // in-order
    case BSLeaf => Vector.empty[BSTree[A]]
    case BSBranch(_, _, l, r) =>
      (l.toVector :+ self) ++ r.toVector
  }

Example:

val unbalancedTree = BSTree(Vector(20, 10, 30, 30, 40, 50, 60, 60, 50))
// unbalancedTree: BSTree[Int] = TN(20[1], 10[1], 30[2])

/*
unbalancedTree:
           20
        /       \
    10            30
                     \
                     40
                        \
                         60
                        /
                       50
*/

unbalancedTree.isBalanced  // false

val rebuiltTree = unbalancedTree.rebuild  // TN(30[2], 10[1], 50[2])
/*
rebuiltTree:

           30
        /       \
    10            50
       \        /    \
        20     40     60
*/

rebuiltTree.isBalanced  // true

Thoughts on the ADT

An alternative to how the ADT is designed is to have the class fields and methods declared in the BSTree base trait with specific implementations reside within subclasses BSBranch and BSLeaf, thus eliminating the need of the boiler-plate pattern matching for the subclasses. There is also the benefit of making class fields like left & right referenceable from the base trait, though they would need to be wrapped in Options with value None for BSLeaf.

As can be seen with the existing ADT, an advantage is having all the binary tree functions defined within the base trait once and for all. If there is the need for having left and right referenceable from the BSTree base trait, one can define something like below within the trait.

  def leftIfAny: Option[BSTree[A]] = self match {
    case BSLeaf => None
    case BSBranch(_, _, l, _) => Some(l)
  }

  def rightIfAny: Option[BSTree[A]] = self match {
    case BSLeaf => None
    case BSBranch(_, _, _, r) => Some(r)
  }

Example:

tree.leftIfAny
// Some(TN(10[1], -, 20[2]))

tree.rightIfAny.flatMap(_.rightIfAny)
// Some(TN(70[1], 60[2], 80[1]))

Then there is also non-idiomatic approach of using mutable class fields in a single tree class commonly seen in Java implementation, like below:

object SimpleBST {
  class TreeNode[A](_elem: A, _left: TreeNode[A] = null, _right: TreeNode[A] = null) {
    var elem: A = _elem
    var count: Int = 1
    var left: TreeNode[A] = _left
    var right: TreeNode[A] = _right
    override def toString: String =
      if (this == null)
        "null"
      else (left, right) match {
        case (null, null) => s"TN($elem[$count], -, -)"
        case (l,    null) => s"TN($elem[$count], ${l.elem}[${l.count}], -)"
        case (null, r)    => s"TN($elem[$count], -, ${r.elem}[${r.count}])"
        case (l,    r)    => s"TN($elem[$count], ${l.elem}[${l.count}], ${r.elem}[${r.count}])"
      }
  }

  // Methods for node addition, deletion, etc ...
}

Addendum: Complete source code of the BSTree ADT

sealed trait BSTree[+A] { self =>

  def traverseInOrder: Unit = self match {
    case BSLeaf => ()
    case BSBranch(_, _, l, r) =>
      l.traverseInOrder
      println(self)
      r.traverseInOrder
  }

  def traverseByLevel: Unit = {
    def loop(tree: BSTree[A], level: Int): Unit = {
      if (level > 0)
        tree match {
          case BSLeaf =>
          case BSBranch(_, _, l, r) =>
            loop(l, level - 1)
            loop(r, level - 1)
        }
      else
        print(s"$tree  ")
    }
    for (l <- 0 until self.height) {
      loop(self, l)
      println()
    }
  }

  def find[B >: A : Ordering](elem: B): BSTree[B] = {
    val ord = implicitly[Ordering[B]]
    import ord._
    self match {
      case BSLeaf => BSLeaf
      case t @ BSBranch(e, c, l, r) =>
        if (e < elem)
          r.find(elem)
        else if (e > elem)
          l.find(elem)
        else
          t
    }
  }

  def height: Int = self match {
    case BSLeaf => 0
    case BSBranch(_, _, l, r) => (l.height max r.height) + 1
  }

  def insert[B >: A : Ordering](elem: B): BSTree[B] = {
    val ord = implicitly[Ordering[B]]
    import ord._
    self match {
      case BSLeaf => BSBranch(elem, 1, BSLeaf, BSLeaf)
      case t @ BSBranch(e, c, l, r) =>
        if (e > elem)
          t.copy(left = l.insert(elem))
        else if (e < elem)
          t.copy(right = r.insert(elem))
        else
          t.copy(count = c + 1)
    }
  }

  def delete[B >: A : Ordering](elem: B): BSTree[B] = {
    val ord = implicitly[Ordering[B]]
    import ord._
    def leftMost(tree: BSTree[B], prev: BSTree[B]): BSTree[B] = tree match {
      case BSLeaf => prev
      case t @ BSBranch(_, _, l, _) => leftMost(l, t)
    }
    self match {
      case BSLeaf => BSLeaf
      case t @ BSBranch(e, c, l, r) =>
        if (e < elem)
          t.copy(right = r.delete(elem))
        else if (e > elem)
          t.copy(left = l.delete(elem))
        else {
          if (l == BSLeaf)
            r
          else if (r == BSLeaf)
            l
          else {
            val nextBigger = leftMost(r, r)
            nextBigger match { case BSBranch(enb, cnb, _, _) =>
              t.copy(elem = enb, count = cnb, right = r.delete(enb))
            }
          }
        }
    }
  }

  def trim[B >: A : Ordering](lower: B, upper: B): BSTree[B] = {
    val ord = implicitly[Ordering[B]]
    import ord._
    self match {
      case BSLeaf => BSLeaf
      case t @ BSBranch(e, _, l, r) =>
        val tt = t.copy(left = l.trim(lower, upper), right = r.trim(lower, upper)) 
        if (e < lower)
          tt match { case BSBranch(_, _, _, r) => r }
        else if (e > upper)
          tt match { case BSBranch(_, _, l, _) => l }
        else
          tt
    }
  }

  def cutOut[B >: A : Ordering](lower: B, upper: B): BSTree[B] = {
    val ord = implicitly[Ordering[B]]
    import ord._
    self match {
      case BSLeaf => BSLeaf
      case t @ BSBranch(e, _, l, r) =>
        val tt = t.copy(left = l.cutOut(lower, upper), right = r.cutOut(lower, upper)) 
        if (e >= lower && e <= upper)
          tt match { case BSBranch(e, _, _, _) => tt.delete(e) }
        else
          tt
    }
  }

  def rebuild(): BSTree[A] = {
    def loop(vs: Vector[A], lower: Int, upper: Int): BSTree[A] = {
      if (upper >= lower) {
        val m = (lower + upper) / 2
        val em = vs(m)
        self match {
          case BSBranch(e, c, _, _) if e == em =>
            BSBranch(em, c+1, loop(vs, lower, m-1), loop(vs, m+1, upper))
          case _ =>
            BSBranch(em, 1, loop(vs, lower, m-1), loop(vs, m+1, upper))
        }
      }
      else
        BSLeaf
    }
    self match {
      case BSLeaf =>
        BSLeaf
      case _ =>
        val vs = self.toElemVector
        loop(vs, 0, vs.size - 1)
    }
  }

  def isBalanced: Boolean = self match {
    case BSLeaf => true
    case BSBranch(_, _, l, r) =>
      if (math.abs(l.height - r.height) > 1)
        false
      else
        l.isBalanced && r.isBalanced
  }

  def toElemVector: Vector[A] = self match {  // in-order
    case BSLeaf =>
      Vector.empty[A]
    case BSBranch(e, _, l, r) =>
      (l.toElemVector :+ e) ++ r.toElemVector
  }

  def toVector: Vector[BSTree[A]] = self match {  // in-order
    case BSLeaf => Vector.empty[BSTree[A]]
    case BSBranch(_, _, l, r) =>
      (l.toVector :+ self) ++ r.toVector
  }

  override def toString: String = self match {
    case BSLeaf => "-"
    case BSBranch(e, c, l, r) => (l, r) match {
      case (BSLeaf, BSLeaf) => s"TN($e[$c], -, -)"
      case (BSBranch(le, lc, _, _), BSLeaf) => s"TN($e[$c], $le[$lc], -)"
      case (BSLeaf, BSBranch(re, rc, _, _)) => s"TN($e[$c], -, $re[$rc])"
      case (BSBranch(le, lc, _, _), BSBranch(re, rc, _, _)) => s"TN($e[$c], $le[$lc], $re[$rc])"
    }
  }
}

object BSTree {
  def apply[A : Ordering](elems: Vector[A]): BSTree[A] = {
    val ord = implicitly[Ordering[A]]
    import ord._
    elems.foldLeft[BSTree[A]](BSLeaf)(_.insert(_))
  }
}

case class BSBranch[A](
    elem: A, count: Int = 1, left: BSTree[A] = BSLeaf, right: BSTree[A] = BSLeaf
  ) extends BSTree[A]

case object BSLeaf extends BSTree[Nothing]

A Crossword Puzzle In Scala

As a programming exercise, creating and solving a crossword puzzle is a fun one, requiring some non-trivial but reasonable effort in crafting out the necessary programming logic.

The high-level requirements are simple — Given a square-shaped crossword board with intersecting word slots, and a set of words that are supposed to fit in the slots, solve the crossword and display the resulting word-populated board.

Data structure for the crossword board

First, it would help make the implementation more readable by coming up with a couple of inter-related classes to represent the crossword board’s layout:

Class Board represents the crossword board which can be constructed with the following parameters:

  • sz: Size of the square-shaped board with dimension sz x sz
  • bgCh: Background character representing a single cell of the board which can be identified with its XY-coordinates (default ‘.’)
  • spCh: Space character representing a single empty cell of a word slot (default ‘*’)
  • slots: A set of word slots, each of which is represented as an instance of class Slot
  • arr: The “hidden” character array of dimension sz x sz that stores the content (bgCh, spCh (in empty slots) and words)

Class Slot represents an individual word slot with these parameters:

  • start: The XY-coordinates of the starting character of the word slot
  • horiz: A boolean indicator whether a word slot is horizontal (true) or vertical (false)
  • len: Length of the word slot
  • jctIdxs: Indexes of all the junction cells that intersect any other word slots
  • word: A word that fits the slot in length and matches characters that intersect other word slots at its junctions (default “”)

Thus, we have the skeletal classes:

case class Slot(start: (Int, Int), horiz: Boolean, len: Int, jctIdxs: Set[Int], word: String = "")

case class Board private(sz: Int, bgCh: Char = '.', spCh: Char = '*', slots: Set[Slot] = Set()) {
  private val arr: Array[Array[Char]] = Array.ofDim(sz, sz)
}

Next, we expand class Board to include a few methods for recursively filling its word slots with matching words along with a couple of side-effecting methods for displaying board content, error reporting, etc.

case class Board private(sz: Int, bgCh: Char = '.', spCh: Char = '*', slots: Set[Slot] = Set()) {
  private val arr: Array[Array[Char]] = Array.ofDim(sz, sz)

  // Print content of a provided array
  def printArr(a: Array[Array[Char]] = arr): Unit

  // Report error messages
  def logError(msg: String, sList: List[Slot], wMap: Map[Int, Array[String]], jMap: Map[(Int, Int), Char]): Unit

  // Fill slots with words of matching lengths & junction characters
  def fillSlotsWithWords(words: Array[String]): Board

  // Update board array from slots
  def updateArrayFromSlots(): Board
}

To separate initialization tasks from the core crossword solving logic, we add an companion object to class Board.

object Board {
  // Initialize a board's array from provided background char, slot space char & slots
  def apply(sz: Int, bgCh: Char = '.', spCh: Char = '*', slots: Set[Slot] = Set()): Board

  // Convert an index of a slot into coordinates
  def idxToCoords(start: (Int, Int), horiz: Boolean, idx: Int): (Int, Int)

  // Initialize a board and create slots from board array with ONLY layout
  def initBoardLayout(layout: Array[Array[Char]], bg: Char = '.', sp: Char = '*'): Board
    def findSlot(i: Int, j: Int): Option[Slot]
    def createSlots(i: Int, j: Int, slots: Set[Slot], mkCh: Char): Set[Slot]
}

Creating a crossword puzzle

There are a couple of ways to construct a properly initialized Board object:

  1. From a set of pre-defined word slots, or,
  2. From a sz x sz array of the board layout with pre-populated cells of background and empty world slot
object Board {
  // Initialize a board's array from provided background char, slot space char & slots
  def apply(sz: Int, bgCh: Char = '.', spCh: Char = '*', slots: Set[Slot] = Set()): Board = {
    val board = new Board(sz, bgCh, spCh, slots)
    val slotCoords = slots.toList.flatMap{ slot =>
      val (i, j) = idxToCoords(slot.start, slot.horiz, slot.len - 1)
      List(i, j)
    }
    require(slots.isEmpty || slots.nonEmpty && slotCoords.max < sz, s"ERROR: $slots cannot be contained in ${board.arr}!")
    for (i <- 0 until sz; j <- 0 until sz) {
      board.arr(i)(j) = bgCh
    }
    slots.foreach{ slot =>
      val (i, j) = (slot.start._1, slot.start._2)
      (0 until slot.len).foreach{ k =>
        val (i, j) = idxToCoords(slot.start, slot.horiz, k)
        board.arr(i)(j) = if (slot.word.isEmpty) spCh else slot.word(k)
      }
    }
    board
  }

  ...
}

Method Board.apply() constructs a Board object by populating the private array field with the provided character bgCh to represent the background cells. Once initialized, empty slots represented by the other provided character spCh can be “carved out” in accordance with a set of pre-defined word Slot objects.

Example:

val emptySlots = Set(
  Slot((6, 8), false, 4, Set(2), ""),
  Slot((3, 1), true, 6, Set(2, 5), ""),
  Slot((3, 6), false, 7, Set(0, 5), ""),
  Slot((1, 9), false, 5, Set(0), ""),
  Slot((1, 3), false, 5, Set(0, 2, 4), ""),
  Slot((8, 1), true, 8, Set(5, 7), ""),
  Slot((1, 2), true, 8, Set(1, 7), ""),
  Slot((5, 0), true, 4, Set(3), "")
)

val board = Board(sz = 10, slots = emptySlots)

board.printArr()
/*
. . . . . . . . . . 
. . * * * * * * * * 
. . . * . . . . . * 
. * * * * * * . . * 
. . . * . . * . . * 
* * * * . . * . . * 
. . . . . . * . * . 
. . . . . . * . * . 
. * * * * * * * * . 
. . . . . . * . * . 
*/

Alternatively, one could construct a Board by providing a pre-populated array of the board layout.

object Board {
  ...

  // Initialize a board and create slots from board array with ONLY layout
  def initBoardLayout(layout: Array[Array[Char]], bg: Char = '.', sp: Char = '*'): Board = {
    val sz = layout.length
    def findSlot(i: Int, j: Int): Option[Slot] = {
      if (j < sz-1 && layout(i)(j+1) == sp) {  // Horizontal
        val ln = Iterator.from(j).takeWhile(k => k < sz && layout(i)(k) == sp).size
        val js = (0 until ln).collect{
          case k if (i>=0+1 && layout(i-1)(j+k)!=bg) || (i<sz-1 && layout(i+1)(j+k)!=bg) => k
        }.toSet
        Option.when(ln > 1)(Slot((i, j), true, ln, js))
      }
      else {  // Vertical
        val ln = Iterator.from(i).takeWhile(k => k < sz && layout(k)(j) == sp).size
        val js = (0 until ln).collect{
          case k if (j>=0+1 && layout(i+k)(j-1)!=bg) || (j<sz-1 && layout(i+k)(j+1)!=bg) => k
        }.toSet
        Option.when(ln > 1)(Slot((i, j), false, ln, js))
      }
    }
    def createSlots(i: Int, j: Int, slots: Set[Slot], mkCh: Char): Set[Slot] = {
      if (i == sz) slots
      else {
        if (j == sz)
          createSlots(i+1, 0, slots, mkCh)
        else {
          findSlot(i, j) match {
            case Some(slot) =>
              val jctCoords = slot.jctIdxs.map{ idx =>
                Board.idxToCoords(slot.start, slot.horiz, idx)
              }
              (0 until slot.len).foreach { k =>
                val (i, j) = Board.idxToCoords(slot.start, slot.horiz, k)
                if (!jctCoords.contains((i, j)))
                  layout(i)(j) = mkCh
              }
              createSlots(i, j+1, slots + slot, mkCh)
            case None =>
              createSlots(i, j+1, slots, mkCh)
          }
        }
      }
    }
    val mkCh = '\u0000'
    val newBoard = Board(sz, bg, sp).copy(slots = createSlots(0, 0, Set(), mkCh))
    (0 until sz).foreach{ i =>
      newBoard.arr(i) = layout(i).map{ ch => if (ch == mkCh) sp else ch }
    }
    newBoard
  }
}

Method Board.initBoardLayout() kicks off createSlots(), which parses every element of the array to identify the “head” of any word slot and figure out its “tail” and indexes of cells that intersect with other slots. The mkCh character is for temporarily masking off any identified slot cell that is not a intersecting junction during the parsing.

Example:

val arr = Array(
  Array('.', '.', '.', '.', '.', '.', '.', '.', '.', '.'),
  Array('.', '.', '*', '*', '*', '*', '*', '*', '*', '*'),
  Array('.', '.', '.', '*', '.', '.', '.', '.', '.', '*'),
  Array('.', '*', '*', '*', '*', '*', '*', '.', '.', '*'),
  Array('.', '.', '.', '*', '.', '.', '*', '.', '.', '*'),
  Array('*', '*', '*', '*', '.', '.', '*', '.', '.', '*'),
  Array('.', '.', '.', '.', '.', '.', '*', '.', '*', '.'),
  Array('.', '.', '.', '.', '.', '.', '*', '.', '*', '.'),
  Array('.', '*', '*', '*', '*', '*', '*', '*', '*', '.'),
  Array('.', '.', '.', '.', '.', '.', '*', '.', '*', '.')
)

val board = Board.initBoardLayout(arr)
/*
Board(10, '.', '*', HashSet(
  Slot((6, 8), false, 4, Set(2), ""),
  Slot((3, 1), true, 6, Set(2, 5), ""),
  Slot((3, 6), false, 7, Set(0, 5), ""),
  Slot((1, 9), false, 5, Set(0), ""),
  Slot((1, 3), false, 5, Set(0, 2, 4), ""),
  Slot((8, 1), true, 8, Set(5, 7), ""),
  Slot((1, 2), true, 8, Set(1, 7), ""),
  Slot((5, 0), true, 4, Set(3), "")
))
*/

board.printArr()
/*
. . . . . . . . . . 
. . * * * * * * * * 
. . . * . . . . . * 
. * * * * * * . . * 
. . . * . . * . . * 
* * * * . . * . . * 
. . . . . . * . * . 
. . . . . . * . * . 
. * * * * * * * * . 
. . . . . . * . * . 
*/

Solving the crossword puzzle

The core crossword solving logic is handled by method fillSlotsWithWords() placed within the body of case class Board.

case class Board private(sz: Int, bgCh: Char = '.', spCh: Char = '*', slots: Set[Slot] = Set()) {
  private val arr: Array[Array[Char]] = Array.ofDim(sz, sz)

  ...

  // Fill slots with words of matching lengths & junction characters
  def fillSlotsWithWords(words: Array[String]): Board = {
    val wordMap: Map[Int, Array[String]] = words.groupMap(_.length)(identity)
    val wLenProd: Int = wordMap.map{ case (_, ws) => ws.size }.product
    val trials: Int = wLenProd * wLenProd
    // Prioritize slots with `len` of the smallest by-len group and maximal number of `jctIdxs`
    val orderedSlots = slots.groupMap(_.len)(identity).toList.
      flatMap{ case (_, ss) => ss.map((ss.size, _)) }.
      sortBy{ case (k, slot) => (k, -slot.jctIdxs.size) }.
      map{ case (_, slot) => slot }
    val jctMap: Map[(Int, Int), Char] = slots.
      map(slot => slot.jctIdxs.map(Board.idxToCoords(slot.start, slot.horiz, _))).
      reduce(_ union _).map(_->' ').toMap
    def loop(slotList: List[Slot],
             slotSet: Set[Slot],
             wMap: Map[Int, Array[String]],
             jMap: Map[(Int, Int), Char],
             runs: Int): Set[Slot] = {
      if (runs == 0) {  // Done trying!
        logError(s"FAILURE: Tried $trials times ...", slotList, wMap, jMap)
        slotSet
      }
      else {
        slotList match {
          case Nil =>
            slotSet  // Success!
          case slot :: others =>
            val wordsWithLen = wMap.get(slot.len)
            wordsWithLen match {
              case None =>
                logError(s"FAILURE: Missing words of length ${slot.len}!", slotList, wMap, jMap)
                slotSet
              case Some(words) =>
                words.find{ word =>
                  slot.jctIdxs.forall{ idx =>
                    val jCoords = Board.idxToCoords(slot.start, slot.horiz, idx)
                    jMap(jCoords) == ' ' || word(idx) == jMap(jCoords)
                  }
                }
                match {
                  case Some(w) =>
                    val kvs = slot.jctIdxs.map { idx =>
                      Board.idxToCoords(slot.start, slot.horiz, idx) -> w(idx)
                    }
                    loop(others, slotSet - slot + slot.copy(word = w), wMap, jMap ++ kvs, runs-1)
                  case None =>
                    val newWMap = wMap + (slot.len -> (words.tail :+ words.head))
                    // Restart the loop with altered wordMap
                    loop(orderedSlots, slots, newWMap, jctMap, runs-1)
                }
            }
        }
      }
    }
    val newBoard = copy(slots = loop(orderedSlots, Set(), wordMap, jctMap, wLenProd * wLenProd))
    (0 until sz).foreach{ i => newBoard.arr(i) = arr(i) }
    newBoard.updateArrayFromSlots()
  }

  ...
}

Method fillSlotsWithWords() takes an array of words as the parameter. Those words are supposed to fit the slots of a given initialized Board. The method creates an optimally ordered List of the slots and a Map of words grouped by the length of the words with each key associated with a list of words of the same length. In addition, it also assembles a Map of slot junctions as a helper dataset for matching intersecting characters by XY-coordinates.

The method then uses a recursive loop() to fill slotList and shuffle the wordMap as needed. Let me elaborate a little bit:

  • Optimally ordered slots – Slots are pre-ordered so that those with the most unique slot-length and the most slot junctions will be processed first. The idea is to fill upfront the most slots with certainty (e.g. those with unique length) and lock down the most slot junction characters as subsequent matching criteria for the intersecting slots.
  • Order shuffling – During the recursive processing, when a slot of a given length can no longer be filled with the remaining list of slots, the list of words of that length will be shuffled and the loop() will be reset to run with the altered wordMap.

Finally, since it’s possible that the given crossword puzzle slot layout and words might not have a solution, a limit in the number of trials of running the recursive loop() is provided as a safety measure to avoid an infinite loop.

Example:

val words = Array("mandarin", "apple", "nance", "papaya", "avocado", "date", "kiwi", "honeydew")

val filledBoard = board.fillSlotsWithWords(words)
/*
Board(10, '.', '*', HashSet(
  Slot((1, 9), false, 5, Set(0), "nance"),
  Slot((3, 6), false, 7, Set(0, 5), "avocado"),
  Slot((5, 0), true, 4, Set(3), "date"),
  Slot((8, 1), true, 8, Set(5, 7), "honeydew"),
  Slot((6, 8), false, 4, Set(2), "kiwi"),
  Slot((3, 1), true, 6, Set(2, 5), "papaya"),
  Slot((1, 3), false, 5, Set(0, 2, 4), "apple"),
  Slot((1, 2), true, 8, Set(1, 7), "mandarin")
))
*/

filledBoard.printArr()
/*
. . . . . . . . . . 
. . m a n d a r i n 
. . . p . . . . . a 
. p a p a y a . . n 
. . . l . . v . . c 
d a t e . . o . . e 
. . . . . . c . k . 
. . . . . . a . i . 
. h o n e y d e w . 
. . . . . . o . i . 
*/

Final thoughts

Appended is the complete source code of the classes for the crossword puzzle.

Obviously there are many different ways to formulate and solve a puzzle game like this. What’s being illustrated here is a brute-force approach, as the order shuffling routine upon hitting a wall within the slot-filling recursive loop has to reset the recursion process. The good news is that, having tested it with a dozen of random examples (admittedly a small sample), the prioritization strategy (by optimally picking slots to be evaluated) does prove to help a great deal in efficiently solving the sample games.

case class Slot(start: (Int, Int), horiz: Boolean, len: Int, jctIdxs: Set[Int], word: String = "")

object Board {
  // Initialize a board's array from provided background char, slot space char & slots
  def apply(sz: Int, bgCh: Char = '.', spCh: Char = '*', slots: Set[Slot] = Set()): Board = {
    val board = new Board(sz, bgCh, spCh, slots)
    val slotCoords = slots.toList.flatMap{ slot =>
      val (i, j) = idxToCoords(slot.start, slot.horiz, slot.len - 1)
      List(i, j)
    }
    require(slots.isEmpty || slots.nonEmpty && slotCoords.max < sz, s"ERROR: $slots cannot be contained in ${board.arr}!")
    for (i <- 0 until sz; j <- 0 until sz) {
      board.arr(i)(j) = bgCh
    }
    slots.foreach{ slot =>
      val (i, j) = (slot.start._1, slot.start._2)
      (0 until slot.len).foreach{ k =>
        val (i, j) = idxToCoords(slot.start, slot.horiz, k)
        board.arr(i)(j) = if (slot.word.isEmpty) spCh else slot.word(k)
      }
    }
    board
  }

  // Convert an index of a slot into coordinates
  def idxToCoords(start: (Int, Int), horiz: Boolean, idx: Int): (Int, Int) = {
    if (horiz) (start._1, start._2 + idx) else (start._1 + idx, start._2)
  }

  // Initialize a board and create slots from board array with ONLY layout
  def initBoardLayout(layout: Array[Array[Char]], bg: Char = '.', sp: Char = '*'): Board = {
    val sz = layout.length
    def findSlot(i: Int, j: Int): Option[Slot] = {
      if (j < sz-1 && layout(i)(j+1) == sp) {  // Horizontal
        val ln = Iterator.from(j).takeWhile(k => k < sz && layout(i)(k) == sp).size
        val js = (0 until ln).collect{
          case k if (i>=0+1 && layout(i-1)(j+k)!=bg) || (i<sz-1 && layout(i+1)(j+k)!=bg) => k
        }.toSet
        Option.when(ln > 1)(Slot((i, j), true, ln, js))
      }
      else {  // Vertical
        val ln = Iterator.from(i).takeWhile(k => k < sz && layout(k)(j) == sp).size
        val js = (0 until ln).collect{
          case k if (j>=0+1 && layout(i+k)(j-1)!=bg) || (j<sz-1 && layout(i+k)(j+1)!=bg) => k
        }.toSet
        Option.when(ln > 1)(Slot((i, j), false, ln, js))
      }
    }
    def createSlots(i: Int, j: Int, slots: Set[Slot], mkCh: Char): Set[Slot] = {
      if (i == sz) slots
      else {
        if (j == sz)
          createSlots(i+1, 0, slots, mkCh)
        else {
          findSlot(i, j) match {
            case Some(slot) =>
              val jctCoords = slot.jctIdxs.map{ idx =>
                Board.idxToCoords(slot.start, slot.horiz, idx)
              }
              (0 until slot.len).foreach { k =>
                val (i, j) = Board.idxToCoords(slot.start, slot.horiz, k)
                if (!jctCoords.contains((i, j)))
                  layout(i)(j) = mkCh
              }
              createSlots(i, j+1, slots + slot, mkCh)
            case None =>
              createSlots(i, j+1, slots, mkCh)
          }
        }
      }
    }
    val mkCh = '\u0000'
    val newBoard = Board(sz, bg, sp).copy(slots = createSlots(0, 0, Set(), mkCh))
    (0 until sz).foreach{ i =>
      newBoard.arr(i) = layout(i).map{ ch => if (ch == mkCh) sp else ch }
    }
    newBoard
  }
}

case class Board private(sz: Int, bgCh: Char = '.', spCh: Char = '*', slots: Set[Slot] = Set()) {
  private val arr: Array[Array[Char]] = Array.ofDim(sz, sz)

  // Print content of a provided array
  def printArr(a: Array[Array[Char]] = arr): Unit =
    for (i <- 0 until sz) {
      for (j <- 0 until sz) {
        print(s"${a(i)(j)} ")
        if (a(i)(j) == '\u0000') print(" ")  // Print a filler space for null char
      }
      println()
    }

  // Report error messages
  def logError(msg: String, sList: List[Slot], wMap: Map[Int, Array[String]], jMap: Map[(Int, Int), Char]): Unit = {
    println(msg)
    println(s"Remaining slots: $sList")
    println(s"Latest wordMap: $wMap")
    println(s"Latest jctMap: $jMap")
  }

  // Fill slots with words of matching lengths & junction characters
  def fillSlotsWithWords(words: Array[String]): Board = {
    val wordMap: Map[Int, Array[String]] = words.groupMap(_.length)(identity)
    val wLenProd: Int = wordMap.map{ case (_, ws) => ws.size }.product
    val trials: Int = wLenProd * wLenProd
    // Prioritize slots with `len` of the smallest by-len group and maximal number of `jctIdxs`
    val orderedSlots = slots.groupMap(_.len)(identity).toList.
      flatMap{ case (_, ss) => ss.map((ss.size, _)) }.
      sortBy{ case (k, slot) => (k, -slot.jctIdxs.size) }.
      map{ case (_, slot) => slot }
    val jctMap: Map[(Int, Int), Char] = slots.
      map(slot => slot.jctIdxs.map(Board.idxToCoords(slot.start, slot.horiz, _))).
      reduce(_ union _).map(_->' ').toMap
    def loop(slotList: List[Slot],
             slotSet: Set[Slot],
             wMap: Map[Int, Array[String]],
             jMap: Map[(Int, Int), Char],
             runs: Int): Set[Slot] = {
      if (runs == 0) {  // Done trying!
        logError(s"FAILURE: Tried $trials times ...", slotList, wMap, jMap)
        slotSet
      }
      else {
        slotList match {
          case Nil =>
            slotSet  // Success!
          case slot :: others =>
            val wordsWithLen = wMap.get(slot.len)
            wordsWithLen match {
              case None =>
                logError(s"FAILURE: Missing words of length ${slot.len}!", slotList, wMap, jMap)
                slotSet
              case Some(words) =>
                words.find{ word =>
                  slot.jctIdxs.forall{ idx =>
                    val jCoords = Board.idxToCoords(slot.start, slot.horiz, idx)
                    jMap(jCoords) == ' ' || word(idx) == jMap(jCoords)
                  }
                }
                match {
                  case Some(w) =>
                    val kvs = slot.jctIdxs.map { idx =>
                      Board.idxToCoords(slot.start, slot.horiz, idx) -> w(idx)
                    }
                    loop(others, slotSet - slot + slot.copy(word = w), wMap, jMap ++ kvs, runs-1)
                  case None =>
                    val newWMap = wMap + (slot.len -> (words.tail :+ words.head))
                    // Restart the loop with altered wordMap
                    loop(orderedSlots, slots, newWMap, jctMap, runs-1)
                }
            }
        }
      }
    }
    val newBoard = copy(slots = loop(orderedSlots, Set(), wordMap, jctMap, wLenProd * wLenProd))
    (0 until sz).foreach{ i => newBoard.arr(i) = arr(i) }
    newBoard.updateArrayFromSlots()
  }

  // Update board array from slots
  def updateArrayFromSlots(): Board = {
    slots.foreach{ slot =>
      (0 until slot.len).foreach{ k =>
        val (i, j) = Board.idxToCoords(slot.start, slot.horiz, k)
        arr(i)(j) = if (slot.word.isEmpty) spCh else slot.word(k)
      }
    }
    this
  }
}

Algorand NFTs With IPFS Assets

As of this writing, the overall cryptocurrency sector is undergoing a major downturn in which about two-third of the average blockchain’s market cap has evaporated since early April. This might not be the best time to try make anyone excited about launching NFTs on any blockchain. Nevertheless, volatility of cryptocurrencies has always been a known phenomenon. From a technological perspective, it’s much less of a concern than how well-designed the underlying blockchain is in terms of security, decentralization and scalability.

Many popular blockchain platforms out there are offering their own open-standard crypto tokens, but so far the most prominent crypto tokens, fungible or non-fungible, are Ethereum-based. For instance, the NFTs we minted on Avalanche‘s Fuji testnet in a previous blog post are Ethereum-based and ERC-721 compliant.

Given Ethereum’s large market share, dApp developers generally prefer having their NFTs transacting on Ethereum main chain or Ethereum-compatible chains like Polygon and Avalanche’s C-Chain, yet many others opt to pick incompatible chains as their NFT development/trading platforms.

Choosing a blockchain for NFT development

Use cases of NFT are mostly about provenance of authenticity. To ensure that the NFT related transactions to be verifiable at any point of time, the perpetual availability of the blockchain in which the transaction history reside is critical. Though not often, hard forks do happen, consequently rendering historical transactions forking off the main chain. That’s highly undesirable. Some blockchains such as Algorand and Polkadot tout that their chains are by-design “forkless”, which does provide an edge over competitors.

Another factor critical for transacting NFTs on a blockchain is low latency in consensually finalizing transactions. That latency is generally referred to as finality. Given that auctions are commonly time-sensitive events for trading of NFTs, a long delay is obviously ill-suited. Chains like Avalanche and Algorand are able to keep the finality under 5 seconds which is much shorter compared to 10+ minutes required on other blockchains like Cardano or Ethereum.

Algorand and IPFS

Launched 3 years ago in June 2019, Algorand is a layer-1 blockchain with a focus on being highly decentralized, scalable and secured with low transaction cost. Its low latency (i.e. finality) along with a design emphasis in running the blockchain with a forkless operational model makes it an appealing chain for transacting and securing NFTs.

In this blog post, we’re going to create a simple dApp in JavaScript to mint a NFT on the Algorand Testnet for a digital asset pinned to IPFS. IPFS, short for InterPlanetary File System, is a decentralized peer-to-peer network aimed to serve as a single resilient global network for storing and sharing files.

Algorand NFT

Algorand comes with its standard asset class, ASA (Algorand Standard Assets), which can be used for representing a variety of customizable digital assets. A typical NFT can be defined configuratively as an ASA by assigning asset parameters with values that conform to Algorand’s NFT specifications.

The two common Algorand specs for NFTs are ARC-3 and ARC-69. A main difference between the two specs is that ARC-69 asset data is kept on-chain whereas ARC-3’s isn’t. We’ll be minting an ARC-3 NFT with the digital asset stored on IPFS.

Contrary to the previous NFT development exercise on Avalanche that leverages a rich-UI stack (i.e. Scaffold-ETH), this is going to be a barebone proof-of-concept with core focus on how to programmatically create a NFT for a digital asset (an digital image) pinned to IPFS. No web frontend UI.

Algorand SDKs

Algorand offers SDKs for a few programming languages including JavaScript, Python, Go and Java. We’ll be using the JavaScript SDK. The official developer website provides tutorials for various dApp use cases and one of them is exactly about launching ARC-3 NFT for assets on IPFS. Unfortunately, even though the tutorial is only about 6 months old it no longer works due to some of its code already being obsolete.

It’s apparently a result of the rapidly evolving SDK code — a frustrating but common problem for developers to have to constantly play catch-up game with backward incompatible APIs evolving at a breakneck pace. For example, retrieval of user-level information is no longer supported by the latest Algorand SDK client (algod client), but no where could I find any tutorials doing it otherwise. Presumably for scalability, it turns out such queries are now delegated to an Algorand indexer which runs as an independent process backed by a PostgreSQL compatible database.

Given the doubt of the demo code on the Algorand website being out of date, I find it more straight forward (though a little tedious) to pick up the how-to’s by directly digging into the SDK source code js-algorand-sdk. For instance, one could quickly skim through the method signature and implementation logic of algod client method pendingTransactionInformation from the corresponding source or indexer method lookupAccountByID from indexer’s source for their exact tech specs.

Create a NPM project with dependencies

Minimal requirements for this Algorand NFT development exercise include the following:

  • Node.js installed with NPM
  • An account at Pinata, an IPFS pinning service
  • An Algorand compatible crypto wallet (Pera is preferred for the availability of a “developer” mode)

First, create a subdirectory as the project root. For example:

$ mkdir ~/algorand/algo-nft-ipfs/
$ cd ~/algorand/algo-nft-ipfs/

Next, create dependency file package.json with content like below:

{
    "name": "algo-nft-ipfs",
    "version": "1.0.0",
    "description": "Algorand NFT with asset pinned to IPFS",
    "scripts": {
        "mint": "node algo-nft-ipfs.js"
    },
    "dependencies": {
        "@algonaut/algo-validation-agent": "latest",
        "@pinata/sdk": "latest",
        "algosdk": "latest",
        "bs58": "latest",
        "dotenv": "latest",
        "ipfs-core": "latest",
        "ipfs-http-client": "latest",
        "node-base64-image": "latest"
    },
    "devDependencies": {
        "nodemon": "latest",
        "parcel-bundler": "latest"
    },
    "keywords": []
}

Install the NPM package:

$ npm install

Create file “.env” for keeping private keys

Besides the SDKs for Pinata and Algorand, dotenv is also included in the package.json dependency file, allowing variables such as NFT recipient’s wallet mnemonic, algod client/indexer URLs (for Algorand Testnet) and Pinata API keys, to be kept in file .env in the filesystem.

mnemonic = ""
algodClientUrl = "https://node.testnet.algoexplorerapi.io"
algodClientPort = ""
algodClientToken = ""
indexerUrl = "https://algoindexer.testnet.algoexplorerapi.io"
indexerPort = ""
indexerToken = ""
pinataApiKey = ""
pinataApiSecret = ""

Note that Algorand uses a 25-word mnemonic with the 25th word derived from the checksum out of selected words within the 24-word BIP39-compliant mnemonic. Many blockchains simply use 12-word BIP39 mnemonics.

ARC-3 NFT metadata

Next, we create file assetMetadata.js for storing algorand ARC-3 specific metadata:

module.exports = {
    arc3MetadataJson: {
        "name": "",
        "description": "",
        "image": "ipfs://",
        "image_integrity": "sha256-",
        "image_mimetype": "",
        "external_url": "",
        "external_url_integrity": "",
        "external_url_mimetype": "",
        "animation_url": "",
        "animation_url_integrity": "sha256-",
        "animation_url_mimetype": "",
        "properties": {
            "file_url": "",
            "file_url_integrity": "",
            "file_url_mimetype": "",
        }
    }
}

Main application logic

The main function createNft does a few things:

  • Create an account object from the wallet mnemonic stored in file .env
  • Create an digital asset (an image) pinned to IPFS
  • Create an ARC-3 compliant NFT associated with the pinned asset

const createNft = async () => {
  try {
    let account = createAccount();

    console.log("Press any key when the account is funded ...");
    await keypress();

    const asset = await createAssetOnIpfs();

    const { assetID } = await createArc3Asset(asset, account);
  }
  catch (err) {
    console.log("err", err);
  };

  process.exit();
};

Account object creation

Function createAccount is self explanatory. It retrieves the wallet mnemonic from file .env, derives from it the secret key and wallet address (public key) and display a reminder to make sure the account is funded with Algorand Testnet tokens as fees for transactions.

const createAccount = () => {
  try {
    const mnemonic = process.env.mnemonic
    const account = algosdk.mnemonicToSecretKey(mnemonic);

    console.log("Derived account address = " + account.addr);
    console.log("To add funds to the account, visit https://dispenser.testnet.aws.algodev.network/?account=" + account.addr);

    return account;
  }
  catch (err) {
    console.log("err", err);
  }
};

Pinning a digital asset to IPFS

Function createAssetOnIpfs is responsible for creating and pinning a digital asset to IPFS. This is where the asset attributes including source file path, description, MIME type (e.g. image/png, video/mp4) will be provided. In this example a ninja smiley JPEG under the project root is being used as a placeholder. Simply substitute it with your favorite image, video, etc.

const createAssetOnIpfs = async () => {
  return await pinata.testAuthentication().then((res) => {
    console.log('Pinata test authentication: ', res);
    return assetPinnedToIpfs(
      'smiley-ninja-896x896.jpg',
      'image/jpeg',
      'Ninja Smiley',
      'Ninja Smiley 896x896 JPEG image pinned to IPFS'
    );
  }).catch((err) => {
    return console.log(err);
  });
}

The actual pinning work is performed by function assetPinnedToIpfs which returns identifying IPFS URL for the digital asset’s metadata.

const assetPinnedToIpfs = async (nftFilePath, mimeType, assetName, assetDesc) => {
  const nftFile = fs.createReadStream(nftFilePath);
  ...

  const pinMeta = {
    pinataMetadata: {
      name: assetName,
      ...
    },
    pinataOptions: {
      cidVersion: 0
    }
  };

  const resultFile = await pinata.pinFileToIPFS(nftFile, pinMeta);

  let metadata = assetMetadata.arc3MetadataJson;

  const integrity = ipfsHash(resultFile.IpfsHash);

  metadata.name = `${assetName}@arc3`;
  metadata.description = assetDesc;
  metadata.image = `ipfs://${resultFile.IpfsHash}`;
  metadata.image_integrity = `${integrity.cidBase64}`;
  metadata.image_mimetype = mimeType;
  metadata.properties = ...
  ...

  const resultMeta = await pinata.pinJSONToIPFS(metadata, pinMeta);
  const metaIntegrity = ipfsHash(resultMeta.IpfsHash);

  return {
    name: `${assetName}@arc3`,
    url: `ipfs://${resultMeta.IpfsHash}`,
    metadata: metaIntegrity.cidUint8Arr,
    integrity: metaIntegrity.cidBase64
  };
};

Creating an Alogrand ARC-3 NFT

Function createNft is just an interfacing wrapper of the createArc3Asset function that does the actual work of initiating and signing the transactions on the Algorand Testnet for creating the ARC-3 NFT associated with the pinned asset using algod client.

const createArc3Asset = async (asset, account) => {
  (async () => {
    let acct = await indexerClient.lookupAccountByID(account.addr).do();
    console.log("Account Address: " + acct['account']['address']);
    ...
  })().catch(e => {
    console.error(e);
    console.trace();
  });

  const txParams = await algodClient.getTransactionParams().do();

  const txn = algosdk.makeAssetCreateTxnWithSuggestedParamsFromObject({
    from: account.addr,
    total: 1,
    decimals: 0,
    ...
    unitName: 'nft',
    assetName: asset.name,
    assetURL: asset.url,
    assetMetadataHash: new Uint8Array(asset.metadata),
    suggestedParams: txParams
  });

  const rawSignedTxn = txn.signTxn(account.sk);
  const tx = await algodClient.sendRawTransaction(rawSignedTxn).do();

  const confirmedTxn = await waitForConfirmation(tx.txId);
  const txInfo = await algodClient.pendingTransactionInformation(tx.txId).do();

  const assetID = txInfo["asset-index"];

  console.log('Account ', account.addr, ' has created ARC3 compliant NFT with asset ID', assetID);
  console.log(`Check it out at https://testnet.algoexplorer.io/asset/${assetID}`);

  return { assetID };
}

Note that element values total:1 and decimals:0 are for ensuring uniqueness of the NFT. Also worth noting is that Algorand SDK provides a waitForConfirmation() function for awaiting transaction confirmation for a specified number of rounds:

const confirmedTxn = await algosdk.waitForConfirmation(algodClient, txId, waitRounds);

However, for some unknown reason, it doesn’t seem to work with a fixed waitRounds, thus a custom function seen in a demo app on Algorand’s website is being used instead. The custom code simply verifies returned value from algod client method pendingTransactionInformation(txId) in a loop.

Putting everything together

For simplicity, the above code logic is all put in a single JavaScript script algo-nft-ipfs.js under the project root.

const fs = require('fs');
const path = require('path');
const algosdk = require('algosdk');
const bs58 = require('bs58');

require('dotenv').config()
const assetMetadata = require('./assetMetadata');

const algodClient = new algosdk.Algodv2(
  process.env.algodClientToken,
  process.env.algodClientUrl,
  process.env.algodClientPort
);

const indexerClient = new algosdk.Indexer(
  process.env.indexerToken,
  process.env.indexerUrl,
  process.env.indexerPort
);

const pinataApiKey = process.env.pinataApiKey;
const pinataApiSecret = process.env.pinataApiSecret;
const pinataSdk = require('@pinata/sdk');
const pinata = pinataSdk(pinataApiKey, pinataApiSecret);

const keypress = async () => {
  process.stdin.setRawMode(true);
  return new Promise(resolve => process.stdin.once('data', () => {
    process.stdin.setRawMode(false)
    resolve()
  }));
};

const waitForConfirmation = async (txId) => {
  const status = await algodClient.status().do();
  let lastRound = status["last-round"];
  let txInfo = null;

  while (true) {
    txInfo = await algodClient.pendingTransactionInformation(txId).do();
    if (txInfo["confirmed-round"] !== null && txInfo["confirmed-round"] > 0) {
      console.log("Transaction " + txId + " confirmed in round " + txInfo["confirmed-round"]);
      break;
    }
    lastRound ++;
    await algodClient.statusAfterBlock(lastRound).do();
  }

  return txInfo;
}

const createAccount = () => {
  try {
    const mnemonic = process.env.mnemonic
    const account = algosdk.mnemonicToSecretKey(mnemonic);

    console.log("Derived account address = " + account.addr);
    console.log("To add funds to the account, visit https://dispenser.testnet.aws.algodev.network/?account=" + account.addr);

    return account;
  }
  catch (err) {
    console.log("err", err);
  }
};

const ipfsHash = (cid) => {
  const cidUint8Arr = bs58.decode(cid).slice(2);
  const cidBase64 = cidUint8Arr.toString('base64');
  return { cidUint8Arr, cidBase64 };
};

const assetPinnedToIpfs = async (nftFilePath, mimeType, assetName, assetDesc) => {
  const nftFile = fs.createReadStream(nftFilePath);
  const nftFileName = nftFilePath.split('/').pop();
  
  const properties = {
    "file_url": nftFileName,
    "file_url_integrity": "",
    "file_url_mimetype": mimeType
  };

  const pinMeta = {
    pinataMetadata: {
      name: assetName,
      keyvalues: {
        "url": nftFileName,
        "mimetype": mimeType
      }
    },
    pinataOptions: {
      cidVersion: 0
    }
  };

  const resultFile = await pinata.pinFileToIPFS(nftFile, pinMeta);
  console.log('Asset pinned to IPFS via Pinata: ', resultFile);

  let metadata = assetMetadata.arc3MetadataJson;

  const integrity = ipfsHash(resultFile.IpfsHash);

  metadata.name = `${assetName}@arc3`;
  metadata.description = assetDesc;
  metadata.image = `ipfs://${resultFile.IpfsHash}`;
  metadata.image_integrity = `${integrity.cidBase64}`;
  metadata.image_mimetype = mimeType;
  metadata.properties = properties;
  metadata.properties.file_url = `https://ipfs.io/ipfs/${resultFile.IpfsHash}`;
  metadata.properties.file_url_integrity = `${integrity.cidBase64}`;

  console.log('Algorand NFT-IPFS metadata: ', metadata);

  const resultMeta = await pinata.pinJSONToIPFS(metadata, pinMeta);
  const metaIntegrity = ipfsHash(resultMeta.IpfsHash);
  console.log('Asset metadata pinned to IPFS via Pinata: ', resultMeta);

  return {
    name: `${assetName}@arc3`,
    url: `ipfs://${resultMeta.IpfsHash}`,
    metadata: metaIntegrity.cidUint8Arr,
    integrity: metaIntegrity.cidBase64
  };
};

const createAssetOnIpfs = async () => {
  return await pinata.testAuthentication().then((res) => {
    console.log('Pinata test authentication: ', res);
    return assetPinnedToIpfs(
      'smiley-ninja-896x896.jpg',
      'image/jpeg',
      'Ninja Smiley',
      'Ninja Smiley 896x896 JPEG image pinned to IPFS'
    );
  }).catch((err) => {
    return console.log(err);
  });
}

const createArc3Asset = async (asset, account) => {
  (async () => {
    let acct = await indexerClient.lookupAccountByID(account.addr).do();
    console.log("Account Address: " + acct['account']['address']);
    console.log("         Amount: " + acct['account']['amount']);
    console.log("        Rewards: " + acct['account']['rewards']);
    console.log(" Created Assets: " + acct['account']['total-created-assets']);
    console.log("  Current Round: " + acct['current-round']);
  })().catch(e => {
    console.error(e);
    console.trace();
  });

  const txParams = await algodClient.getTransactionParams().do();

  const txn = algosdk.makeAssetCreateTxnWithSuggestedParamsFromObject({
    from: account.addr,
    total: 1,
    decimals: 0,
    defaultFrozen: false,
    manager: account.addr,
    reserve: undefined,
    freeze: undefined,
    clawback: undefined,
    unitName: 'nft',
    assetName: asset.name,
    assetURL: asset.url,
    assetMetadataHash: new Uint8Array(asset.metadata),
    suggestedParams: txParams
  });

  const rawSignedTxn = txn.signTxn(account.sk);
  const tx = await algodClient.sendRawTransaction(rawSignedTxn).do();

  // const confirmedTxn = await algosdk.waitForConfirmation(algodClient, tx, 4);
  // /* Error: Transaction not confirmed after 4 rounds */
  const confirmedTxn = await waitForConfirmation(tx.txId);
  const txInfo = await algodClient.pendingTransactionInformation(tx.txId).do();

  const assetID = txInfo["asset-index"];

  console.log('Account ', account.addr, ' has created ARC3 compliant NFT with asset ID', assetID);
  console.log(`Check it out at https://testnet.algoexplorer.io/asset/${assetID}`);

  return { assetID };
}

const createNft = async () => {
  try {
    let account = createAccount();

    console.log("Press any key when the account is funded ...");
    await keypress();

    const asset = await createAssetOnIpfs();

    const { assetID } = await createArc3Asset(asset, account);
  }
  catch (err) {
    console.log("err", err);
  };

  process.exit();
};

createNft();

Minting the Algorand NFT

To mint the ARC-3 compliant NFT:

$ npm run mint

Upon successful minting of the NFT, you’ll see messages similar to the following with a reminder for where to look up for details about the NFT and its associated transactions on the Algorand Testnet:

Account  has created ARC3 compliant NFT with asset ID: 
Check it out at https://testnet.algoexplorer.io/asset/

Verifying …

From the algoexplorer.io website, the URL of the IPFS metadata file for the NFT should look like below:

ipfs://

Value should match the hash value of the pinned asset metadata file under your Pinata account and should be viewable at:

https://gateway.pinata.cloud/ipfs/

There are a few Algorand compatible wallets with Pera being the one created by the Algorand’s development team. For developers, Pera has a convenient option for switching between the Algorand Mainnet and Testnet. To verify the receipt of the NFT, simply switch to Algorand Testnet (under Settings > Developer Settings > Node Settings).

Here’s what the received NFT in a Pera wallet would look like:

Pera - Ninja Smiley ARC3 NFT

From within Pera Explorer:

Pera Explorer - Ninja Smiley ARC3