zkApp programmability is not yet available on the Mina Mainnet, but zkApps can now be deployed on the Mina Devnet.
Tutorial 5: Common Types and Functions
In previous tutorials, you learned how to deploy smart contracts to the network interact with them from a React UI and NodeJS.
In this tutorial, you learn about types you can use when building with o1js. Earlier tutorials mostly use the Field
type. o1js provides other higher-order types built from Fields that are useful for zkApp development and expand the possibilities for more applications.
The example project includes a main.ts file that shows all of the concepts presented in this tutorial, along with smart contracts showing more advanced usage of some of the concepts, particularly Merkle Trees.
Prerequisites
This tutorial has been tested with:
Ensure your environment meets the Prerequisites for zkApp Developer Tutorials.
Basic Types
Five basic types are derived from Fields:
Each type has the usual programming language semantics.
For example, 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.toField().equals(char2.toField());
console.log(`char1: ${char1}`);
console.log(`char1 === char2: ${char1EqualsChar2.toString()}`);
console.log(`Fields in char1: ${Character.toFields(char1).length}`);
This result prints to the console when the code is run:
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
Four advanced types are:
All arguments passed into smart contracts must be arguments o1js can consume. You cannot pass normal strings. Instead, you must pass in strings that are wrapped to be compatible with circuits. This is accomplished with Struct
.
The default CircuitString
has a maximum length of 128 characters because o1js types must be fixed length. However, the CircuitString
API abstracts this restriction away and can be used like a dynamic length string with the maximum length caveat.
You can create custom types to build your own strings, modified to whatever length you want.
A brief example of custom types:
const str1 = CircuitString.fromString('abc..xyz');
console.log(`str1: ${str1}`);
console.log(`Fields in str1: ${CircuitString.toFields(str1).length}`);
// --------------------------------------
const zkAppPrivateKey = PrivateKey.random();
const zkAppPublicKey = zkAppPrivateKey.toPublicKey();
const data1 = Character.toFields(char2).concat(signedNumSum.toFields());
const data2 = Character.toFields(char1).concat(CircuitString.toFields(str1));
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: EKF714j3wgH3tuuQncL93ruQFbJGnhDzfWossAHUp15PdATKE7Ka
public key: B62qrhsTdExJDAxQx76V1mc13ibzikpmNaNRShTG3MWedCXESBhQ2Ch
Fields in private key: 2
Fields in public key: 2
signature verified for data1: false
signature verified for data2: true
Fields in signature: 3
Observe and follow best practices for your key security. Make sure that you never use the private key in this example output, or any private key that's publicly accessible, in a real application.
There are 255 Fields in a private key and 256 Fields in a signature. If you are curious about the reason for this, the answer 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 is 255.
Struct
You can create your own compound data types with the special Struct type.
Define a Struct as one or more data types that o1js understands. For example, Field, higher-order types built into o1js based on Field, or other Struct types defined by you. You can also define methods on your Struct to act upon this data type.
The following example demonstrates how to use Struct
to implement a Point
structure and an array of points of length 8 structure.
In o1js, programs are compiled into fixed-sized circuits. This means that data structures it consumes must also be a fixed size.
To meet the fixed-size requirement, this code declares the array in Points8
structure to be a static size of 8.
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)}`);
The console output:
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
Two functions help do control flow in o1js:
Provable.if Similar to a ternary in JavaScript
Provable.switch Similar to a switch case statement in JavaScript
You can write conditionals inside o1js with these functions.
For example:
const input1 = Int64.from(10);
const input2 = Int64.from(-15);
const inputSum = input1.add(input2);
const inputSumAbs = Provable.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 = Provable.switch(
[input1largest, input2largest, input3largest],
Int64,
[input1, input2, input3]
);
console.log(`largest: ${largest.toString()}`);
With output:
inputSum: -5
inputSumAbs: 5
largest: 22
Both branches are executed when using Provable.if
, like in a JavaScript ternary. Because o1js is creating a zk circuit, there is no primitive for if
statements where only one branch is executed.
Assertions and Constraints
o1js functions are compiled to generate circuits.
When a transaction is proven in o1js, the proof is that the program logic is computed according to the written program, and all assertions are holding true.
In previous tutorials, you learned a.assertEquals(b)
. The .assertTrue()
is available on the Bool class.
Circuits in o1js have a fixed maximum size. Each operation performed in a function counts towards this maximum size. This maximum size is equivalent to:
- 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 Recursion.
Merkle Trees
You can use Merkle trees to manage large amounts of data within a circuit. The power of Merkle trees is demonstrated in the 05-common-types-and-functions/src reference project for this tutorial. See the BasicMerkleTreeContract.ts contract and main.ts that demonstrates how contracts interact with Merkle trees and how to construct them.
The first step is to import MerkleTree
:
import {
...
MerkleTree,
...
} from 'o1js'
To create Merkle trees in your application:
const height = 20;
const tree = new MerkleTree(height);
The height variable determines how many leaves are available to the application. For example, a height of 20 leads 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 that update the Merkle root can take a witness of the change as an argument. The MerkleMapWitness represents the Merkle path to the data for which inclusion is being proved.
A contract 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.
For example, to put a condition on a leaf update, the update
function checks that the number added was less than 10:
...
@state(Field) treeRoot = State<Field>();
...
@method async initState(initialRoot: Field) {
this.treeRoot.set(initialRoot);
}
@method async update(
leafWitness: MerkleWitness20,
numberBefore: Field,
incrementAmount: Field
) {
const initialRoot = this.treeRoot.get();
this.treeRoot.requireEquals(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);
}
The code to interact with the smart contract:
// 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, async () => {
AccountUpdate.fundNewAccount(deployerAccount);
await zkApp.deploy();
// get the root of the new tree to use as the initial tree root
await 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, async () => {
await 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, basicTreeZkAppPrivateKey]).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()}`
);
In this example, leaves are fields. However, you can put more variables in a leaf by hashing an array of fields and setting a leaf to that hash.
This complete example is in the project directory.
A more advanced example LedgerContract
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
See the API reference documentation for the MerkleMap class you can use 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 async init(initialRoot: Field) {
this.mapRoot.set(initialRoot);
}
@method async update(
keyWitness: MerkleMapWitness,
keyToChange: Field,
valueBefore: Field,
incrementAmount: Field,
) {
const initialRoot = this.mapRoot.get();
this.mapRoot.requireEquals(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, async () => {
await zkapp.update(
contract.update(
witness,
key,
Field(50),
Field(5)
);
);
});
...
You use MerkleMaps 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
Congratulations! You have finished reviewing more common types and functions in o1js. With this, you should now be capable of writing many advanced smart contracts and zkApps.
To use more data from your zkApp, check out Tutorial 6 to learn how to use off-chain storage.