Introduction
Zero-knowledge proofs (ZKPs) enable privacy-preserving verification of information, and Circom is a popular tool for building ZK circuits. In this tutorial, we’ll create a zk circuit and the goal is to prove that a user knows a “preimage” (a secret input) that hashes to a specific value, without revealing the preimage itself. Additionally, it generates a nullifier to prevent double usage of the preimage, which is essential in applications like privacy-preserving payments, identity verification, and anonymous voting.
Revisiting Key Concepts
- Prover and Verifier: The prover generates a proof that the verifier can use to confirm a statement’s validity without learning any details.
- Proof: A piece of cryptographic evidence that convinces the verifier.
- Witness: Hidden input data used to construct the proof.
- Circuit: A set of logical steps used to create a proof. It defines how the inputs relate to the outputs.
- Constraint System: Rules combining inputs using mathematical and cryptographic operations.
- Trusted Setup: A one-time process to generate cryptographic keys for the prover and verifier to use.
The combination of these elements allows zero-knowledge proofs to provide privacy, security, and verification without revealing sensitive information.
To learn more, read this introduction
Introduction to Zero-Knowledge Proofs
Tools and Environment Setups.
To build and run ZKPs, you’ll need the following tools installed on your system:
Note: you can use zkREPL as a faster and easier web based editor with built in circom compiler.
- Node.js: JavaScript runtime to run SnarkJS scripts.
- Rust: Used to compile Circom.
- Circom: A specialized programming language for ZK circuits.
- SnarkJS: A JavaScript library for generating and verifying ZKP proofs.
Step 1: Install Prerequisites
For installation and project setup, check out Part1 of this section in the series.
Building the Circuit
Create a file called verifypreimage in the circuits/ folder.
verifypreimage.circom
pragma circom 2.1.6;
include "circomlib/poseidon.circom";
// include "https://github.com/iden3/circomlib/blob/master/circuits/poseidon.circom";
template VerifyPreimage () {
//public inputs
signal input hash;
signal input address;
//private inputs
signal input preimage;
// output
signal output nullifier;
// hash the preimage and check if the result matches the hash.
component hasher = Poseidon(1);
hasher.inputs[0] <== preimage;
hasher.out === hash;
// the contract should keep track of seen nullifiers so as to prevent double usage.
component nullifierHasher = Poseidon(2);
nullifierHasher.inputs[0] <== address;
nullifierHasher.inputs[1] <== preimage;
nullifier <== nullifierHasher.out;
}
component main = VerifyPreimage();
/* INPUT = {
"hash": "",
} */
Code Walkthrough
Imports
include "circomlib/poseidon.circom";
- This imports the Poseidon hash function from the
circomlib
, a library of cryptographic primitives commonly used in zk-SNARK circuits.
Signals Declaration
// Public inputs
signal input hash; // Public hash to be verified
signal input address; // Public address (used to compute the nullifier)
// Private inputs
signal input preimage; // Private preimage (user's secret input)
// Output
signal output nullifier; // Public output that allows tracking preimage usage
- Public Inputs:
hash
: This is the known hash value that we want to verify. It will be used to check if it matches the hash of the secret preimage.address
: A public address (could be an Ethereum address or a unique identifier) that gets combined with the preimage to generate a nullifier. This ensures that users cannot reuse the same preimage twice for the same address.
- Private Input:
preimage
: This is the secret value the user knows, and it will be hashed to check if it matches the public hash.
- Output:
nullifier
: A unique identifier that can be publicly tracked to prevent double usage of the same preimage for the same address.
Hashing the Preimage
component hasher = Poseidon(1);
hasher.inputs[0] <== preimage;
hasher.out === hash;
- Hashing Process:
- This creates a Poseidon hash component (
hasher
) that takes 1 input (the preimage). - The preimage is passed into the Poseidon hashing function.
- The output of the Poseidon hash is compared to the public
hash
input usinghasher.out === hash;
. - This constraint ensures that the preimage provided by the prover produces the exact hash that matches the public input. If this constraint fails, the proof is invalid.
- This creates a Poseidon hash component (
Generating the Nullifier
component nullifierHasher = Poseidon(2);
nullifierHasher.inputs[0] <== address;
nullifierHasher.inputs[1] <== preimage;
nullifier <== nullifierHasher.out;
- Nullifier Generation:
- A second Poseidon hash is created to produce a nullifier.
- This hash takes 2 inputs:
- Address (user’s public identifier)
- Preimage (user’s secret)
- The output of this hash becomes the nullifier.
- This nullifier ensures that for a given
(address, preimage)
pair, a unique identifier is produced. - By keeping track of these nullifiers on-chain, the smart contract can detect and prevent reuse of the same preimage with the same address.
Main Component
component main = VerifyPreimage();
- This creates the main component for the circuit. It instantiates the
VerifyPreimage
template, so when the circuit is compiled, the main logic runs. - The
main
component expects inputs forhash
,address
, andpreimage
and outputs the nullifier.
How it Works
- Prover’s Role:
- The prover (user) knows a preimage and provides it as a private input.
- They claim that this preimage hashes to a specific public
hash
value. - The prover also provides a public address (possibly linked to their wallet or account) that is used to generate a nullifier.
- Verifier’s Role:
- The verifier checks the following conditions:
- Preimage Verification: Does the Poseidon hash of the provided preimage equal the public
hash
?poseidon(preimage) === hash
- Nullifier Calculation: Computes the nullifier for the
(address, preimage)
pair.poseidon(address, preimage)
- This nullifier can be stored on-chain to prevent the prover from using the same preimage again.
- Preimage Verification: Does the Poseidon hash of the provided preimage equal the public
Compiling the Circuit
Run the following command to compile the circuit:
circom circuits/verifypreimage.circom --r1cs --wasm --sym -o output/
Let’s look at each file:
-
–r1cs: Generates the Rank 1 Constraint System (R1CS) file.
-
–wasm: Outputs a WebAssembly file for computation.
-
–sym: Outputs a symbol file for debugging.
-
-o output/: Specifies the output directory.
Check for the following files in output/:
-
verifypreimage.r1cs: The constraint system file.
-
verifypreimage.wasm: The WebAssembly file.
-
verifypreimage.sym: The symbol file for debugging.
Generating Trusted Setup
Generate the keys for the prover and verifier:
wget https://hermez.s3-eu-west-1.amazonaws.com/powersOfTau28_hez_final_10.ptau -O outputs/pot10.ptau
snarkjs groth16 setup outputs/verifypreimage.r1cs outputs/pot10.ptau outputs/verifypreimage_final.zkey
Export the Verification Key
snarkjs zkey export verificationkey outputs/verifypreimage_final.zkey outputs/verification_key.json
Creating Inputs
Create an input file input/input.json
with the following content:
{
"hash": "3cb6bc651b03d994974629eacef9696311d85c2ca9e3d08cbdf01e1833c2a405",
"address": " 0x03C64d863cB2a39881f1702EBED5D46C314F66e2",
"preimage": "54112"
}
These Inputs will be used to generate the proof.
Generating the Proof
node outputs/verifypreimage_js/generate_witness.js outputs/verifypreimage_js/verifypreimage.wasm inputs/input.json outputs/witness.wtns
snarkjs groth16 prove outputs/verifypreimage_final.zkey outputs/witness.wtns outputs/proof.json outputs/public.json
snarkjs groth16 verify outputs/verification_key.json outputs/public.json outputs/proof.json
If everything is setup correctly, you should see:
snarkJS: OK!
Summary
-
Design the Circuit: Create the logic of the proof in a Circom file.
-
Compile the Circuit: Convert the logic to constraint systems and WebAssembly files.
-
Generate Trusted Setup: Produce keys for the prover and verifier.
-
Input Data: Provide the inputs for the computation.
-
Generate Witness: Produce intermediate computation results.
-
Generate Proof: Create a proof from the witness.
-
Verify Proof: Use the verifier to check the proof’s validity.
Use Cases
-
Anonymous Payments (like Tornado Cash):
- The user can deposit funds and then later withdraw them anonymously.
- When they withdraw, they reveal the preimage, and the contract verifies it against the nullifier.
- If the same preimage is used again, the contract will reject it since the nullifier would already exist.
-
Anonymous Voting:
- Voters submit their preimage as their “vote”, and their votes are hashed.
- Once their vote is cast, the system tracks the nullifier to ensure that the voter cannot vote twice.
-
Identity Proofs:
- A user can prove they possess certain credentials (like a driver’s license or degree) without revealing it.
- The user proves that the preimage hashes to a known hash value, and the system tracks the nullifier to ensure the user doesn’t reuse it for multiple claims.
Conclusion
Congratulations! You’ve completed a hands-on implementation of zero-knowledge proofs. This process may seem complex initially, but as you create more advanced circuits, you’ll see how useful ZKPs can be for privacy-preserving computation.
We encourage you to continue your exploration of topics on zk proofs. If you’re interested in diving deeper, you can follow up on the pathway here Zero-Knowledge Proofs on the Celo Blockchain: A Comprehensive Tutorial Series - Pathways - Celo Academy
Resources
Circom docs
SnarkJS
0xparc Circom Workshop
About the author
I’m Jonathan Iheme, A full stack block-chain Developer from Nigeria. With a great passion for Zero Knowledge Technology.