Zero-knowledge proofs (ZKPs) let a prover convince a verifier that a statement is true without revealing the underlying secret. This article explains the core mental model, then walks through a reproducible implementation workflow: defining a circuit, generating a proof off-chain, and verifying it on-chain or in a backend service. You will find runnable code for proof generation and verification, a diagnostic procedure for common failures, and guidance on when ZKPs are the right tool versus when simpler commitments suffice. The focus is on methodology and verification, not on endorsing a specific proving system or endpoint provider.
Scope and assumptions for a ZKP implementation
This guide assumes you are a developer familiar with basic cryptography (hash functions, public-key signatures) and have access to a blockchain RPC endpoint for on-chain verification. You do not need prior experience with arithmetic circuits. The goal is to move from the abstract definition—a prover convinces a verifier that a statement is true without revealing why—to a working pipeline you can test and measure.
We will use a simplified zk-SNARK workflow with Circom and snarkjs, which are widely used for Ethereum-compatible verifiers. The same mental model applies to other proving systems (STARKs, Bulletproofs, Halo2), though the tooling and proof sizes differ. All code uses placeholders such as RPC_URL and PRIVATE_KEY; never commit real keys.
- Node.js 18+ and npm installed
- Basic familiarity with JavaScript and command-line tools
- An RPC endpoint for the target chain (use /en/api-service to compare options)
- A testnet account with test ETH for contract deployment
The mental model: prover, verifier, and the witness
A zero-knowledge proof has three roles: the prover, the verifier, and the statement. The statement is a public claim, such as “I know a secret input that hashes to this public value.” The witness is the secret input. The prover runs a computation that produces a proof; the verifier checks the proof against the public statement. The proof must satisfy completeness (true statements are accepted), soundness (false statements are rejected), and zero-knowledge (the proof reveals nothing about the witness).
In blockchain contexts, the verifier is often a smart contract. The contract does not re-execute the computation; it only checks a succinct proof. This is why ZKPs enable verifiable computation: you can prove that an off-chain computation was done correctly without forcing every node to repeat it. The tradeoff is that generating proofs is computationally expensive, while verification is cheap.
From statement to circuit: defining constraints
A circuit is a set of arithmetic constraints over a finite field. Each constraint is an equation that must hold for the witness. For example, to prove knowledge of a preimage x such that hash(x) = h, you would implement the hash function as a circuit and constrain its output to equal the public h. The prover supplies x; the circuit checks the hash internally.
Circuit design is where most ZKP projects spend their effort. Constraints are expensive: more constraints mean larger proving keys and slower proof generation. You should minimize constraints by using efficient hash functions (e.g., Poseidon instead of SHA-256 inside the circuit) and by avoiding unnecessary range checks. The public inputs are the values the verifier sees; everything else is private.
// example.circom
pragma circom 2.0.0;
include "circomlib/circuits/poseidon.circom";
template PreimageProof() {
signal input preimage;
signal input hash;
component hasher = Poseidon(1);
hasher.inputs[0] <== preimage;
hash === hasher.out;
}
component main = PreimageProof();Compiling the circuit and generating a trusted setup
After writing the circuit, you compile it to a Rank-1 Constraint System (R1CS) and a WebAssembly witness generator. Then you run a trusted setup to produce proving and verification keys. For development, a local Powers of Tau ceremony is sufficient; for production, you must use a multi-party ceremony or a transparent proving system to avoid a single point of toxic waste.
The commands below assume you have circom and snarkjs installed globally. The output files are circuit.r1cs, circuit.wasm, and circuit.zkey. Keep the .zkey file secure; it is required for proof generation.
circom example.circom --r1cs --wasm --sym
snarkjs powersoftau new bn128 12 pot12_0000.ptau -v
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="First contribution" -v
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v
snarkjs groth16 setup example.r1cs pot12_final.ptau circuit_0000.zkey
snarkjs zkey contribute circuit_0000.zkey circuit_final.zkey --name="My contribution" -v
snarkjs zkey export verificationkey circuit_final.zkey verification_key.jsonGenerating a proof off-chain with JavaScript
Proof generation takes the witness (private inputs) and the public inputs, runs the circuit, and outputs a proof and public signals. The code below uses snarkjs in Node.js. It reads the witness calculator from the compiled WASM, computes the witness, and generates a Groth16 proof. The proof is a JSON object that can be sent to a verifier contract or a backend service.
Note that proof generation can take seconds to minutes depending on circuit size. For large circuits, you may need a dedicated proving service or a machine with sufficient memory. The example uses a small circuit for demonstration.
const snarkjs = require("snarkjs");
const fs = require("fs");
async function generateProof() {
const wasm = "example_js/example.wasm";
const zkey = "circuit_final.zkey";
const input = { preimage: 12345, hash: "0x..." }; // replace with actual Poseidon hash
const { proof, publicSignals } = await snarkjs.groth16.fullProve(input, wasm, zkey);
fs.writeFileSync("proof.json", JSON.stringify(proof, null, 2));
fs.writeFileSync("public.json", JSON.stringify(publicSignals, null, 2));
console.log("Proof generated");
}
generateProof().catch(console.error);Verifying the proof on-chain with a Solidity verifier
snarkjs can export a Solidity verifier contract from the verification key. The contract exposes a verifyProof function that takes the proof and public signals and returns a boolean. You deploy this contract to your target chain and call it from your application. The verification gas cost is typically a few hundred thousand gas, depending on the proving system and number of public inputs.
The following script uses ethers.js to call the verifier contract. Replace RPC_URL, PRIVATE_KEY, and VERIFIER_ADDRESS with your values. The proof and public signals are read from the files generated in the previous step.
const { ethers } = require("ethers");
const fs = require("fs");
async function verifyOnChain() {
const provider = new ethers.JsonRpcProvider("RPC_URL");
const wallet = new ethers.Wallet("PRIVATE_KEY", provider);
const verifierAddress = "VERIFIER_ADDRESS";
const abi = ["function verifyProof(uint256[2] a, uint256[2][2] b, uint256[2] c, uint256[] publicSignals) public view returns (bool)"];
const verifier = new ethers.Contract(verifierAddress, abi, wallet);
const proof = JSON.parse(fs.readFileSync("proof.json"));
const publicSignals = JSON.parse(fs.readFileSync("public.json"));
const isValid = await verifier.verifyProof(proof.pi_a, proof.pi_b, proof.pi_c, publicSignals);
console.log("Proof valid:", isValid);
}
verifyOnChain().catch(console.error);Expected verification result and how to interpret it
When the proof is valid, the verifier contract returns true and the transaction (if you submit one) succeeds. If the proof is invalid, the contract returns false or reverts. You should test both cases: generate a proof with the correct witness, then attempt verification with a tampered public signal. The latter should fail, confirming soundness.
For off-chain verification, snarkjs provides a verify command that checks the proof against the verification key. This is useful for debugging before deploying. The expected output is “OK” for a valid proof and an error for an invalid one.
snarkjs groth16 verify verification_key.json public.json proof.jsonDiagnostic procedure for common ZKP failures
Most ZKP failures fall into three categories: witness generation errors, proof generation errors, and verification mismatches. Start by checking that the witness satisfies all constraints. Run the witness calculator directly and inspect the output. If the witness is correct, ensure the proving key matches the circuit; a mismatch between the .zkey and the .r1cs will cause proof generation to fail or produce invalid proofs.
If on-chain verification fails but off-chain verification succeeds, check that the public signals are encoded correctly for the Solidity verifier. Groth16 proofs use elliptic curve points that must be passed as uint256 arrays; a common mistake is swapping the order of coordinates or using decimal strings instead of hex. Also verify that the verifier contract was exported from the same verification key.
- Witness error: constraint not satisfied—check input types and field modulus.
- Proof generation error: missing .zkey or wrong circuit—regenerate keys.
- Verification mismatch: public signals order or encoding—compare with snarkjs output.
- Gas estimation failure: proof format incompatible with verifier—re-export contract.
Limitations and tradeoffs of ZKPs
ZKPs are powerful but not free. Proving time and memory grow with circuit size; complex computations (e.g., large Merkle proofs or zkEVM) can require specialized hardware. Trusted setups introduce a trust assumption unless you use transparent systems. Proof verification is cheap on-chain, but the proof itself must be transmitted and stored, which costs calldata gas.
Zero-knowledge proofs also do not solve data availability: if the witness is not available, the proof may be valid but the underlying data cannot be recovered. For many applications, a simpler commitment scheme (e.g., a hash) is sufficient and far cheaper. Use ZKPs when you need to prove a property of private data without revealing it, or when you need succinct verification of a heavy computation.
Next steps: integrating ZKPs into your stack
Once you have a working proof pipeline, the next step is to integrate it with your application’s backend and smart contracts. Consider where proofs are generated (client-side, server-side, or a proving service) and how you will handle failures. For on-chain verification, you will need a reliable RPC endpoint; you can explore /en/api-service for managed options or /en/dedicated-node for dedicated infrastructure. If you are deploying to a specific network, check /en/networks for supported chains.
For production use, audit your circuit and verifier contract, and consider a formal verification of the constraints. Measure proving time and verification gas on your target hardware and chain; these numbers vary widely. You can use the results-table procedure below to record your own measurements.
- Measure proving time for your circuit on representative hardware.
- Record verification gas cost per proof on your target chain.
- Test with invalid witnesses to confirm soundness.
- Document the trusted setup ceremony and key management.
Reproducible measurement procedure for your ZKP pipeline
To compare proving systems or circuit designs, run a controlled benchmark. Use a fixed circuit and witness, and measure the following on the same machine: witness generation time, proof generation time, proof size, and on-chain verification gas. Run each measurement at least five times and report the median. Do not rely on published benchmarks from other teams; your constraints and hardware will differ.
Create a results table like the one below and fill it with your own measurements. The table is a template; do not invent numbers. For gas measurements, use a local fork or testnet and record the gas used by the verifyProof transaction. For proof size, record the byte length of the proof JSON.