k-singh / plasma-toolkit   1.0.4

GitHub

An Ergo-Appkit based library providing an abstraction layer to easily interact with AVL Trees as an L2 Solution

Scala versions: 2.12

Plasma Toolkit

Plasma Toolkit is a library on top of Ergo Appkit that provides an abstraction layer to simplify the process of integrating AVL Trees (AKA Plasma) into off-chain code. The goal is to give developers an easy way to use this Layer-2 scaling solution in contracts, off-chain code, and distributed systems managing the Plasma itself. Plasma Toolkit uses the default versioned storage implementation powered by LevelDB, with another SwayDB implementation in the work. This allows for distributed systems to keep track of the key-value pairs held in digests stored on-chain.

Details

Creating and managing AVL Trees is greatly simplified when using the library. To create a normal, un-stored / temporary AVL Tree, we use Plasma Maps. Plasma Maps look similar to normal Scala maps on the surface with a few changes that make them compatible with on-chain AVL Trees.

import org.ergoplatform.sdk.ErgoId
import sigmastate.{AvlTreeFlags, Values}
import work.lithos.plasma.PlasmaParameters
import work.lithos.plasma.collections.PlasmaMap
// Plasma Map that uses ErgoId's as keys, and ErgoTrees as values
val plasmaMap = new PlasmaMap[ErgoId, Values.ErgoTree](AvlTreeFlags.AllOperationsAllowed, PlasmaParameters.default)

All Plasma Maps use 32 byte digests and Blake2b256 hashing. Any class may be inserted into a Plasma Map so long as there is a corresponding implicit ByteConversion for that class.

import org.ergoplatform.sdk.ErgoId
import sigmastate.Values
import sigmastate.serialization.ErgoTreeSerializer
import work.lithos.plasma.ByteConversion

// Default ByteConversions for ErgoId and ErgoTree
implicit val convertsId: ByteConversion[ErgoId] = new ByteConversion[ErgoId] {
  override def convertToBytes(t: ErgoId): Array[Byte] = t.getBytes

  override def convertFromBytes(bytes: Array[Byte]): ErgoId = new ErgoId(bytes)
}

implicit val convertsErgoTree: ByteConversion[Values.ErgoTree] = new ByteConversion[Values.ErgoTree] {
  override def convertToBytes(t: Values.ErgoTree): Array[Byte] = t.bytes

  override def convertFromBytes(bytes: Array[Byte]): Values.ErgoTree = ErgoTreeSerializer.DefaultSerializer.deserializeErgoTree(bytes)
}

Custom classes may also be used with their own definitions to allow for flexibility in contracts:

import com.google.common.primitives.{Ints, Longs}
import org.bouncycastle.util.encoders.Hex
import org.ergoplatform.appkit.{ErgoType, ErgoValue}
import sigmastate.eval.Colls
import special.collection.Coll
import work.lithos.plasma.ByteConversion

case class StateScore(score: Long, paid: Boolean) {
  def toBytes: Array[Byte] = Longs.toByteArray(score) ++ Array(if(paid) 1.toByte else 0.toByte)
}

def getPaid(byte: Byte): Boolean = {
  byte match {
    case 0 =>
      false
    case 1 =>
      true
    case _ =>
      throw new Exception("A payment byte was serialized incorrectly!")
  }
}
  implicit val scoreConversion: ByteConversion[StateScore] = new ByteConversion[StateScore] {
    override def convertToBytes(t: StateScore): Array[Byte] = t.toBytes

    override def convertFromBytes(bytes: Array[Byte]): StateScore = StateScore(Longs.fromByteArray(bytes.slice(0, 8)), getPaid(bytes.slice(8, 9).head))
  }

Once a Plasma Map is created, operations may be performed on it. All operations done on a Plasma Map return some OpResult that wraps the Plasma Map's value field. The set of these OpResults are returned in a ProvenResult, which holds the returned values along with a Proof object that holds the corresponding proof for the batched set of operations. OpResults are all returned in the order in which they were entered.

import org.ergoplatform.sdk.ErgoId
import sigmastate.AvlTreeFlags
import work.lithos.plasma.PlasmaParameters
import work.lithos.plasma.collections.{OpResult, PlasmaMap, Proof, ProvenResult}
import work.lithos.plasma.ByteConversion.convertsLongKey
val tokenMap = new PlasmaMap[ErgoId, Long](AvlTreeFlags.AllOperationsAllowed, PlasmaParameters.default)

val cometId: ErgoId = ErgoId.create("0cd8c9f416e5b1ca9f986a7f10a84191dfb85941619e49e53c0dc30ebf83324b")
val tokenData: Seq[(ErgoId, Long)] = Seq(cometId -> 100L)

val result: ProvenResult[Long] = tokenMap.insert(tokenData: _*)

val opResults: Seq[OpResult[Long]] = result.response
val proof: Proof = result.proof

