Please note that zkApp programmability is not yet available on Mina Mainnet, but zkApps can now be deployed to Berkeley Testnet.
Common Types and Functions
In previous tutorials, we've seen how to deploy smart contracts to the network, and we've interacted with them from both a React UI and NodeJS.
In this tutorial, we will talk about more types that are useful when building with SnarkyJS, so you can build more applications. So far, we've mostly been using the Field
type. SnarkyJS provides other higher-order types built from Fields, that will be useful for your development
You can find all of these on the SnarkyJS Reference page, for their full API documentation.
There is also a project here, with a main.ts demoing the concepts presented in this tutorial, along with smart contracts showing more advanced usage of some of the concepts, particularly Merkle Trees.
Basic Types
To start, we'll discuss 5 basic types derived from the Fields:
These each have their usual programming language semantics.
For example, we can have the following code:
const num1 = UInt32.from(40);
const num2 = UInt64.from(40);
const num1EqualsNum2: Bool = num1.toUInt64().equals(num2);
console.log(`num1 === num2: ${num1EqualsNum2.toString()}`);
console.log(`Fields in num1: ${num1.toFields().length}`);
// --------------------------------------
const signedNum1 = Int64.from(-3);
const signedNum2 = Int64.from(45);
const signedNumSum = signedNum1.add(signedNum2);
console.log(`signedNum1 + signedNum2: ${signedNumSum.toString()}`);
console.log(`Fields in signedNum1: ${signedNum1.toFields().length}`);
// --------------------------------------
const char1 = Character.fromString('c');
const char2 = Character.fromString('d');
console.log(`char1: ${char1.toString()}`);
console.log(`char1 === char2:: ${char1.equals(char2).toString()}`);
console.log(`Fields in char1: ${char1.toFields().length}`);
And when run it'll print to the console:
num1 === num2: true
Fields in num1: 1
signedNum1 + signedNum2: 42
Fields in signedNum1: 2
char1: c
char1 === char2: false
Fields in char1: 1
More Advanced Types
4 more advanced types are:
All arguments passed into smart contracts need to be arguments SnarkyJS can understand. This means that we can't just pass normal strings - we need to pass in strings that have been wrapped to be compatible with circuits, which is what CircuitValue
accomplishes.
One special thing to note, is that the default CircuitString
has a max length of 128 characters. SnarkyJS supports fixed length strings currently. Support for dynamic length strings will be added in the future after dynamic array access is available.
We'll see in the following section how to create custom types, where you could for example build your own strings, modified to whatever length you would like.
A brief example of using these:
const str1 = CircuitString.fromString('abc..xyz');
console.log(`str1: ${str1.toString()}`);
console.log(`Fields in str1: ${str1.toFields().length}`);
// --------------------------------------
const privateKey = PrivateKey.random();
const publicKey = privateKey.toPublicKey();
const data1 = char2.toFields().concat(signedNumSum.toFields())
const data2 = char1.toFields().concat(str1.toFields());
const signature = Signature.create(privateKey, data2);
const verifiedData1 = signature.verify(publicKey, data1);
const verifiedData2 = signature.verify(publicKey, data2);
console.log(`private key: ${privateKey.toBase58()}`);
console.log(`public key: ${publicKey.toBase58()}`);
console.log(`Fields in private key: ${privateKey.toFields().length}`);
console.log(`Fields in public key: ${publicKey.toFields().length}`);
console.log(`signature verified for data1: ${verifiedData1.toString()}`);
console.log(`signature verified for data2: ${verifiedData2.toString()}`);
console.log(`Fields in signature: ${signature.toFields().length}`);
And the console output:
str1: abc..xyz
Fields in str1: 128
private key: EKF7vyCyexH6KZpBo4jPG29Ac7tcuYwS1MFwUudhZ6D8v3Ap88SY
public key: B62qo32qFbpLybPGtFDW8GT5Efu4ZtbUXDoj5vyTrmSp6AXgMh7btAH
Fields in private key: 255
Fields in public key: 2
signature verified for data1: false
signature verified for data2: true
Fields in signature: 256
CircuitValue
CircuitValue
is soon to be replaced with Struct
in an upcoming version of SnarkyJS. Expect this tutorial to update to use Struct
in the future accordingly.
A special type is CircuitValue, which lets you create your own compound data types.
Your CircuitValue can be defined as one or more data types that SnarkyJS understands, i.e. Field, higher-order types built into SnarkyJS based on Field, or other CircuitValue types defined by you. You can also define methods on your CircuitValue to act upon this data type, if desired, but doing so is optional.
See the following for an example of how to use CircuitValue
, implementing a Point
structure, and an array of points of length 8 structure.
SnarkyJS compiles programs into fixed sized circuits. This means that data structures it consumes must also be a fixed size, and is why we declare the array in Points8
structure to be a static size of 8 in the example below.
class Point extends CircuitValue {
@prop x: Field;
@prop y: Field;
static fromField(x: Field, y: Field) {
return new Point(x, y);
}
static addPoints(a: Point, b: Point) {
return new Point(a.x.add(b.x), a.y.add(b.y));
}
}
const point1 = Point.fromField(Field(10), Field(4));
const point2 = Point.fromField(Field(1), Field(2));
const pointSum = Point.addPoints(point1, point2);
console.log(`pointSum Fields: ${pointSum.toFields().map((p) => p.toString())}`);
class Points8 extends CircuitValue {
@arrayProp(Point, 8) points: Point[];
constructor(points: Point[]) {
super();
this.points = points;
}
}
const pointsArray = new Array(8).fill(null).map((_, i) => Point.fromField(Field(i), Field(i*10)));
const points8 = new Points8(pointsArray);
console.log(`points8 Fields: ${JSON.stringify(points8)}`);
Which prints the following to the console:
pointSum Fields: 11,6
points8 Fields: {"points":[{"x":"0","y":"0"},{"x":"1","y":"10"},{"x":"2","y":"20"},{"x":"3","y":"30"},{"x":"4","y":"40"},{"x":"5","y":"50"},{"x":"6","y":"60"},{"x":"7","y":"70"}]}
A great example of a complex use of CircuitValue
is the MerkleTree implementation - see the source for that here for more.
Control flow
There are two functions which help do control flow within SnarkyJS:
Circuit.if is similar to a ternary in JavaScript. Circuit.switch is similar to a switch case statement in JavaScript.
With these, you can write conditionals inside SnarkyJS.
For example:
const input1 = Int64.from(10);
const input2 = Int64.from(-15);
const inputSum = input1.add(input2);
const inputSumAbs = Circuit.if(inputSum.isPositive(), inputSum, inputSum.mul(Int64.minusOne));
console.log(`inputSum: ${inputSum.toString()}`);
console.log(`inputSumAbs: ${inputSumAbs.toString()}`);
const input3 = Int64.from(22);
const input1largest = input1.sub(input2).isPositive().and(input1.sub(input3).isPositive());
const input2largest = input2.sub(input1).isPositive().and(input2.sub(input3).isPositive());
const input3largest = input3.sub(input1).isPositive().and(input3.sub(input2).isPositive());
const largest = Circuit.switch([ input1largest, input2largest, input3largest ], Int64, [ input1, input2, input3 ])
console.log(`largest: ${largest.toString()}`);
With output:
inputSum: -5
inputSumAbs: 5
largest: 22
By inserting function calls, you can write circuits which usual if / else statements:
const result = Circuit.if(
a.gt(b),
(() => {
// TRUE
return a;
})(),
(() => {
// FALSE
return b;
})()
);
Note that unlike normal programming, because SnarkyJS under the hood is creating a zk cirucit, this is in fact executing both branches and selecting the one that matches the condition.
Assertions
We've been using assertions throughout these tutorials already - but to clarify what they're doing, assertions are creating constraints in our circuit, that must hold true for a valid proof to be generated.
Besides the a.assertEquals(b)
we've often used, there is also .assertTrue()
available on the Bool class.
Merkle Trees
Next, let's go over an example using merkle trees. Merkle trees are very powerful, since they let us manage large amounts of data within a circuit. In the project corresponding to this tutorial, you can find a full reference for the example here. The contract can be found in BasicMerkleTreeContract.ts, and the example can be found in a section of main.ts.
Merkle trees are currently in the Experimental
module of SnarkyJS. So start off by importing Experimental
:
import {
...
Experimental,
...
} from 'snarkyjs'
Merkle Trees can be created in your application as so:
const height = 20;
const tree = new Experimental.MerkleTree(height);
The height variable determines how many leaves are available to your application. A height of 20 for example will lead to a tree with 2^(20-1)
, or 524,288 leaves.
Merkle trees in smart contracts are stored as the hash of the merkle tree's root. Smart contract methods which update the merkle root can take a "witness" of the change as an argument, which represents the merkle path to the data for which inclusion is being proved.
Here is a simple example of a contract that stores the root of a merkle tree, where each leaf stores a number, and the smart contract has an update
function that adds a number to the leaf. As an example of putting a condition on a leaf update, the update
function checks that the number added was less than 10.
...
@state(Field) treeRoot = State<Field>();
...
@method init(initialRoot: Field) {
this.treeRoot.set(initialRoot);
}
@method update(
leafWitness: MerkleWitness20,
numberBefore: Field,
incrementAmount: Field,
) {
const initialRoot = this.treeRoot.get();
this.treeRoot.assertEquals(initialRoot);
incrementAmount.assertLt(Field(10));
// check the initial state matches what we expect
const rootBefore = leafWitness.calculateRoot(numberBefore);
rootBefore.assertEquals(initialRoot);
// compute the root after incrementing
const rootAfter = leafWitness.calculateRoot(numberBefore.add(incrementAmount));
// set the new root
this.treeRoot.set(rootAfter);
}
And code to interact with it:
// initialize the zkapp
const zkapp = new BasicMerkleTreeContract(basicTreeZkAppAddress);
// create a new tree
const height = 20;
const tree = new Experimental.MerkleTree(height);
class MerkleWitness extends Experimental.MerkleWitness(height) {}
// deploy the smart contract
const deployTxn = await Mina.transaction(deployerAccount, () => {
AccountUpdate.fundNewAccount(deployerAccount);
zkapp.deploy({ zkappKey: basicTreeZkAppPrivateKey });
// get the root of the new tree to use as the initial tree root
zkapp.init(tree.getRoot());
zkapp.sign(basicTreeZkAppPrivateKey);
});
await deployTxn.send().wait();
const incrementIndex = 522;
const incrementAmount = Field(9);
// get the witness for the current tree
const witness = new MerkleWitness(tree.getWitness(BigInt(incrementIndex)));
// update the leaf locally
tree.setLeaf(BigInt(incrementIndex), incrementAmount);
// update the smart contract
const txn1 = await Mina.transaction(deployerAccount, () => {
zkapp.update(
witness,
Field(0), // leafs in new trees start at a state of 0
incrementAmount);
zkapp.sign(basicTreeZkAppPrivateKey);
});
await txn1.send().wait();
// compare the root of the smart contract tree to our local tree
console.log(`BasicMerkleTree: local tree root hash after send1: ${tree.getRoot()}`);
console.log(`BasicMerkleTree: smart contract root hash after send1: ${zkapp.treeRoot.get()}`);
While in this example leaves are fields, you can put more variables in a leaf by instead hashing an array of fields, and setting a leaf to that.
You can find this complete example in the project directory, as well as a more advanced example LedgerContract
, which implements a basic ledger of tokens, including checks that the sender has signed their transaction and that the amount the sender has sent matches the amount the receiver receives.
Merkle Map
Lastly, let's go over an example using merkle maps. These allow us to implement key value stores.
The API for Merkle Maps is similar to Merkle Trees, just instead of using an index to set a leaf, one uses a key:
const map = new Experimental.MerkleMap();
const key = Field(100);
const value = Field(50);
map.set(key, value);
console.log('value for key', key.toString() + ':', map.get(key));
Which prints:
value for key 100: 50
It can be used inside smart contracts with a witness, similar to merkle trees
...
@state(Field) mapRoot = State<Field>();
...
@method init(initialRoot: Field) {
this.mapRoot.set(initialRoot);
}
@method update(
keyWitness: Experimental.MerkleMapWitness,
keyToChange: Field,
valueBefore: Field,
incrementAmount: Field,
) {
const initialRoot = this.mapRoot.get();
this.mapRoot.assertEquals(initialRoot);
incrementAmount.assertLt(Field(10));
// check the initial state matches what we expect
const [ rootBefore, key ] = mapWitness.computeRootAndKey(valueBefore);
rootBefore.assertEquals(initialRoot);
key.assertEquals(keyToChange);
// compute the root after incrementing
const [ rootAfter, _ ] = mapWitness.computeRootAndKey(valueBefore.add(incrementAmount));
// set the new root
this.treeRoot.set(rootAfter);
}
With (abbreviated) code to interact with it, similar to the merkle tree example above:
const map = new Experimental.MerkleMap();
const rootBefore = map.getRoot();
const key = Field(100);
const witness = map.getWitness(key);
map.set(key, value);
...
// update the smart contract
const txn1 = await Mina.transaction(deployerAccount, () => {
zkapp.update(
contract.update(
witness,
key,
Field(0), //values start in state zero
Field(5)
);
);
});
...
MerkeMaps can be used to implement many useful patterns. For example:
- A key value store from public keys to booleans, of token accounts to whether they've participated in a voted yet.
- A nullifier that privately tracks if an input was used, without revealing it.
Conclusion
Congrats! We have finished reviewing more common types and functions in SnarkyJS. With this, should you now be capable of writing many advanced smart contracts and zkApps.
Checkout out our other tutorials to keep going!