zkApp Tutorial: Guess Game using o1js from o1Labs and deploying it locally
Introduction:
The more you try to find tutorials on zkApp, the more disappointed you will be because there are very few tutorials. So, here I am with one more tutorial for zkApp. This time we are using “o1js” language by “o1labs”. There is already an existing tutorial by o1labs on the same. But I wanted to explain it in my own way and want to add a few new things to it in coming future so I am writing this tutorial again. You can find reference links and my other tutorials at the end. Hope you will enjoy it !!!
By the end of this tutorial, you'll have built and deployed a fun guess game zkApp using o1js that securely keeps track of your points on the blockchain for correct guesses. Through building this simple game, you'll gain hands-on experience with o1js and zkApp development, setting you up to create more complex web3 apps in the future. Whether you're new to zkApps or an experienced blockchain developer, this tutorial will walk you through all the steps needed to bring your zkApp ideas to life.
This tutorial will cover:
Installation of zkapp cli
Creating Project using zkapp cli
Write zkContracts using o1js
Guess Game where your friend can come and guess the number.
Deploy the contract locally and interact with it.
Prerequisites:
To use the zkApp CLI and o1js, your environment requires:
NodeJS v16 and later (or NodeJS v14 using
-experimental-wasm-threads
)NPM v6 and later
Git v2 and later
Knowledge about Merkle Tree.
- Don’t worry if you don’t know about it. You can quickly learn about it from Learn Web3 DAO tutorials. Learn about Merkle Tree: https://learnweb3.io/degrees/ethereum-developer-degree/senior/how-to-create-merkle-trees-for-airdrops/
Little Experience in TypeScript
Todo:
Deploy contract on Berkeley testnet of Mina
Create a FrontEnd to interact with the code
Add claim reward functionality, multiple games to get points, etc.
Reference Links:
Installation:
To install the Mina zkApp CLI:
npm install -g zkapp-cli
To confirm successful installation:
zk --version
Let’s code:
SmartContract:
Let’s
cd
into the directory where you want to install the project.Create the zk project using zkapp cli by running the below command:
zk project guessgame
The
zk project
command can scaffold the UI for your project. For this tutorial, selectnone
:Let’s cd into guessgame:
cd guessgame
Let’s prepare our project, by replacing some unwanted files:
rm src/Add.ts src/Add.test.ts src/interact.ts
Let’s add our file that is
Guess.ts
:
zk file src/Guess
touch src/main.ts
Your
src
folder structure should look like this now:Open your
index.ts
. It must be looking all red. No need to panic, just replace all lines with this:import { Leaderboard } from './Guess.js'; // Class which we are going to export from Guess.ts export { Leaderboard };
It will still look red because we have not exported the class yet. We will export in the coming time. For now your
index.ts
should be looking like this:Now save and close it. Now open your
Guess.TS
.Let’s start with importing the packages which we will be using in
Guess.ts
.import { SmartContract, Poseidon, Field, State, state, PublicKey, method, UInt32, MerkleWitness, Struct, } from 'o1js';
SmartContract
: The class that creates zkApp smart contracts.Poseidon
: Hash function that is optimised for fast performance inside zero knowledge proof systems.Field
: The native number type in o1js. You can think of Field elements as unsigned integers. Field elements are the most basic type in o1js. All other o1js-compatible types are built on top of Field elements.State
: A class used in zkApp smart contracts to create state stored on-chain in a zkApp account.state
: A convenience decorator used in zkApp smart contracts to create references to state stored on-chain in a zkApp account.PublicKey
: Helps in deriving the address on the Mina network where our smart contract will be deployedmethod
: Methods that use this decorator are the end user's entry points to interacting with a smart contract.UInt32
: A 32 bit unsigned integer with values ranging from 0 to 4,294,967,295.MerkleWitness
: Returns a circuit-compatible Witness for a specific Tree height.Struct
: Let’s you declare composite types for use in o1js circuits.
As you know only a limited amount of data can be stored on the chain in Mina, so we are going to use the Merkle tree.
Merkle trees allow you to reference off-chain data by storing only a single hash on-chain.
Merkle trees are special binary trees in which every leaf (the nodes at the very bottom of the tree!) are cryptographic hashes of the underlying pieces of data and the internal nodes are labeled with the cryptographic hash of the concatenated labels (hashes) of its child nodes.
By following this algorithm to the very top, you end up with one single node (the root node) that stores the root hash of the tree. The root hash is a reference to all pieces of data that were included in the tree's leaves so you can reference large amounts of data by using one small hash! Another benefit that Merkle trees provide is something called the witness (sometimes also called Merkle proof or Merkle path). The witness is the path from one specific leaf node to the very top of the tree to the root! Merkle witnesses are proofs of inclusion that prove that one specific piece of data (an account in the ledger or the scores on a leaderboard) exists within the entire tree.Let’s create a class in
Guess.ts
.// MyMerkleWitnes class of height 8 class MyMerkleWitness extends MerkleWitness(8) {}
So, when the user let’s say public key, makes a guess and suppose it is a correct guess, that public key needs to be given a point.
For this, we will create a class called
Accounts
and it will extend Struct class which will havepublicKey
andpoints
.export class Account extends Struct({ publicKey: PublicKey, points: UInt32, }) {}
Now let’s add a couple of functions to the class
Account
. One will be ahash
function(We will do a Poseidon Hash on the struct i.e. public key and points) and another will beaddPoints
function (It will add 1 point to the points related to the public key).export class Account extends Struct({ publicKey: PublicKey, points: UInt32, }) { // Hash Function: hash(): Field { return Poseidon.hash(Account.toFields(this)); } // Add points function addPoints(points: number) { return new Account({ publicKey: this.publicKey, points: this.points.add(points), }); } }
Now comes the main part. Excited ???
Let's create a class named
Leaderboard
that will extendSmartContract
.export class Leaderboard extends SmartContract {}
Let’s declare a state called
commitment
. It will be the off-chain root hash of our Merkle Tree.export class Leaderboard extends SmartContract { @state(Field) commitment = State<Field>(); }
Let’s create a
initState
method. TheinitState
method is intended to run once to set up the initial state on the zkApp account.It will take one parameter at the time of deployment which is
initialCommitment
of typeField
. It will be used to set the initial state of thecommitment
state.Since we are extending
SmartContract
that has its own initialization to perform, we are also callingsuper.init()
which invokes this function on the base class.export class Leaderboard extends SmartContract { @state(Field) commitment = State<Field>(); // initState method @method initState(initialCommitment: Field) { super.init(); this.commitment.set(initialCommitment); } }
Now let’s write our main method. We will name it
guessPreimage
. It will take the below parameters:guess
of typeField
,account
of typeAccount
(We created it earlier) andpath
of typeMyMerkleWitness
(We created it earlier).@method guessPreimage(guess: Field, account: Account, path: MyMerkleWitness) {}
Let’s set our
target
field or the value that people will try to guess. It will be theposeidon hash of 22
. You can use any other number if you want.@method guessPreimage(guess: Field, account: Account, path: MyMerkleWitness) { // This is our hash! its the hash of the preimage "22", but keep it a secret! let target = Field( '17057234437185175411792943285768571642343179330449434169483610110583519635705' ); }
Now let’s check whether the
guess
the parameter that the user has passed is matching to our target. We will do this by hashing the guess value and asserting it. If it will match then the code will move forward and grant a point to the user. Otherwise, it will fail and come out.@method guessPreimage(guess: Field, account: Account, path: MyMerkleWitness) { // this is our hash! its the hash of the preimage "22", but keep it a secret! let target = Field( '17057234437185175411792943285768571642343179330449434169483610110583519635705' ); // if our guess preimage hashes to our target, we won a point! Poseidon.hash([guess]).assertEquals(target); }
Next, we will fetch the on-chain
commitment
or root of our Merkle tree. Then we will make sure that the value we have matches the on-chain one.@method guessPreimage(guess: Field, account: Account, path: MyMerkleWitness) { // this is our hash! its the hash of the preimage "22", but keep it a secret! let target = Field( '17057234437185175411792943285768571642343179330449434169483610110583519635705' ); // if our guess preimage hashes to our target, we won a point! Poseidon.hash([guess]).assertEquals(target); // we fetch the on-chain commitment let commitment = this.commitment.get(); this.commitment.assertEquals(commitment); // Here we are making sure that the value which we got is same as the one on-chain. }
Now we will check the account i.e. Public key and points are within the committed Merkle Tree. For this, we will use
calculateRoot
function. It will take the hash of the account as input, to get the hash of the account we are calling the hash function from the Account class and then we make sure the account is within the committed Merkle tree.@method guessPreimage(guess: Field, account: Account, path: MyMerkleWitness) { // this is our hash! its the hash of the preimage "22", but keep it a secret! let target = Field( '17057234437185175411792943285768571642343179330449434169483610110583519635705' ); // if our guess preimage hashes to our target, we won a point! Poseidon.hash([guess]).assertEquals(target); // we fetch the on-chain commitment let commitment = this.commitment.get(); this.commitment.assertEquals(commitment); // we check that the account is within the committed Merkle Tree path.calculateRoot(account.hash()).assertEquals(commitment); }
Now we will add the point to the account. For adding the point to the account we will call the addPoint method from the Account class. Here, you will notice that rather than updating the point in the existing position, we are creating a new account that is we are keeping the public key the same but just increasing the point.
@method guessPreimage(guess: Field, account: Account, path: MyMerkleWitness) { // this is our hash! its the hash of the preimage "22", but keep it a secret! let target = Field( '17057234437185175411792943285768571642343179330449434169483610110583519635705' ); // if our guess preimage hashes to our target, we won a point! Poseidon.hash([guess]).assertEquals(target); // we fetch the on-chain commitment let commitment = this.commitment.get(); this.commitment.assertEquals(commitment); // we check that the account is within the committed Merkle Tree path.calculateRoot(account.hash()).assertEquals(commitment); // we create the new account with existing public key and grant one point to it. let newAccount = account.addPoints(1); }
Now we will calculate the new root and replace the old one with it.
@method guessPreimage(guess: Field, account: Account, path: MyMerkleWitness) { // this is our hash! its the hash of the preimage "22", but keep it a secret! let target = Field( '17057234437185175411792943285768571642343179330449434169483610110583519635705' ); // if our guess preimage hashes to our target, we won a point! Poseidon.hash([guess]).assertEquals(target); // we fetch the on-chain commitment let commitment = this.commitment.get(); this.commitment.assertEquals(commitment); // we check that the account is within the committed Merkle Tree path.calculateRoot(account.hash()).assertEquals(commitment); // we update the account and grant one point! let newAccount = account.addPoints(1); // we calculate the new Merkle Root, based on the account changes let newCommitment = path.calculateRoot(newAccount.hash()); // Replace the old root with the new one this.commitment.set(newCommitment); }
That’s all !! Your
Guess.ts
should be looking like this :import { SmartContract, Poseidon, Field, State, state, PublicKey, method, UInt32, MerkleWitness, Struct, } from 'o1js'; class MyMerkleWitness extends MerkleWitness(8) {} export class Account extends Struct({ publicKey: PublicKey, points: UInt32, }) { hash(): Field { return Poseidon.hash(Account.toFields(this)); } addPoints(points: number) { return new Account({ publicKey: this.publicKey, points: this.points.add(points), }); } } export class Leaderboard extends SmartContract { @state(Field) commitment = State<Field>(); // initState method @method initState(initialCommitment: Field) { super.init(); this.commitment.set(initialCommitment); } @method guessPreimage(guess: Field, account: Account, path: MyMerkleWitness) { // this is our hash! its the hash of the preimage "22", but keep it a secret! let target = Field( '17057234437185175411792943285768571642343179330449434169483610110583519635705' ); // if our guess preimage hashes to our target, we won a point! Poseidon.hash([guess]).assertEquals(target); // we fetch the on-chain commitment let commitment = this.commitment.get(); this.commitment.assertEquals(commitment); // we check that the account is within the committed Merkle Tree path.calculateRoot(account.hash()).assertEquals(commitment); // we update the account and grant one point! let newAccount = account.addPoints(1); // we calculate the new Merkle Root, based on the account changes let newCommitment = path.calculateRoot(newAccount.hash()); this.commitment.set(newCommitment); } }
Congratulations on writing your first zk smartcontract 🎉🎉🎉🎉🎉 !!! Next, let’s write a Script to interact with our contract.
Interaction Script:
Let’s close all the open files in your code editor.
Now let’s open
main.ts
. Here we will write our code to interact with our contract.First thing first, let’s import 👉:
import { Leaderboard, Account } from './Guess.js'; import { AccountUpdate, Field, MerkleTree, MerkleWitness, Mina, PrivateKey, UInt32, } from 'o1js';
Let’s declare the type Names and a boolean called doProofs set it true and a class for Merkle witness, similar to what we did in the contract.
type Names = 'Bob' | 'Alice' | 'Charlie' | 'Olivia'; const doProofs = true; class MyMerkleWitness extends MerkleWitness(8) {}
Now let's initialise our local Mina blockchain.
const Local = Mina.LocalBlockchain({ proofsEnabled: doProofs }); // Create a instance called Local Mina.setActiveInstance(Local); // Set the local instance as active instance const initialBalance = 10_000_000_000; // Set initial balance // Deployer account let feePayerKey = Local.testAccounts[0].privateKey; // This will be our feepayer account private key let feePayer = Local.testAccounts[0].publicKey; // This will be our feepayer account public key // The zkapp account let zkappKey = PrivateKey.random(); // This will be our zkapp account private key let zkappAddress = zkappKey.toPublicKey(); // This will be our zkapp account public key. This is the address where our account will be deployed.
Now let’s create mapping to the accounts with the Names we declared earlier.
// this map serves as our off-chain in-memory storage let Accounts: Map<string, Account> = new Map<Names, Account>( ['Bob', 'Alice', 'Charlie', 'Olivia'].map((name: string, index: number) => { return [ name as Names, new Account({ publicKey: Local.testAccounts[index].publicKey, // Creating a Pulic key for every user points: UInt32.from(0), }), ]; }) );
Now let’s create our Merkle tree and set the leaf nodes. We are using a Merkle tree of height 8.
// we now need "wrap" the Merkle tree around our off-chain storage // we initialize a new Merkle Tree with height 8 const Tree = new MerkleTree(8); Tree.setLeaf(0n, Accounts.get('Bob')!.hash()); Tree.setLeaf(1n, Accounts.get('Alice')!.hash()); Tree.setLeaf(2n, Accounts.get('Charlie')!.hash()); Tree.setLeaf(3n, Accounts.get('Olivia')!.hash());
Now that we have our accounts set up, we need to pass this to the tree in the contract while deploying.
Let’s create the initialCommitment Field and assign the tree root to it. Then we will pass it during deployment.
let initialCommitment: Field = Tree.getRoot();
Let’s create a Leaderboard instance. We will pass the zkappAddress as the parameter.
let leaderboardZkApp = new Leaderboard(zkappAddress); console.log('Deploying leaderboard..');
Before we deploy our contract we need to compile the smart contract as we have enabled the proofs
if (doProofs) { await Leaderboard.compile(); }
Now we will create a deployment transaction, prove it and then we will sign and send it for execution.
let tx = await Mina.transaction(feePayer, () => { AccountUpdate.fundNewAccount(feePayer).send({ to: zkappAddress, amount: initialBalance, }); leaderboardZkApp.deploy(); leaderboardZkApp.initState(initialCommitment); }); await tx.prove(); await tx.sign([feePayerKey, zkappKey]).send();
Now let’s create an async function that will take the student’s name, index (position of leaf-node) and guess made by the person.
async function makeGuess(name: Names, index: bigint, guess: number) { let account = Accounts.get(name)!; // Create the witness from for the index from the merkle tree. let w = Tree.getWitness(index); let witness = new MyMerkleWitness(w); // Create a Transaction with guess, account and witness. Send the transaction. try { let tx = await Mina.transaction(feePayer, () => { leaderboardZkApp.guessPreimage(Field(guess), account, witness); }); await tx.prove(); await tx.sign([feePayerKey, zkappKey]).send(); } catch (e: any) { console.log(e.message); } // if the transaction was successful, we can update our off-chain storage as well account.points = account.points.add(1); Tree.setLeaf(index, account.hash()); leaderboardZkApp.commitment.get().assertEquals(Tree.getRoot()); }
Here, we will start interacting with smartcontract.
console.log('Initial points: ' + Accounts.get('Bob')?.points); console.log('Making guess..'); await makeGuess('Bob', 0n, 22); console.log('Final points: ' + Accounts.get('Bob')?.points);
That’s all. Your script to interact with the contract is ready.
This is how your
main.ts
should look like now:import { Leaderboard, Account } from './Guess.js'; import { AccountUpdate, Field, MerkleTree, MerkleWitness, Mina, PrivateKey, UInt32, } from 'o1js'; type Names = 'Bob' | 'Alice' | 'Charlie' | 'Olivia'; const doProofs = true; class MyMerkleWitness extends MerkleWitness(8) {} const Local = Mina.LocalBlockchain({ proofsEnabled: doProofs }); Mina.setActiveInstance(Local); const initialBalance = 10_000_000_000; let feePayerKey = Local.testAccounts[0].privateKey; let feePayer = Local.testAccounts[0].publicKey; // the zkapp account let zkappKey = PrivateKey.random(); let zkappAddress = zkappKey.toPublicKey(); // this map serves as our off-chain in-memory storage let Accounts: Map<string, Account> = new Map<Names, Account>( ['Bob', 'Alice', 'Charlie', 'Olivia'].map((name: string, index: number) => { return [ name as Names, new Account({ publicKey: Local.testAccounts[index].publicKey, points: UInt32.from(0), }), ]; }) ); // we now need "wrap" the Merkle tree around our off-chain storage // we initialize a new Merkle Tree with height 8 const Tree = new MerkleTree(8); Tree.setLeaf(0n, Accounts.get('Bob')!.hash()); Tree.setLeaf(1n, Accounts.get('Alice')!.hash()); Tree.setLeaf(2n, Accounts.get('Charlie')!.hash()); Tree.setLeaf(3n, Accounts.get('Olivia')!.hash()); let initialCommitment: Field = Tree.getRoot(); let leaderboardZkApp = new Leaderboard(zkappAddress); console.log('Deploying leaderboard..'); if (doProofs) { await Leaderboard.compile(); } let tx = await Mina.transaction(feePayer, () => { AccountUpdate.fundNewAccount(feePayer).send({ to: zkappAddress, amount: initialBalance, }); leaderboardZkApp.deploy(); leaderboardZkApp.initState(initialCommitment); }); await tx.prove(); await tx.sign([feePayerKey, zkappKey]).send(); console.log('Initial points: ' + Accounts.get('Bob')?.points); console.log('Making guess..'); await makeGuess('Bob', 0n, 22); console.log('Final points: ' + Accounts.get('Bob')?.points); async function makeGuess(name: Names, index: bigint, guess: number) { let account = Accounts.get(name)!; // Create the witness from for the index from the merkle tree. let w = Tree.getWitness(index); let witness = new MyMerkleWitness(w); // Create a Transaction with guess, account and witness. Send the transaction. try { let tx = await Mina.transaction(feePayer, () => { leaderboardZkApp.guessPreimage(Field(guess), account, witness); }); await tx.prove(); await tx.sign([feePayerKey, zkappKey]).send(); } catch (e: any) { console.log(e.message); } // if the transaction was successful, we can update our off-chain storage as well account.points = account.points.add(1); Tree.setLeaf(index, account.hash()); leaderboardZkApp.commitment.get().assertEquals(Tree.getRoot()); }
Phew !!! I am tired………… It was fun ain’t it?
Now finally time to execute and see if everything works !!!
Execution:
Executing the code it very easy.
We will first build the project.
Then will execute the compiled file.
So, let’s go to our project’s root directory.
Then run this:
npm run build && node build/src/main.js
and you will should get something like this.Now if you want you can change the guess value and see what other things you get.
Yayyy, you guys made your first contract work !!!!!! 🎉🎉🎉🎉🎉🎉. I will keep on updating my tutorials and add the deployment part soon.