zkApp Tutorial: Guess Game using o1js from o1Labs and deploying it locally

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:

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, select none:

  • 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 deployed

    • method: 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 have publicKey and points.

      export class Account extends Struct({
        publicKey: PublicKey,
        points: UInt32,
      }) {}
    
  • Now let’s add a couple of functions to the class Account. One will be a hash function(We will do a Poseidon Hash on the struct i.e. public key and points) and another will be addPoints 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 extend SmartContract.

      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. The initState 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 type Field. It will be used to set the initial state of the commitment state.

  • Since we are extending SmartContract that has its own initialization to perform, we are also calling super.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 type Field, account of type Account (We created it earlier) and path of type MyMerkleWitness (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 the poseidon 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.