Skip to main content
info

zkApp programmability is not yet available on the Mina Mainnet. You can get started now by deploying zkApps to the Berkeley Testnet.

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.

All types are defined in the o1js / Modules / Types API reference documentation.

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 verified with Mina zkApp CLI version 0.11.0 and SnarkyJS 0.12.1.

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:

3 const num1 = UInt32.from(40);
4 const num2 = UInt64.from(40);
5
6 const num1EqualsNum2: Bool = num1.toUInt64().equals(num2);
7
8 console.log(`num1 === num2: ${num1EqualsNum2.toString()}`);
9 console.log(`Fields in num1: ${num1.toFields().length}`);
10
11 // --------------------------------------
12
13 const signedNum1 = Int64.from(-3);
14 const signedNum2 = Int64.from(45);
15
16 const signedNumSum = signedNum1.add(signedNum2);
17
18 console.log(`signedNum1 + signedNum2: ${signedNumSum}`);
19 console.log(`Fields in signedNum1: ${signedNum1.toFields().length}`);
20
21 // --------------------------------------
22
23 const char1 = Character.fromString('c');
24 const char2 = Character.fromString('d');
25 const char1EqualsChar2: Bool = char1.equals(char2);
26
27 console.log(`char1: ${char1}`);
28 console.log(`char1 === char2: ${char1EqualsChar2.toString()}`);
29 console.log(`Fields in char1: ${char1.toFields().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:

30 const str1 = CircuitString.fromString('abc..xyz');
31 console.log(`str1: ${str1}`);
32 console.log(`Fields in str1: ${str1.toFields().length}`);
33
34 // --------------------------------------
35
36 const zkAppPrivateKey = PrivateKey.random();
37 const zkAppPublicKey = zkAppPrivateKey.toPublicKey();
38
39 const data1 = char2.toFields().concat(signedNumSum.toFields());
40 const data2 = char1.toFields().concat(str1.toFields());
41
42 const signature = Signature.create(zkAppPrivateKey, data2);
43
44 const verifiedData1 = signature.verify(zkAppPublicKey, data1).toString();
45 const verifiedData2 = signature.verify(zkAppPublicKey, data2).toString();
46
47 console.log(`private key: ${zkAppPrivateKey.toBase58()}`);
48 console.log(`public key: ${zkAppPublicKey.toBase58()}`);
49 console.log(`Fields in private key: ${zkAppPrivateKey.toFields().length}`);
50 console.log(`Fields in public key: ${zkAppPublicKey.toFields().length}`);
51
52 console.log(`signature verified for data1: ${verifiedData1}`);
53 console.log(`signature verified for data2: ${verifiedData2}`);
54
55 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

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.

56 class Point extends Struct({ x: Field, y: Field }) {
57 static add(a: Point, b: Point) {
58 return { x: a.x.add(b.x), y: a.y.add(b.y) };
59 }
60 }
61
62 const point1 = { x: Field(10), y: Field(4) };
63 const point2 = { x: Field(1), y: Field(2) };
64
65 const pointSum = Point.add(point1, point2);
66
67 console.log(`pointSum Fields: ${Point.toFields(pointSum)}`);
68
69 class Points8 extends Struct({
70 points: [Point, Point, Point, Point, Point, Point, Point, Point],
71 }) {}
72
73 const points = new Array(8)
74 .fill(null)
75 .map((_, i) => ({ x: Field(i), y: Field(i * 10) }));
76 const points8: Points8 = { points };
77
78 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:

You can write conditionals inside o1js with these functions.

For example:

79 const input1 = Int64.from(10);
80 const input2 = Int64.from(-15);
81
82 const inputSum = input1.add(input2);
83
84 const inputSumAbs = Provable.if(
85 inputSum.isPositive(),
86 inputSum,
87 inputSum.mul(Int64.minusOne)
88 );
89
90 console.log(`inputSum: ${inputSum.toString()}`);
91 console.log(`inputSumAbs: ${inputSumAbs.toString()}`);
92
93 const input3 = Int64.from(22);
94
95 const input1largest = input1
96 .sub(input2)
97 .isPositive()
98 .and(input1.sub(input3).isPositive());
99 const input2largest = input2
100 .sub(input1)
101 .isPositive()
102 .and(input2.sub(input3).isPositive());
103 const input3largest = input3
104 .sub(input1)
105 .isPositive()
106 .and(input3.sub(input2).isPositive());
107
108 const largest = Provable.switch(
109 [input1largest, input2largest, input3largest],
110 Int64,
111 [input1, input2, input3]
112 );
113 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.

To create Merkle trees in your application:

114 const height = 20;
115 const tree = new MerkleTree(height);
116 class MerkleWitness20 extends MerkleWitness(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:

117 class BasicMerkleTreeContract extends SmartContract {
118 @state(Field) treeRoot = State<Field>();
119
120 @method initState(initialRoot: Field) {
121 this.treeRoot.set(initialRoot);
122 }
123
124 @method update(
125 leafWitness: MerkleWitness20,
126 numberBefore: Field,
127 incrementAmount: Field
128 ) {
129 const initialRoot = this.treeRoot.get();
130 this.treeRoot.assertEquals(initialRoot);
131
132 incrementAmount.assertLessThan(Field(10));
133
134 // check the initial state matches what we expect
135 const rootBefore = leafWitness.calculateRoot(numberBefore);
136 rootBefore.assertEquals(initialRoot);
137
138 // compute the root after incrementing
139 const rootAfter = leafWitness.calculateRoot(
140 numberBefore.add(incrementAmount)
141 );
142
143 // set the new root
144 this.treeRoot.set(rootAfter);
145 }
146 }

The code to interact with the smart contract:

147 // initialize the zkapp
148 let basicTreeZkAppPrivateKey = PrivateKey.random();
149 let basicTreeZkAppAddress = basicTreeZkAppPrivateKey.toPublicKey();
150
151 let Local = Mina.LocalBlockchain({ proofsEnabled: false });
152 Mina.setActiveInstance(Local);
153 let deployerKey = Local.testAccounts[0].privateKey;
154 let deployerAccount = deployerKey.toPublicKey();
155 let senderPrivateKey = Local.testAccounts[1].privateKey;
156 let senderPublicKey = senderPrivateKey.toPublicKey();
157 const zkApp = new BasicMerkleTreeContract(basicTreeZkAppAddress);
158 await BasicMerkleTreeContract.compile();
159
160 // deploy the smart contract
161 const deployTxn = await Mina.transaction(deployerAccount, () => {
162 AccountUpdate.fundNewAccount(deployerAccount);
163 zkApp.deploy();
164 // get the root of the new tree to use as the initial tree root
165 zkApp.initState(tree.getRoot());
166 });
167 await deployTxn.prove();
168 deployTxn.sign([deployerKey, basicTreeZkAppPrivateKey]);
169
170 const pendingDeployTx = await deployTxn.send();
171 /**
172 * `txn.send()` returns a pending transaction with two methods - `.wait()` and `.hash()`
173 * `.hash()` returns the transaction hash
174 * `.wait()` automatically resolves once the transaction has been included in a block. this is redundant for the LocalBlockchain, but very helpful for live testnets
175 */
176 await pendingDeployTx.wait();
177
178 const incrementIndex = 522n;
179 const incrementAmount = Field(9);
180
181 // get the witness for the current tree
182 const witness = new MerkleWitness20(tree.getWitness(incrementIndex));
183
184 // update the leaf locally
185 tree.setLeaf(incrementIndex, incrementAmount);
186
187 // update the smart contract
188 const txn1 = await Mina.transaction(senderPublicKey, () => {
189 zkApp.update(
190 witness,
191 Field(0), // leafs in new trees start at a state of 0
192 incrementAmount
193 );
194 });
195 await txn1.prove();
196 const pendingTx = await txn1.sign([senderPrivateKey, zkAppPrivateKey]).send();
197 await pendingTx.wait();
198
199 // compare the root of the smart contract tree to our local tree
200 console.log(
201 `BasicMerkleTree: local tree root hash after send1: ${tree.getRoot()}`
202 );
203 console.log(
204 `BasicMerkleTree: smart contract root hash after send1: ${zkApp.treeRoot.get()}`
205 );

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:

206 const map = new MerkleMap();
207
208 const key = Field(100);
209 const value = Field(50);
210
211 map.set(key, value);
212
213 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

214
215 @state(Field) mapRoot = State<Field>();
216
217 @method init(initialRoot: Field) {
218 this.mapRoot.set(initialRoot);
219 }
220 @method update(
221 keyWitness: MerkleMapWitness,
222 keyToChange: Field,
223 valueBefore: Field,
224 incrementAmount: Field,
225 ) {
226 const initialRoot = this.mapRoot.get();
227 this.mapRoot.assertEquals(initialRoot);
228
229 incrementAmount.assertLessThan(Field(10));
230
231 // check the initial state matches what we expect
232 const [ rootBefore, key ] = keyWitness.computeRootAndKey(valueBefore);
233 rootBefore.assertEquals(initialRoot);
234
235 key.assertEquals(keyToChange);
236
237 // compute the root after incrementing
238 const [ rootAfter, _ ] = keyWitness.computeRootAndKey(valueBefore.add(incrementAmount));
239
240 // set the new root
241 this.treeRoot.set(rootAfter);
242 }

With (abbreviated) code to interact with it, similar to the Merkle tree example above:

243 const map = new MerkleMap();
244
245 const rootBefore = map.getRoot();
246
247 const key = Field(100);
248
249 const witness = map.getWitness(key);
250
251 // update the smart contract
252 const txn1 = await Mina.transaction(deployerAccount, () => {
253 zkapp.update(
254 contract.update(
255 witness,
256 key,
257 Field(50),
258 Field(5)
259 );
260 );
261 });

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.