Please note that zkApp programmability is not yet available on Mina Mainnet, but zkApps can now be deployed to Berkeley Testnet.
This tutorial was last tested with SnarkyJS 0.8.0.
Tutorial 5: 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}`);
console.log(`Fields in signedNum1: ${signedNum1.toFields().length}`);
// --------------------------------------
const char1 = Character.fromString('c');
const char2 = Character.fromString('d');
const char1EqualsChar2: Bool = char1.equals(char2);
console.log(`char1: ${char1}`);
console.log(`char1 === char2: ${char1EqualsChar2.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 Struct
accomplishes.
One special thing to note, is that the default CircuitString
has a max length of 128 characters. This is because, under the hood, SnarkyJS types have to be fixed length. However, the CircuitString
API abstracts this away and can be used like a dynamic length string, with the only caveat being the max length.
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}`);
console.log(`Fields in str1: ${str1.toFields().length}`);
// --------------------------------------
const zkAppPrivateKey = PrivateKey.random();
const zkAppPublicKey = zkAppPrivateKey.toPublicKey();
const data1 = char2.toFields().concat(signedNumSum.toFields());
const data2 = char1.toFields().concat(str1.toFields());
const signature = Signature.create(zkAppPrivateKey, data2);
const verifiedData1 = signature.verify(zkAppPublicKey, data1).toString();
const verifiedData2 = signature.verify(zkAppPublicKey, data2).toString();
console.log(`private key: ${zkAppPrivateKey.toBase58()}`);
console.log(`public key: ${zkAppPublicKey.toBase58()}`);
console.log(`Fields in private key: ${zkAppPrivateKey.toFields().length}`);
console.log(`Fields in public key: ${zkAppPublicKey.toFields().length}`);
console.log(`signature verified for data1: ${verifiedData1}`);
console.log(`signature verified for data2: ${verifiedData2}`);
console.log(`Fields in signature: ${signature.toFields().length}`);
And the console output:
str1: abc..xyz
Fields in str1: 128
private key: EKEdDGiN9Zd9TaSPcNjs3nB6vs9JS3WCgdsrfyEeLcQpnXNR7j6E
public key: B62qoGDUnJGdiD8MPEs4Lo76kWXSNbJD6Dn8HzkaBSfhZQWShJC8gEe
Fields in private key: 255
Fields in public key: 2
signature verified for data1: false
signature verified for data2: true
Fields in signature: 256
Small but important note: Make sure that you never use the private key above (or any private key that's publicly accessible) in a real application!
If you're curious why there are 255 Fields in a private key and 256 in a signature - the reason for this is cryptographic in nature: Elliptic curve scalars are most efficiently represented in a SNARK as an array of bits, and the bit length of these scalars happens to be 255.
Struct
A special type is Struct, which lets you create your own compound data types.
Your Struct 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 Struct types defined by you. You can also define methods on your Struct to act upon this data type, if desired, but doing so is optional.
See the following for an example of how to use Struct
, 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 Struct({ x: Field, y: Field }) {
static add(a: Point, b: Point) {
return { x: a.x.add(b.x), y: a.y.add(b.y) };
}
}
const point1 = { x: Field(10), y: Field(4) };
const point2 = { x: Field(1), y: Field(2) };
const pointSum = Point.add(point1, point2);
console.log(`pointSum Fields: ${Point.toFields(pointSum)}`);
class Points8 extends Struct({
points: [Point, Point, Point, Point, Point, Point, Point, Point],
}) {}
const points = new Array(8)
.fill(null)
.map((_, i) => ({ x: Field(i), y: Field(i * 10) }));
const points8: Points8 = { points };
console.log(`points8 JSON: ${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"}]}
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
Note that when using Circuit.if
, like in a JS ternary, both branches are executed. Unlike normal programming, because SnarkyJS under the hood is creating a zk circuit, there is no primitive for if statements where only one branch is executed.
Assertions and Constraints
SnarkyJS functions are compiled to generate circuits.
When a transaction is proven in SnarkyJS, SnarkyJS is proving that the program logic is computed according to the written program, and all assertions are holding true.
We've seen assertions already, with a.assertEquals(b)
, which we've often used. There is also .assertTrue()
available on the Bool class.
Circuits in SnarkyJS currently have a fixed maximum size. Each operation performed in a function counts towards this maximum size. This maximum size is currently equivalent to the following:
- about 5,200 hashes on two fields
- about 2,600 hashes on four fields
- about
2^17
field multiplies - about
2^17
field additions
If a program is too large to fit into these constraints, it can be broken up into multiple recursive proof verifications. See more on how to do this in the section here.
Merkle Trees
Lastly, 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.
Start off by importing MerkleTree
:
import {
...
MerkleTree,
...
} from 'snarkyjs'
Merkle Trees can be created in your application like so:
const height = 20;
const tree = new 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 initState(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);
await BasicMerkleTreeContract.compile();
// create a new tree
const height = 20;
const tree = new MerkleTree(height);
class MerkleWitness20 extends MerkleWitness(height) {}
// deploy the smart contract
const deployTxn = await Mina.transaction(deployerAccount, () => {
AccountUpdate.fundNewAccount(deployerAccount);
zkApp.deploy();
// get the root of the new tree to use as the initial tree root
zkApp.initState(tree.getRoot());
});
await deployTxn.prove();
deployTxn.sign([deployerKey, basicTreeZkAppPrivateKey]);
const pendingDeployTx = await deployTxn.send();
/**
* `txn.send()` returns a pending transaction with two methods - `.wait()` and `.hash()`
* `.hash()` returns the transaction hash
* `.wait()` automatically resolves once the transaction has been included in a block. this is redundant for the LocalBlockchain, but very helpful for live testnets
*/
await pendingDeployTx.wait();
const incrementIndex = 522n;
const incrementAmount = Field(9);
// get the witness for the current tree
const witness = new MerkleWitness20(tree.getWitness(incrementIndex));
// update the leaf locally
tree.setLeaf(incrementIndex, incrementAmount);
// update the smart contract
const txn1 = await Mina.transaction(senderPublicKey, () => {
zkApp.update(
witness,
Field(0), // leafs in new trees start at a state of 0
incrementAmount
);
});
await txn1.prove();
const pendingTx = await txn1.sign([senderPrivateKey, zkAppPrivateKey]).send();
await pendingTx.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 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: 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 ] = keyWitness.computeRootAndKey(valueBefore);
rootBefore.assertEquals(initialRoot);
key.assertEquals(keyToChange);
// compute the root after incrementing
const [ rootAfter, _ ] = keyWitness.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 MerkleMap();
const rootBefore = map.getRoot();
const key = Field(100);
const witness = map.getWitness(key);
...
// update the smart contract
const txn1 = await Mina.transaction(deployerAccount, () => {
zkapp.update(
contract.update(
witness,
key,
Field(50),
Field(5)
);
);
});
...
MerkleMaps 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, you should now be capable of writing many advanced smart contracts and zkApps.
Checkout Tutorial 6 to learn how to use off-chain storage, to use more data from your zkApp.