zkApp Tutorial: Pass Or Fail using o1js from o1Labs and deploying it locally
Introduction:
Hello Hello !! Imagine you are a student and you take your college exams with your friends. Your results are out and you are pass but your score is poor. Now if you are born in any Asian family, it’s for sure you will either get a good slap and kung fu chops and a couple of flying chappals (Backbenchers can relate). In addition to it, you get compared to Sharma ji’s child. Isn’t it hard ??
What if there was proof that you had passed but the score was not shared ?? Wouldn’t it be amazing? This is possible. If you follow the below tutorial you can build your zkApp that creates proof that you have scored enough and have passed the exam.
This tutorial will cover:
Installation of zkapp cli
Creating Project using zkapp cli
Write zkContracts using o1js
Contact for Pass or fail zkApp
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
Use zkOracles to store and then retrieve the student data
How it all works?
In this example, we are going to need a trusted organization to make sure that the correct details are entered.
We are using the SSC Board of Maharashtra, India as a trusted organization.
The SSC board will be the deployer of the contract. It will add the details.
There will be two roots. On-chain and Off-chain.
Both roots should match to add marks or verify. If someone tries something funny it won’t work because a change in character changes the complete hash of the tree.
If Student want to verify they just need to pass their Name and the Index which can be their roll number if applied in real life.
The verification method will check if the roots are the same and if the student is already present in the Merkle tree. Then it it will just make sure the student’s score is greater than what is limit for pass or fail and return the bool value if pass or fail.
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 teachersremark
The
zk project
command can scaffold the UI for your project. For this tutorial, selectnone
:Let’s cd into guessgame:
cd teachersremark
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/teachersremark
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 { Add } from './teachersremark.js'; // Class which we are going to export from teachersremark.ts export { Add };
It will still look red because we have not exported the class yet. We will export in the coming part. For now your
index.ts
should be looking like this:import { Add } from './teachersremark.js'; export { Add };
Now save and close it. Now open your
teachersremark.ts
.Let’s start with importing the packages which we will be using in
teachersremark.ts
.import { Field, SmartContract, state, State, method, MerkleWitness, PublicKey, PrivateKey, Struct, UInt32, Poseidon, Bool, Signature, } from 'o1js';
As you know only a limited amount of data can be stored on a 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
teachersremark.ts
.class MyMerkleWitness extends MerkleWitness(8) {} // MyMerkleWitnes class of height 8
As discussed earlier we will now create our first class Account extending struct and having two methods as given below:
export class Account extends Struct({ stdPublicKey: PublicKey, marks: UInt32, }) { // Hash method to has the contents of the account while adding it to the merkle tree hash(): Field { return Poseidon.hash(Account.toFields(this)); } // To return a new account with the smae public key but updated marks. addMarks(marks: UInt32) { return new Account({ stdPublicKey: this.stdPublicKey, // Public Key associated to students name marks: this.marks.add(marks), // Marks scored by student & added by SSC board }); } }
Now let’s create our other class ADD which is extending SmartContract. It contains a couple of methods:
initstate(): Allows us to initialize smart contract, the root of the Merkle tree and the trusted organization’s public key.
addMarks(): It makes sure that only the trusted organization is adding the marks and no one else by deriving the public key from the private key and then matching it up with the one that was set during deployment. It checks whether the root of the Merkle tree is the same then if the account’s hash is present in the Merkle tree and then it updates the marks, calculates new root and then updates the old root with a new one.
verifyIfPass(): This method checks if the root is the same then checks if the account hash is the same and then verifies the signature that was passed is true and then checks if the student has passed or failed.
export class Add extends SmartContract { // Maharashtra Board of Examination: SSC (State Board Of Secondary School Certificate). (India) @state(PublicKey) sscPublicKey = State<PublicKey>(); // This is the root of our merkle tree. This will be set during deployment. // As Students number are fixed so we don't need to add them again and again, we will add them at once during deployment. @state(Field) commitment = State<Field>(); // initState method: Here initialising the root and the Public key i.e the Deployer Key // Initial commitment will come during the deployment. The Account will already have Student's name and their public key @method initState(sscPublicKey: PublicKey, initialCommitment: Field) { super.init(); this.sscPublicKey.set(sscPublicKey); this.commitment.set(initialCommitment); } // This method will add the marks to related public key // Instead of going and updating the value of the old public key, we will add a new account keeping public key same but changing the marks. @method addMarks( sscPrivateKey: PrivateKey, account: Account, marks: UInt32, path: MerkleWitness8 ) { // Here we are making sure that only SSC board should be able to invoke this function. // As Public key is available to everyone anyone can use it. // So, we are going to take the private key as the input. // Then we will check whether the public key that is derived from the private key is same as what has been passed earlier // Circuit Assertion: Getting the public key stored in contract and making sure the new variable has the same const commitedPublicKey = this.sscPublicKey.get(); this.sscPublicKey.assertEquals(commitedPublicKey); // Deriving from private key and checking if it is matching to public key commitedPublicKey.assertEquals(sscPrivateKey.toPublicKey()); // Now let's fetch the on-chain commitment let commitment = this.commitment.get(); this.commitment.assertEquals(commitment); // Let's check if the account is present in the merkle tree path.calculateRoot(account.hash()).assertEquals(commitment); // If it matching then we can add the marks to the account let newAccount = account.addMarks(marks); // we calculate the new Merkle Root, based on the account changes let newCommitment = path.calculateRoot(newAccount.hash()); // Now we will update the existing commitment with the new commitment this.commitment.set(newCommitment); } @method verifyIfPass( sscPublicKey: PublicKey, account: Account, path: MerkleWitness8, signature: Signature ): [Bool, Bool] { const y = Field(80); // Now let's fetch the on-chain commitment let commitment = this.commitment.get(); this.commitment.assertEquals(commitment); // Let's check if the account is present in the merkle tree path.calculateRoot(account.hash()).assertEquals(commitment); // Here we are verifying if the signature that was passed is correct or not const ok = signature.verify(sscPublicKey, account.stdPublicKey.toFields()); ok.assertTrue(); // Checking if the person has passed or not and returning the values. let passORfail = account.marks.greaterThanOrEqual(UInt32.from(y)); return [passORfail, ok]; } }
So, overall your
teachersremark.ts
should be looking like this.import { Field, SmartContract, state, State, method, MerkleWitness, PublicKey, PrivateKey, Struct, UInt32, Poseidon, Bool, Signature, } from 'o1js'; class MerkleWitness8 extends MerkleWitness(8) {} export class Account extends Struct({ stdPublicKey: PublicKey, marks: UInt32, }) { // Hash method to has the contents of the account while adding it to the merkle tree hash(): Field { return Poseidon.hash(Account.toFields(this)); } // To return a new account with the smae public key but updated marks. addMarks(marks: UInt32) { return new Account({ stdPublicKey: this.stdPublicKey, marks: this.marks.add(marks), }); } } export class Add extends SmartContract { // Maharashtra Board of Examination: SSC (State Board Of Secondary School Certificate). (India) @state(PublicKey) sscPublicKey = State<PublicKey>(); // This is the root of our merkle tree. This will be set during deployment. // As Students number are fixed so we don't need to add them again and again, we will add them at once during deployment. @state(Field) commitment = State<Field>(); // initState method: Here initialising the root and the Public key i.e the Deployer Key // Initial commitment will come during the deployment. The Account will already have Student's name and their public key @method initState(sscPublicKey: PublicKey, initialCommitment: Field) { super.init(); this.sscPublicKey.set(sscPublicKey); this.commitment.set(initialCommitment); } // This method will add the marks to related public key // Instead of going and updating the value of the old public key, we will add a new account keeping public key same but changing the marks. @method addMarks( sscPrivateKey: PrivateKey, account: Account, marks: UInt32, path: MerkleWitness8 ) { // Here we are making sure that only SSC board should be able to invoke this function. // As Public key is available to everyone anyone can use it. // So, we are going to take the private key as the input. // Then we will check whether the public key that is derived from the private key is same as what has been passed earlier // Circuit Assertion: Getting the public key stored in contract and making sure the new variable has the same const commitedPublicKey = this.sscPublicKey.get(); this.sscPublicKey.assertEquals(commitedPublicKey); // Deriving from private key and checking if it is matching to public key commitedPublicKey.assertEquals(sscPrivateKey.toPublicKey()); // Now let's fetch the on-chain commitment let commitment = this.commitment.get(); this.commitment.assertEquals(commitment); // Let's check if the account is present in the merkle tree path.calculateRoot(account.hash()).assertEquals(commitment); // If it matching then we can add the marks to the account let newAccount = account.addMarks(marks); // we calculate the new Merkle Root, based on the account changes let newCommitment = path.calculateRoot(newAccount.hash()); // Now we will update the existing commitment with the new commitment this.commitment.set(newCommitment); } @method verifyIfPass( sscPublicKey: PublicKey, account: Account, path: MerkleWitness8, signature: Signature ): [Bool, Bool] { const y = Field(80); // Now let's fetch the on-chain commitment let commitment = this.commitment.get(); this.commitment.assertEquals(commitment); // Let's check if the account is present in the merkle tree path.calculateRoot(account.hash()).assertEquals(commitment); // Here we are verifying if the signature that was passed is correct or not const ok = signature.verify(sscPublicKey, account.stdPublicKey.toFields()); ok.assertTrue(); // Checking if the person has passed or not and returning the values. let passORfail = account.marks.greaterThanOrEqual(UInt32.from(y)); return [passORfail, ok]; } }
That’s all your zkSmart Contract is now ready.
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 { Account, Add } from './teachersremark.js'; import { AccountUpdate, Field, MerkleTree, MerkleWitness, Mina, PrivateKey, Signature, 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 = 'Radhe' | 'Krishn' | '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 a map 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>( ['Radhe', 'Krishn', 'Bob', 'Alice', 'Charlie', 'Olivia'].map( (name: string, index: number) => { return [ name as Names, new Account({ stdPublicKey: Local.testAccounts[index].publicKey, marks: 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 initialize a new Merkle Tree with height 8 const Tree = new MerkleTree(8); Tree.setLeaf(0n, Accounts.get('Radhe')!.hash()); Tree.setLeaf(1n, Accounts.get('Krishn')!.hash()); Tree.setLeaf(2n, Accounts.get('Bob')!.hash()); Tree.setLeaf(3n, Accounts.get('Alice')!.hash()); Tree.setLeaf(4n, Accounts.get('Charlie')!.hash()); Tree.setLeaf(5n, 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 teachersremark instance. We will pass the zkappAddress as the parameter.
let teachersremark = new Add(zkappAddress); console.log('Deploying Contract .....');
Before we deploy our contract we need to compile the smart contract as we have enabled the proofs
if (doProofs) { await Add.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, }); teachersremark.deploy(); teachersremark.initState(feePayer, initialCommitment); }); await tx.prove(); await tx.sign([feePayerKey, zkappKey]).send(); console.log('Contract Deployed....'); console.log('Initial marks: ' + Accounts.get('Radhe')?.marks); console.log( `Now let the SSC board add some Marks... (sshhhhh!! We are gonna fail some Students!!)` );
Now we will create an async function for the SSC board to add the marks.
async function addMarks(name: Names, marks: number, index: bigint) { 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 private key of trusted organisation, marks to be added, account and witness. Send the transaction. try { let tx = await Mina.transaction(feePayer, () => { teachersremark.addMarks( feePayerKey, account, UInt32.from(marks), witness ); }); await tx.prove(); await tx.sign([feePayerKey, zkappKey]).send(); } catch (e: any) { console.log(e.message); } // Once the transaction is successful, we can update our off-chain storage as well account.marks = account.marks.add(marks); Tree.setLeaf(index, account.hash()); teachersremark.commitment.get().assertEquals(Tree.getRoot()); } console.log( `Now let the SSC board add some Marks... (sshhhhh!! We are gonna fail some Students!!)` ); await addMarks('Radhe', 100, 0n); await addMarks('Krishn', 100, 1n); await addMarks('Bob', 10, 2n); console.log( `Marks added!! Radhe : ${Accounts.get('Radhe')?.marks} and Krishn : ${ Accounts.get('Krishn')?.marks } and Bob ${Accounts.get('Bob')?.marks}` );
Now that marks are added our students can directly verify if they have passed or not.
For that let’s create a async function for them. It will take the name and the index of the student in the tree.
async function verifyIfPass(name: Names, index: bigint) { 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); let sig = Signature.create(feePayerKey, account.stdPublicKey.toFields()); try { const [passORfail, ok] = teachersremark.verifyIfPass( feePayer, account, witness, sig ); // Nested If else to check and display the message accordingly if (ok.toBoolean()) { console.log( `Hmm... Signature is legit, now let's see if he has passed or not !!` ); if (passORfail.toBoolean()) { console.log(`Dude, ${name} has proof, ${name} passed !!!`); } else { console.log(`Ohhh!!! Better luck next time ${name} :). Also, Study hard Pro !!`); } } else { console.log( `Hmm... Signature is not legit!! You are a liar, imposter ${name}!!` ); } } catch (e: any) { console.log('Here Here!' + e.message); } } // Let's verify if you have pass await verifyIfPass('Radhe', 0n); await verifyIfPass('Krishn', 1n); await verifyIfPass('Bob', 2n);
Now your overall
main.ts
should look like this:import { Account, Add } from './teachersremark.js'; import { AccountUpdate, Field, MerkleTree, MerkleWitness, Mina, PrivateKey, Signature, UInt32, } from 'o1js'; type Names = 'Radhe' | 'Krishn' | 'Bob' | 'Alice' | 'Charlie' | 'Olivia'; const doProofs = true; class MyMerkleWitness extends MerkleWitness(8) {} 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. import { Account, Add } from './teachersremark.js'; import { AccountUpdate, Field, MerkleTree, MerkleWitness, Mina, PrivateKey, Signature, UInt32, } from 'o1js'; type Names = 'Radhe' | 'Krishn' | 'Bob' | 'Alice' | 'Charlie' | 'Olivia'; const doProofs = true; class MyMerkleWitness extends MerkleWitness(8) {} 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. // we initialize a new Merkle Tree with height 8 const Tree = new MerkleTree(8); Tree.setLeaf(0n, Accounts.get('Radhe')!.hash()); Tree.setLeaf(1n, Accounts.get('Krishn')!.hash()); Tree.setLeaf(2n, Accounts.get('Bob')!.hash()); Tree.setLeaf(3n, Accounts.get('Alice')!.hash()); Tree.setLeaf(4n, Accounts.get('Charlie')!.hash()); Tree.setLeaf(5n, Accounts.get('Olivia')!.hash()); let initialCommitment: Field = Tree.getRoot(); let teachersremark = new Add(zkappAddress); console.log('Deploying Contract .....'); if (doProofs) { await Add.compile(); } let tx = await Mina.transaction(feePayer, () => { AccountUpdate.fundNewAccount(feePayer).send({ to: zkappAddress, amount: initialBalance, }); teachersremark.deploy(); teachersremark.initState(feePayer, initialCommitment); }); await tx.prove(); await tx.sign([feePayerKey, zkappKey]).send(); console.log('Contract Deployed....'); console.log('Initial marks: ' + Accounts.get('Radhe')?.marks); console.log( `Now let the SSC board add some Marks... (sshhhhh!! We are gonna fail some Students!!)` ); async function addMarks(name: Names, marks: number, index: bigint) { 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 private key of trusted organisation, marks to be added, account and witness. Send the transaction. try { let tx = await Mina.transaction(feePayer, () => { teachersremark.addMarks( feePayerKey, account, UInt32.from(marks), witness ); }); await tx.prove(); await tx.sign([feePayerKey, zkappKey]).send(); } catch (e: any) { console.log(e.message); } // Once the transaction is successful, we can update our off-chain storage as well account.marks = account.marks.add(marks); Tree.setLeaf(index, account.hash()); teachersremark.commitment.get().assertEquals(Tree.getRoot()); } console.log( `Now let the SSC board add some Marks... (sshhhhh!! We are gonna fail some Students!!)` ); await addMarks('Radhe', 100, 0n); await addMarks('Krishn', 100, 1n); await addMarks('Bob', 10, 2n); console.log( `Marks added!! Radhe : ${Accounts.get('Radhe')?.marks} and Krishn : ${ Accounts.get('Krishn')?.marks } and Bob ${Accounts.get('Bob')?.marks}` ); async function verifyIfPass(name: Names, index: bigint) { 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); let sig = Signature.create(feePayerKey, account.stdPublicKey.toFields()); try { const [passORfail, ok] = teachersremark.verifyIfPass( feePayer, account, witness, sig ); // Nested If else to check and display the message accordingly if (ok.toBoolean()) { console.log( `Hmm... Signature is legit, now let's see if he has passed or not !!` ); if (passORfail.toBoolean()) { console.log(`Dude, ${name} has proof, ${name} passed !!!`); } else { console.log(`Ohhh!!! Better luck next time ${name} :). Also, Study hard Pro !!`); } } else { console.log( `Hmm... Signature is not legit!! You are a liar, imposter ${name}!!` ); } } catch (e: any) { console.log('Here Here!' + e.message); } } // Let's verify if you have pass await verifyIfPass('Radhe', 0n); await verifyIfPass('Krishn', 1n); await verifyIfPass('Bob', 2n);
That’s all. Your interaction script is ready. Now let’s test it !!!
Congratulations 🥂 !!
Execution:
Executing the code is 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 get something like this.