All of these classes have functions to convert between common types used in Ergo Appkit. This allows for easy interactions between Plasma Map's and on-chain contracts.

import org.ergoplatform.appkit.{ErgoClient, ErgoContract, Parameters}
import org.ergoplatform.sdk.ErgoId
import work.lithos.plasma.collections.{PlasmaMap, Proof}
import work.lithos.plasma.ByteConversion.convertsLongKey
implicit val ergoClient: ErgoClient
implicit val myContract: ErgoContract
implicit val myMap: PlasmaMap[ErgoId, Long]
implicit val myProof: Proof
ergoClient.execute {
  ctx =>
    val txB = ctx.newTxBuilder()
    val outB = txB.outBoxBuilder()
    val outBox = outB
      .value(Parameters.OneErg)
      .contract(myContract)
      .registers(myMap.ergoValue, myProof.ergoValue)
      .build()
}

LocalPlasmaMap

Interacting with a locally stored Plasma Map is done in a similar way, except that you must use the LocalPlasmaMap class instead.

import scorex.crypto.authds.avltree.batch.VersionedLDBAVLStorage
import scorex.crypto.hash.{Blake2b256, Digest32}
import scorex.db.LDBVersionedStore
import sigmastate.{AvlTreeFlags, Values}
import work.lithos.plasma.PlasmaParameters
import work.lithos.plasma.collections.LocalPlasmaMap
import work.lithos.plasma.ByteConversion.convertsLongKey
import java.io.File

val ldbStore = new LDBVersionedStore(new File("./level"), 10)
val avlStorage = new VersionedLDBAVLStorage[Digest32](ldbStore, PlasmaParameters.default.toNodeParams)(Blake2b256)
val localMap = new LocalPlasmaMap[Long, Values.ErgoTree](avlStorage, AvlTreeFlags.AllOperationsAllowed, PlasmaParameters.default)

ProxyPlasmaMap

It can be useful to apply changes to a tree without necessarily committing to them. This is especially true in the context of chained transactions or unexpected errors. For example, if changes are applied to the tree but latency causes connection to the node to be lost, then the locally stored tree may have changes that do not exist on-chain!

To deal with this problem, you can use a ProxyPlasmaMap. This PlasmaMap applies changes on a temporary tree which allows you to receive proofs for the operations you perform. However, none of these changes are saved to storage until the commitChanges() function is called. This ensures that unexpected errors can be dealt with easily.

When dealing with the ProxyPlasmaMap, changes must first be explicitly enabled by calling initiate(). This function initializes the internal temporary map. Following this, operations may be performed on the map. All operations are applied to the temporary map, but are also kept track of inside an internal Queue. Once commitChanges() is called, the Queued operations are applied to persistent storage, and the temporary map is destroyed.

import org.ergoplatform.sdk.ErgoId
import scorex.crypto.authds.avltree.batch.VersionedLDBAVLStorage
import scorex.crypto.hash.{Blake2b256, Digest32}
import scorex.db.LDBVersionedStore
import sigmastate.{AvlTreeFlags, Values}
import work.lithos.plasma.PlasmaParameters
import work.lithos.plasma.collections.ProxyPlasmaMap
import work.lithos.plasma.ByteConversion.convertsLongKey
import java.io.File

val ldbStore = new LDBVersionedStore(new File("./level"), 10)
val avlStorage = new VersionedLDBAVLStorage[Digest32](ldbStore, PlasmaParameters.default.toNodeParams)(Blake2b256)
val proxyMap = new ProxyPlasmaMap[ErgoId, Long](avlStorage, AvlTreeFlags.AllOperationsAllowed, PlasmaParameters.default)
val ergopadId: ErgoId = ErgoId.create("d71693c49a84fbbecd4908c94813b46514b18b67a99952dc1e6e4791556de413")
val tokenDataErgoPad: Seq[(ErgoId, Long)] = Seq(ergopadId -> 100L)


// This will fail due to the ProxyMap being un-initiated
proxyMap.insert(tokenDataErgoPad: _*)

// Initiates operations on the tree
proxyMap.initiate()

// This will be successfully applied to the internal temporary tree, while also queueing this
// operation for later application into persistence
proxyMap.insert(tokenDataErgoPad: _*)

// This commits ALL of the changes made on the temporary tree into persistent storage, while also
// destroying the temporary tree
proxyMap.commitChanges()

val cometId: ErgoId = ErgoId.create("0cd8c9f416e5b1ca9f986a7f10a84191dfb85941619e49e53c0dc30ebf83324b")
val tokenDataComet: Seq[(ErgoId, Long)] = Seq(cometId -> 100L)

proxyMap.initiate()
proxyMap.insert(tokenDataComet: _*)

// This commits only the next operation that exists in the Queue. This does NOT destroy
// the temporary map.
proxyMap.commitNextOperation()

// This drops any uncommitted changes and destroys the temporary map. In this case,
// all changes were already committed since only one operation was performed.

proxyMap.dropChanges()