Getting Started with zk-SNARKs
In this section, we walk through a simple example in which we (the prover) demonstrate to a verifier that we know the secret preimage of a public hash without revealing the secret itself.
In cryptography, a hash function takes an input value, called the preimage, and produces a fixed-length output known as the hash. A key property of cryptographic hash functions is that they are one-way: it is computationally infeasible to recover the original input from the hash alone.
We, the prover, want to prove to a verifier the following statement:
Statement: I know a value
secretsuch thatH(secret) = hash.
Where:
secretis private (i.e known by the prover only)hashis public (i.e known by the prover and the verifier)
Before we start, it is important to note that the proof developed here is not very useful in practice because it can be used only once (for reasons explained in the Conlusion). However, we present a more practical construction called Proof of Commitment, which addresses this limitation.
To construct this first zero-knowledge proof, we follow these steps:
- Design an arithmetic circuit in Circom checks whether a private input matches a public hash value.
- Generate the trusted setup (Powers of Tau Ceremony).
- Generate two key artifacts:
- A prover that outputs a zero-knowledge proof.
- A verifier that checks the proof using only public information.
- Generate and verify a proof in JavaScript.
- [Optional] Verify the proof on-chain using an Ethereum smart contract.
Step 1: Building the Arithmetic Circuit in Circom
Circom circuits operate on signals, which are private by default unless explicitly declared as public inputs or outputs.
Circuit Code
Our goal is to prove that we know a secret input whose hash equals a given public hash value. We use the Poseidon hash function, which is optimized for zero-knowledge circuits and provided by circomlib.
npm install circomlib
pragma circom 2.0.0;
include "./node_modules/circomlib/circuits/poseidon.circom";
template OurFirstProof() {
signal input secret;
signal output hash;
component hasher = Poseidon(1);
hasher.inputs[0] <== secret;
hash <== hasher.out;
}
component main = OurFirstProof();
This circuit takes a private secret and outputs its Poseidon hash as a public signal.
Note that our proof does not have public inputs; otherwise public signals should be declared in the main component (as done in Proof of Commitment, for example).
Compiling the Circuit
To keep the generated zk artifacts organized, create a directory called zk-data.
mkdir zk-data
Now, let’s compile the circuit to generate the file zk-data/OurFirstProof.r1cs.
circom OurFirstProof.circom --r1cs --wasm -o zk-data
The output should confirm successful compilation and show constraint statistics:
template instances: 71
non-linear constraints: 213
linear constraints: 0
public inputs: 0
public outputs: 1
private inputs: 1
private outputs: 0
wires: 215
labels: 583
Written successfully: zk-data/OurFirstProof.r1cs
Written successfully: zk-data/OurFirstProof_js/OurFirstProof.wasm
Everything went okay, circom safe
The number of constraints (213) is important as it determines the minimum size of the trusted setup.
Step 2: Generating the Trusted Setup (Powers of Tau Ceremony)
As mentioned in the introduction, zk-SNARKs require a trusted setup, which consists of two phases:
- Phase 1: Circuit-independent (global).
- Phase 2: Circuit-specific.
We generate a Phase 1 setup supporting up to \(2^{12}\) constraints, which is sufficient since \(213*2\) constraints is roughly \(2^{9}\):
snarkjs powersoftau new bn128 12 zk-data/pot12_0000.ptau -v
snarkjs powersoftau contribute zk-data/pot12_0000.ptau zk-data/pot12_0001.ptau --name="First contribution" -v
snarkjs powersoftau prepare phase2 zk-data/pot12_0001.ptau zk-data/pot12_final.ptau -v
For phase 1, we perform the minimal three round ceremony. Once pot12_final.ptau is generated, the files pot12_0000.ptau and pot12_0001.ptau are no longer needed and must be deleted. In the ZK jargon, those files are called toxic waste and they must not be leaked to prevent proof forgery.
Note: This is the reason why, for production use, you should rely on a multi-party trusted setup such as the Perpetual Powers of Tau Ceremony as done for the
zk-toolboxlibrary.
Step 3: Generating the Prover and Verifier
For testing purposes, we perform a minimal Phase 2 setup:
snarkjs groth16 setup zk-data/OurFirstProof.r1cs zk-data/pot12_final.ptau zk-data/OurFirstProof.zkey
snarkjs zkey export verificationkey zk-data/OurFirstProof.zkey zk-data/OurFirstProof.vkey
These two commands generate several files in zk-data:
- the prover that is composed of two files: the compiled circuit (
.wasmfile) and its corresponding proving key (.zkeyfile) - the verifier that is essentially one verifier key (
.vkeyfile)
Note: For production systems, a full Phase 2 ceremony should be conducted. See the Circom documentation or the
Makefilein ourzk-toolboxlibrary for more details.
Step 4: Generating and Validating Proofs in JavaScript
Now that our prover and verifier are ready, let’s create and verify proofs in Javascript.
Import Prover and Verifier
First, let’s import the wasm prover, the proving and verifying keys:
import { readFileSync } from 'fs';
import { join } from 'path';
import { groth16 } from 'snarkjs';
let wasmFile = join("zk-data", "OurFirstProof_js", "OurFirstProof.wasm");
let zkeyFile = join("zk-data", "OurFirstProof.zkey");
const vKey = JSON.parse(readFileSync(join("zk-data", "OurFirstProof.vkey")));
Generate a Secret
Our ZK proofs takes a secret value as input. As explained in the Circom documentation, an arithmetic circuit takes input signals that are values between 0,...,p-1 where p is the prime number set by default to
p = 21888242871839275222246405745257275088548364400416034343698204186575808495617.
So our secret value in Javascript will be a random 32 bits BigInt modulo p:
npm install @noble/ciphers
import { randomBytes } from '@noble/ciphers/webcrypto';
const p = BigInt('21888242871839275222246405745257275088548364400416034343698204186575808495617');
function randomBigInt32ModP(): bigint {
const bytes = randomBytes(32)
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return BigInt('0x' + hex) % p;
}
const secret = randomBigInt32ModP();
Generate the Proof
Then, we just need one line to call groth16.fullProve that takes the wasm file, the proving key and the inputs (the private secret here) and that function returns the proof and all of the public signals including public inputs and public outputs.
const { proof, publicSignals } = await groth16.fullProve({secret}, wasmFile, zkeyFile);
(Optional) Verify Output Correctness Locally
As an optional step after generating the proof, the prover should verify that the circuit is correct by verifying the public outputs. Here, we want to check that hash in the public signals corresponds to the Poseidon hash of secret from the private inputs.
Poseidon has been implemented in Javascript in the Poseidon Lite NPM package.You can use to it to verify the public output value computed by the circuit.
First, install poseidon-lite
npm install poseidon-lite
Then compute the hash of the secret and verifies it corresponds to the public value generated by the circuit.
import { poseidon1 } from 'poseidon-lite';
const hash = poseidon1([secret]);
console.assert(publicSignals[0] == hash);
Verify the Proof
In our circuit, the only public value we have is the hash of the secret value. Therefore, the array publicSignals has only one element being the hash. Now, we can verify this proof calling the function groth16.verify using the verifying key and the public signals. If the proof is valid, it should return true.
const isValid = await groth16.verify(vKey, publicSignals, proof);
console.assert(isValid);
[Optional] Step 5: Verifying the Proof On-Chain
So far, we have been generating and verifying proofs using Javascript. Additionally, the NPM module snarksjs enables to compile the verifier in the Solidity language directly thus enabling to write an Ethereum smart-contract that can verify the proof on-chain.
Export Solidity Verifier
Then, let’s turn our verifier into a smart contract inside the directory contracts
snarkjs zkey export solidityverifier zk-data/OurFirstProof.zkey contracts/verifier.sol
Here, we are going to assume that the reader knows how to deploy such a smart contract. However, let’s see how to pack the proof and the public signals to call this contract.
Packing the Proof for Calling the Contract Verifier
The goal is to generate the list of arguments [pi_a, pi_b, pi_c, signals] that can be passed as arguments to a deployed Verifier contract. Those parameters can exported from the proof and the publicSignals as such:
const proofCalldata = await groth16.exportSolidityCallData(proof, publicSignals);
const [pi_a, pi_b, pi_c, signals] = JSON.parse("[" + proofCalldata + "]");
Conclusion
In this first tutorial, we proved knowledge of a secret that hashes to a known value, without revealing the secret. We built a Circom circuit, generated a trusted setup, produced and verified a zero-knowledge proof, and exported a Solidity verifier.
Yet, there is a major shortcoming. Once a proof is generated for a given secret, it can only be used once in practice. Because once published, it can be replayed by anyone. Hence, anyone can prove they know the secret behind the public hash as well.
This is not a bug: SNARKs are designed to produce publicly verifiable, non-interactive proofs. But this also means a proof is not bound to a specific session, time, or verifier.
This becomes an issue whenever the protocol expects the prover to demonstrate knowledge multiple times, e.g.:
- Logging in repeatedly
- Authorizing transactions
- Proving control of a private key over time
- Rate-limited access
- Preventing replay attacks
In these cases, a static proof is equivalent to a leaked password: once published, it can be reused by anyone.
To prevent replay, the statement must change every time, while still being tied to the same secret. We address this problem in our Proof of Commitment construction by defining the concept of context binding.