Development of Zero-Knowledge Proof Applications
Most projects requesting ZKP support face one of two problems: either they need to prove a fact without revealing data (age, balance, set membership), or they need to move heavy computations off-chain with on-chain verification. These are different tasks with different tooling stacks, and confusing them is the first and most costly mistake at the start.
Proof-system selection: this is not an academic question
The choice between Groth16, PLONK, STARK, Halo2 and FRI determines everything: proof size, generation time, trusted setup presence, on-chain verification cost.
Comparison of major systems
| System | Trusted setup | Proof size | Verification cost (EVM) | Prover time | Recursion |
|---|---|---|---|---|---|
| Groth16 | Yes (per-circuit) | ~200 bytes | ~270k gas | Fast | Complex |
| PLONK (KZG) | Yes (universal) | ~800 bytes | ~400k gas | Medium | Simpler |
| PLONK (IPA) | No | ~1.5KB | Expensive | Slow | Good |
| STARK | No | 40–200KB | Very expensive in EVM | Slow | Excellent |
| Halo2 | No | ~1–5KB | Non-native | Medium | Built-in |
Groth16 — the choice for production systems with fixed schemes and minimal gas requirements. Used by: Tornado Cash (former), Zcash Sapling, most zkSNARK bridges. Downside: every circuit change requires a new ceremony.
PLONK with KZG — de facto standard for zkRollup-like systems. Gnosis, zkSync Lite, Polygon Hermez use PLONK variants. Universal trusted setup (Powers of Tau) is reused — no ceremony needed for each scheme.
STARKs — the choice for tasks without trusted setup and requiring recursion: StarkNet, Cairo VM. Enormous proof size — EVM verification is natively impractical, requiring a separate verifier contract or L3 approach.
Halo2 — used by Zcash Orchard, Scroll. No trusted setup required, built-in recursion. Tooling is less mature, smaller ecosystem, but actively developing.
For most practical tasks (private voting, proof of membership, zkKYC, age verification) — Groth16 via circom/snarkjs or PLONK via gnark/noir — is the correct starting point.
Circuit development: where real bugs hide
Circom and its pitfalls
Circom is a DSL for writing arithmetic circuits. A circuit compiles to R1CS (Rank-1 Constraint System), then through snarkjs or rapidsnark a proof is generated.
Basic scheme for proving knowledge of a preimage hash:
pragma circom 2.1.4;
include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/comparators.circom";
template ProveBalance() {
signal input balance; // private
signal input salt; // private
signal input commitment; // public (stored on-chain)
signal input threshold; // public
// Prove: hash(balance, salt) == commitment
component hasher = Poseidon(2);
hasher.inputs[0] <== balance;
hasher.inputs[1] <== salt;
hasher.out === commitment;
// Prove: balance >= threshold (without revealing balance)
component gte = GreaterEqThan(64);
gte.in[0] <== balance;
gte.in[1] <== threshold;
gte.out === 1;
}
component main {public [commitment, threshold]} = ProveBalance();
Critical vulnerability: insufficiently constrained signals. This is the most common class of bugs in ZK circuits. If a signal is used in computation but lacks sufficient constraints — the prover can pass an arbitrary value and the verifier will accept the proof.
Example of vulnerable code:
// VULNERABLE: no constraint that out is a bit
template IsZero() {
signal input in;
signal output out;
signal inv;
inv <-- in != 0 ? 1/in : 0;
out <-- in == 0 ? 1 : 0;
// FORGOT: in * out === 0 and (in * inv - 1 + out) === 0
}
The verifier accepts any out because there are no constraints linking out to in.
Overflow in field arithmetic. Circom operates in a prime field p = 21888242871839275222246405745257275088548364400416034343698204186575808495617. Every operation is modulo p. If input data is from the real world (age, timestamp), the range is safe. But when multiplying large numbers, explicit range checking is needed via Num2Bits:
component rangeCheck = Num2Bits(64);
rangeCheck.in <== balance;
// Now balance is guaranteed < 2^64
Gnark (Go) for more complex schemes
When the scheme is too complex for circom (recursive proofs, BLS signature verification, zkEVM-like components) — use gnark:
type Circuit struct {
PreImage frontend.Variable `gnark:",secret"`
Hash frontend.Variable `gnark:",public"`
}
func (c *Circuit) Define(api frontend.API) error {
mimc, err := mimc.NewMiMC(api)
if err != nil {
return err
}
mimc.Write(c.PreImage)
result := mimc.Sum()
api.AssertIsEqual(result, c.Hash)
return nil
}
gnark is 10–30x faster than snarkjs in prover time for equivalent schemes. For production with real users this matters: proof generation in browser via WASM takes 3–15 seconds on a moderate Groth16 circuit, in a Go server — 0.1–1 second.
On-chain verification
The Solidity verifier is generated automatically — snarkjs does this via snarkjs zkey export solidityverifier. But the production contract needs adaptation:
contract BalanceProofVerifier {
IGroth16Verifier public immutable verifier;
// Nullifier storage for replay protection
mapping(bytes32 => bool) public usedNullifiers;
function verifyAndExecute(
uint[2] calldata a,
uint[2][2] calldata b,
uint[2] calldata c,
uint[2] calldata publicInputs // [commitment, threshold]
) external {
bytes32 nullifier = keccak256(abi.encodePacked(a, b, c));
require(!usedNullifiers[nullifier], "Proof already used");
require(verifier.verifyProof(a, b, c, publicInputs), "Invalid proof");
usedNullifiers[nullifier] = true;
// ... main logic
}
}
Nullifier pattern — mandatory for proof of membership and any systems where a single proof should not be used twice. Nullifier = deterministic hash of a secret input that cannot be linked to identity but can be checked for uniqueness.
Gas cost for Groth16 verification — about 270k gas. On Ethereum mainnet at 20 gwei this is ~$2–5 per verification. For high-frequency systems — deploy to L2 (Arbitrum, Base) reduces cost 10–50x.
Infrastructure for proof generation
Client-side generation (browser)
Suitable for: wallet-level operations, single proofs. Uses WebAssembly snarkjs build:
import { groth16 } from "snarkjs";
const { proof, publicSignals } = await groth16.fullProve(
{ balance: "5000", salt: randomSalt, commitment: onChainCommitment, threshold: "1000" },
"/circuits/balance_proof.wasm",
"/circuits/balance_proof_final.zkey"
);
.zkey files for complex schemes weigh 10–500MB — this is a problem for the browser. Solution: split into chunks (chunked zkey) or use streaming download.
Server-side generation (proving service)
For schemes with a large number of constraints (>1M) — client won't cope. Architecture:
Client → API (task creation) → Queue (Bull/RabbitMQ) → Prover Worker → S3 (proof) → Webhook
Prover worker — Go service with gnark or Rust with bellman/arkworks. Horizontal scaling: each worker is independent, tasks are idempotent.
For zkEVM-level schemes (billions of constraints) — GPU proving via CUDA. Acceleration 100–1000x compared to CPU. Providers: Ingonyama, Ulvetanna.
Trusted Setup Ceremony
For Groth16 — mandatory. Process:
- Universal Powers of Tau (take ready-made from Hermez/EthSnarks — these are publicly verified parameters up to a certain size)
- Phase 2 ceremony specific to your scheme: each participant adds their randomness
- Final beacon — public random source (Bitcoin block hash)
If at least one participant is honest — the parameters are secure. For production projects: minimum 10–20 participants, public transcript verification.
Timelines and scope
| Phase | Content | Duration |
|---|---|---|
| Scheme specification | Formalize task, choose proof-system, design public/private inputs | 1 week |
| Circuit development | Write circom/gnark/noir, unit test constraints | 2–4 weeks |
| Circuit audit | Find under-constrained signals, check soundness | 1–2 weeks |
| Verifier contract | Solidity verifier + nullifier logic + protocol integration | 1–2 weeks |
| Prover infrastructure | WASM build or server-side prover, API | 1–2 weeks |
| Trusted setup | Organize ceremony (if Groth16/PLONK-KZG) | 1 week |
Total for typical ZKP application (proof of membership, zkKYC, private transactions): 6–12 weeks from specification to mainnet. Complex zkRollup-like systems — 6–18 months for a team.







