Zero Knowledge App using SNARKs
A blog on how to create a zero knowledge app using SnarkJS, solidity and react
Originally published on November 11, 2023 on my previous blog.
In this blog, we will create a simple zero knowledge application where a user can prove that he is above 18 years old without revealing his age. To achieve this we will use CIRCOM and snarkjs to create a circuit and generate a proof. Then we will create a solidity contract which will verify the proofs and store the result on chain. Finally, we will create a simple react app to interact with the contract.
You can find the complete code here.
Zero Knowledge
Zero knowledge is a way of proving that you know something without revealing what you know. For example, if you wanted to prove that you know the password to a door without revealing the password, you could do so by opening the door. Someone watching you would know that you know the password, but they would not learn what the password is. This is the basic idea behind zero knowledge proofs. You can find more examples here.
zk-SNARKs
zk-SNARKs stands for Zero-Knowledge Succinct Non-Interactive Argument of Knowledge. One popular implementation of zk-SNARKs is Zcash. It is a type of zero-knowledge proof that allows one party to prove to another that a statement is true, without revealing any information beyond the validity of the statement itself, and without any interaction between the prover and the verifier. The non-interactive nature of zk-SNARKS make it suitable for scenarios where real-time interaction is not possible. In our project, the age verification proofs will be verified on-chain, which can be later used by other users to verify the age of the user.
CIRCOM
Circom is a domain-specific language for writing arithmetic circuits for zero-knowledge proof systems. It is used to create circuits which can be used to generate proofs. In Circom circuits, we have signals which acts like inputs and outputs. We will also use the circomlib library which contains some useful circuits like comparators, hashers, etc. You can find more about the language here.
Initialising the project
Since we will be creating a smart contract at the end, we will use the hardhat framework to create the project. This will help us to compile, deploy and test the contract. So we can follow the hardhat installation guide to create the project. After setting up the project, we will install snarksjs and circomlib npm packages to create the circuit.
npm install snarksjs circomlib
After this you can create a separate directory for the zk related codes to keep things organised.
Creating the circuit
We will create a simple circuit which will take the age of the user as input and output a boolean value which will be true if the user is above 18 years old.
First we will define the signals. In our case, we will have one input signal which will be the age of the user and one output signal which will be true if the user is above 18 years old.
signal input age;
signal output out;
Now we need to compare the age with 18. For this, we will use the GreaterThan component from the circomlib library. This component takes two inputs and outputs 1 if the first input is greater than the second input. We will connect the age signal to the first input and 18 to the second input. Then we will connect the output of the GreaterThan component to the output signal. You can find more about the GreaterThan component here.
component greaterThan = GreaterThan(8); // 8 denotes number of bits
greaterThan.in[0] <== age;
greaterThan.in[1] <== 17; // 18 is considered 18+ so 17 is the limit
out <-- greaterThan.out;
out === 1;
Before using the comparator, we need to include the circomlib library that we installed with npm. So include the correct path to the library in the code. The overall circuit will look like this:
pragma circom 2.0.0;
include "../../node_modules/circomlib/circuits/comparators.circom";
template AgeAbove18() {
signal input age;
signal output out;
component greaterThan = GreaterThan(8); // 8 denotes number of bits
greaterThan.in[0] <== age;
greaterThan.in[1] <== 17; // 18 is considered 18+ so 17 is the limit
out <-- greaterThan.out;
out === 1;
}
component main = AgeAbove18();
Though I have said the code returns true or false based on age, it actually always returns true, and if age is less than 18 then it gives Assert Error.
Next we will compile the circuit and export the solidity code. This process is same as mentioned in the circom documentation. You can follow the steps mentioned there to compile the circuit and export the solidity code.
Creating the solidity contract
After exporting the solidity code, we will create a solidity contract which will verify the proofs and store the result on chain. We will copy the exported solidity code to contracts directory of hardhat project and name it verifier.sol.Also, create a new contract AgeVerifier.sol which will call the verifier contract and store result. The AgeVerifier.sol contract will look like this:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
interface IVerifier {
function verifyProof(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[2] memory input
) external view returns (bool);
}
contract AgeVerifier {
IVerifier public verifier;
mapping(address => bool) public ageChecked;
mapping(address => address) public approvedViewer;
constructor(address _verifier) {
verifier = IVerifier(_verifier);
}
function verifyProof(
uint256[2] memory a,
uint256[2][2] memory b,
uint256[2] memory c,
uint256[2] memory input
) external {
require(
verifier.verifyProof(a, b, c, input),
"Below 18 years old"
);
ageChecked[msg.sender] = true;
}
function CheckProof(address _w) external view returns (bool) {
require(_w == msg.sender || approvedViewer[_w] == msg.sender, "Not approved to check");
return ageChecked[_w];
}
function Approve(address _w) external {
approvedViewer[msg.sender] = _w;
}
}
The verifyProof function is used to verify the proof and store the result on chain.
I have added a few extra features to the contract. The Approve function can be used to approve another user to check the age. The CheckProof function can be used to check if the user has verified his age or not.
Now to call the verifyProof function, we need to pass the proof as arguments. We will use the snarkjs library to generate the proof. The code to generate proof will look like this:
const { groth16 } = require('snarkjs')
const witness = {
age: 20,
}
const { proof, publicSignals } = await groth16.fullProve(witness, wasmFilePath, finalZkeyPath, null)
Here pass the age as the witness. The wasmFilePath is the path to the circuit.wasm file which is generated when we compiled the circuit. The finalZkeyPath is the path to the final.zkey file which is generated in phase 2 of Groth16 Trusted setup. The proof and publicSignals are the outputs of the groth16.fullProve function. We will pass these two as arguments to the verifyProof function of the contract. The code to call the verifyProof function will look like this:
const transaction = verifier.verifyProof(
[proof.pi_a[0], proof.pi_a[1]],
[
[proof.pi_b[0][1], proof.pi_b[0][0]],
[proof.pi_b[1][1], proof.pi_b[1][0]],
],
[proof.pi_c[0], proof.pi_c[1]],
publicSignals,
)
Now yo can write tests in the test directory to test the contract. You can find the complete code here.
Creating the react app
Now we can create a basic react app to interact with the contract. We will take age as input and then generate the proof. We can use wagmi now to connect wallet and call the verifyProof function of the contract. This will store if the user is above 18 or not in the contract. Remember that the contract will only store the result if the user is above 18. If the user is below 18, then error will be raised at the proof generation step.
The whole react app is available in the frontend directory of the github repo.
Comments
Join the discussionNo comments yet. Be the first to comment.