Building zk based web3 apps
We will be doing walk through of a simple zk-dapp for age verification, that will allow users to prove that they are below a certain age without revealing their actual age. This is a fairly trivial problem to solve and may not necessarily require zero-knowledge proofs but to keep things simple and easy to understand we will go ahead with this idea.
Subscribe here for all the cool zk-tech and web3 stuff!
The overview of the architecture looks something like the below.
We will be using a nextjs app to interact with the smart contract and take user input ( age) which is meant to be private and will not be published on-chain. The proof generation occurs on the frontend using snarkjs. There are two smart contracts for this dapp, AgeCheck and Verifier:
AgeCheck.sol
takes proof inputs from the frontend app and maintains the mapping of addresses if they are verified or not.Verifier.sol
has one purpose i.e. to verify the proof.
There are lots of different ways to generate zero-knowledge proofs and these are continuously evolving. There is a lot of advancement going in the zero-knowledge space. For the purpose of this dapp, we will be using SNARK proofs, more specifically Groth16.
To generate zero-knowledge proof for our use case we need to define the problem statement in terms of circuits. In this case, our problem statement is verifying that the user's age ( age is private information) should be below a certain age limit ( age limit is public information). To create circuits we will be using circom library.
The purpose of the circuit is to take the user’s age as input and check if that is above a certain age limit ( for eg. 18). The circuit is then compiled to create wasm which then can be used in the browser to generate proofs. The input to the circuit includes private and public inputs - in this case, user’s age is private input ( as it doesn’t need to be revealed) and ageLimit is public input ( known to everyone). These inputs are used in generating the witness ( which is a set of signals that satisfies the circuit) you can read more about them here.
To summarize in short**, zk-SNARK proofs are a specific type of zero-knowledge proofs that allow you to prove that you know a set of signals i.e. witness that matches all the constraints of a circuit without revealing any of the signals except the public inputs and the outputs.**
I know it sounds a bit confusing, but may in a later post I will simplify some math behind it and maybe a circuit primer. For the purpose of this dapp, let's proceed in order to understand how everything works in tandem.
Now let’s dive into the code, we will be using zk-block boilerplate for zero-knowledge dapps, refer to this repo.
Clone the repository.
git clone https://github.com/heypran/zk-block.git
Note: Following along with code requires familiarity with react, ethers, and solidity.
We will start from the backend ( or if you look at the above diagram, from top to bottom). Here’s what we gonna do:
cd zk-block
cd backend
yarn install
yarn compile:circuits
This will use the circuit present in the backend/circuits
folder and compile it using the script in scripts/compile-circuits.ts
If you know about zero-knowledge proofs, you will know that Groth16 protocol requires performing a trusted setup. This is done in two phases. Phase 1 is independent of the circuit and phase 2 is circuit dependent. You can read about them here.
Caution: Please note that you need to perform MPC to generate trusted setup, do not use the setup in compile-circuits.ts
as-is for production.
When you compile the circuit, it will create the necessary files in the build/snarks
folder which can be used in the frontend repo to generate proofs. The above compilation also generates the Verifier.sol
in the contracts
folder. The Verifier.sol
is used to verify the proof on-chain.
Execute the following command inside the background
folder to compile the contracts
yarn compile
This will compile the contracts in backend/contracts
folder, which contains AgeCheck.sol
and Verifier.sol
( which is generated in the above step). The compilation is done using hardhat configuration and the selected network in the hardhat.config.ts
, the compiled code is generated in the backend/build
folder.
(Optional) Deploy contracts to the network of your own choice
Note: This step is not required, if you don’t want to deploy, you can simply use the existing testnet contract address, which is already configured inside ui/src/config/constants.ts
and also mentioned in the github readme.
In order to deploy the compiled contract, you will have to create a backend/private.json
with your private key inside it ( refer private.example.json
) and change the selected network in hardhat.config.ts
to a network of your choice ( refer chain.ts
to view the networks).
Execute the below command to deploy-
yarn deploy:agecheck --network testnet
The deployed contract address will be displayed on the terminal, configure it inside ui/src/config/constants.ts
Now let’s move to the frontend, to understand how the proof is generated on the frontend.
Execute the below commands.
cd ..
cd frontend
yarn install
yarn dev
The above will run the zk-block frontend locally, visit http://localhost:3000/dapp
to see the age verification in action. You may connect to network, Polygon, or Harmony ( or where you deployed the smart contract), using Metamask.
Input the age in the box, and click verify. This will verify your age as above 18 using the smart contract without publishing your age on the blockchain.
Behind the scenes, when the user inputs the age, it will generate the witness using public and private inputs ( refer to the below snippet from ui/src/utils/zk/zk-witness.ts
).
The code is commented on for understanding.
// read the wasm generated by compiling the circuit in STEP 1
const buffer = await getBinaryPromise(circuitWasm);
// generate witness using calculator file generated from circom
// and then passing the public and private input
const witnessCalculator = await generateWitnessJs(buffer);
const buff = await witnessCalculator.calculateWTNSBin(params, 0);
const provingKey = await fetch(zkey);
const provingKeyBuffer = await provingKey.arrayBuffer();
// Generate groth proof
const { proof, publicSignals } = await snarkjs.groth16.prove(
new Uint8Array(provingKeyBuffer),
buff,
null,
);
// required to generate solidity call params
const editedPublicSignals = unstringifyBigInts(publicSignals);
const editedProof = unstringifyBigInts(proof);
// Generate solidity compatible params for Verifier.sol
const callData = await snarkjs.groth16.exportSolidityCallData(
editedProof,
editedPublicSignals,
);
The calldata
are proof parameters generated to be able to verify the proof ( i.e. the age is indeed above a limit, in this case 18) defined using our Verifier.sol
deployed on-chain.
You can play around with zk age verification here. Now if you go through the architecture diagram, you will have a better understanding :).
Please note: This zkblock boilerplate is updated on a regular basis if you find that anything is not working as it suppose to be or you want to suggest any improvement, feel free to create an issue. Also, you can go through the readme. Any contributions are welcome.
Subscribe here for all the cool zk-tech and web3 stuff!
Happy Hacking!
References:
https://github.com/heypran/zk-block
https://github.com/iden3/circom