Common ZK App Patterns
Patterns that appear everywhere
Every ZK application you encounter in production is built from a small set of recurring patterns. The circuits differ, the proof systems differ, the applications differ — but the underlying structural patterns repeat.
This lesson catalogues the six most important ones. Recognize them and you can reason about almost any ZK application you encounter, and design new ones faster.
Pattern 1: Commitment and Reveal
The most fundamental pattern. A prover commits to a value by publishing its hash. Later they reveal information about that value without revealing the value itself.
Structure:
``rust fn main(secret: Field, commitment: pub Field) { let computed = std::hash::poseidon2::Poseidon2::hash([secret], 1); assert(computed == commitment); } ``
The commitment is public. The secret is private. The proof says: I know the preimage of this commitment.
Where it appears:
Sealed bid auctions — bidders commit to bids before the reveal phase. Nobody can change their bid after seeing others. Nobody can see bids before the reveal.
Private voting — voters commit to votes before tallying. The circuit proves the vote is valid without revealing it.
The commitment pattern is stateless. It does not require a Merkle tree or on-chain state beyond the commitment value itself.
Pattern 2: Nullifiers
The nullifier pattern extends commitment-and-reveal to prevent double use of the same secret. It is the mechanism that makes anonymous transactions and private credentials resistant to replay attacks.
Structure:
```rust fn main( secret: Field, nullifier: pub Field, commitment: pub Field, ) { // prove commitment is correctly formed let computed_commitment = std::hash::poseidon2::Poseidon2::hash([secret], 1); assert(computed_commitment == commitment);
// prove nullifier is deterministically derived from secret let computed_nullifier = std::hash::poseidon2::Poseidon2::hash([secret, 1], 2); assert(computed_nullifier == nullifier); } ```
The nullifier is a deterministic function of the secret. It is public and stored on-chain after use. The secret remains private. If the same secret is used again, it produces the same nullifier, and the contract rejects it as already spent.
Where it appears:
ZK mixers — the nullifier prevents double-withdrawal from the same deposit without linking the withdrawal to the deposit.
Anonymous credentials — a credential can be used once to claim a benefit. The nullifier prevents the same credential from being used twice without revealing which credential it is.
Private voting — each voter's nullifier is derived from their identity secret. One identity, one vote.
The nullifier pattern requires on-chain state — a set of spent nullifiers that the contract checks before accepting any proof. The circuit proves the nullifier was derived correctly. The contract ensures it has not been seen before.
Pattern 3: Merkle Membership
A prover demonstrates membership in a set without revealing which member they are. The set is represented as a Merkle tree whose root is public. The prover's position and path in the tree are private.
Structure:
```rust use dep::std::merkle::compute_merkle_root;
fn main( leaf: Field, // private — which member index: Field, // private — position in tree hash_path: [Field; 20], // private — sibling hashes root: pub Field, // public — the set root ) { let computed_root = compute_merkle_root( leaf, index, hash_path ); assert(computed_root == root); } ```
The root is published on-chain. Membership is updated by adding leaves and updating the root. Any member can prove they are in the set without revealing their leaf value or position.
Where it appears:
Allowlists — prove you are on a list without revealing your position on it.
Token ownership — prove you own a note in a shielded pool without revealing which note.
Group membership — prove you are a registered voter, a verified human, or a licensed entity without revealing your identity.
Semaphore, Tornado Cash, Worldcoin's World ID, and most anonymous credential systems are built on this pattern.
The combination: Merkle membership and nullifiers are almost always used together. The circuit proves: I am a member of this set (Merkle path), and I am using the set membership for the first time (nullifier). This combination is the foundation of every private transaction system.
```rust fn main( secret: Field, nullifier: pub Field, root: pub Field, index: Field, hash_path: [Field; 20], ) { // derive the leaf from the secret let leaf = std::hash::poseidon2::Poseidon2::hash([secret], 1);
// prove membership let computed_root = std::merkle::compute_merkle_root( leaf, index, hash_path ); assert(computed_root == root);
// derive and verify nullifier let computed_nullifier = std::hash::poseidon2::Poseidon2::hash([secret, 1], 2); assert(computed_nullifier == nullifier); } ```
Pattern 4: Range Proofs
A prover demonstrates that a private value falls within a range without revealing the value.
Structure:
``rust fn main( value: u64, min: pub u64, max: pub u64, ) { assert(value >= min); assert(value <= max); } ``
Using typed integers like u64 gives you range constraints for free at the type level. The compiler generates bit decomposition constraints that enforce the type bound. The comparison assertions add the additional bounds check.
Where it appears:
Age verification — prove you are over 18 without revealing your exact age.
Credit scoring — prove your score is above a threshold without revealing the score.
Balance proofs — prove you have enough funds to cover a transaction without revealing your balance.
Compliance attestation — prove a transaction value is within regulatory limits without revealing the amount.
Range proofs are often combined with commitment schemes. The prover commits to the value, then proves range properties about the committed value.
Pattern 5: Signature Verification
A prover demonstrates that a message was signed by a known key without revealing the signature or the private key. This enables private authentication and access control.
Structure in Noir using Schnorr:
```rust use dep::std::schnorr;
fn main( message: [u8; 10], pub_key_x: pub Field, pub_key_y: pub Field, signature: [u8; 64], ) { let valid = schnorr::verify_signature( pub_key_x, pub_key_y, signature, message ); assert(valid); } ```
The message and public key are public. The signature is private. The circuit proves a valid signature exists for this message from this key, without the verifier seeing the signature bytes.
Where it appears:
Private login — prove you hold a key that corresponds to a registered public key without exposing the key material.
Delegated credentials — prove a credential was issued by an authorized signer without revealing the credential contents.
Anonymous action — prove an authorized entity took an action without revealing which authorized entity it was.
ECDSA variant: Noir's standard library includes ecdsa_secp256k1::verify_signature for verifying Ethereum-compatible ECDSA signatures inside a ZK circuit. This enables proving that a specific Ethereum account signed a message without revealing the signature itself.
Pattern 6: Recursive Aggregation
A single proof that verifies many other proofs. One on-chain verification covers arbitrarily many underlying computations.
This pattern does not have a simple circuit example because it operates at the proof system level rather than the circuit level. But the concept is important enough to understand precisely.
Structure:
`` Proof₁ + Proof₂ + Proof₃ + ... + ProofN ↓ Aggregation circuit ↓ One aggregated proof ↓ One on-chain verification ``
The aggregation circuit takes N proofs as private inputs and outputs one proof that all N were valid. The verifier checks one proof regardless of N.
Implementations:
zkVerify does this at the infrastructure level. Your proof is aggregated with others into a Merkle root. One transaction on the destination chain covers all of them.
Recursive PLONK and Halo2 do this at the circuit level. A Halo2 circuit can verify another Halo2 proof natively. Polygon's proof system uses this to aggregate transaction proofs before posting to Ethereum.
Folding schemes like Nova achieve a similar result without generating an intermediate proof at each step, making the per-step cost much lower.
Where it appears:
ZK rollups — thousands of transaction proofs aggregated into one Ethereum verification.
Proof markets — provers aggregate many client proofs into one settlement proof.
Batch credential issuance — prove many credentials are valid with one on-chain call.
Combining patterns
Production ZK applications combine these patterns. A private voting system uses:
Commitment — voters commit to their votes
Merkle membership — prove the voter is on the registered voter list
Nullifiers — prevent double voting
Range proofs — prove the vote value is valid (e.g., 0 or 1 for a binary vote)
A private DeFi protocol adds:
Signature verification — prove the transaction was authorized by the account owner
Recursive aggregation — batch many transactions into one proof
Understanding these patterns as composable building blocks rather than monolithic application designs is what separates developers who can design ZK systems from those who can only implement them from existing templates.
Choosing patterns for your application
The selection is driven by what your application needs to prevent and what it needs to hide:
| Need to prevent | Pattern | |---|---| | Using a secret twice | Nullifiers | | Unauthorized access | Signature verification | | Invalid values | Range proofs | | Impersonation | Merkle membership | | High on-chain cost | Recursive aggregation | | Pre-commitment fraud | Commitment and reveal |
Start with the simplest combination that satisfies your security requirements. Each additional pattern adds circuit complexity and proving time. A system that requires membership, nullifiers, and range proofs simultaneously will have a significantly larger circuit than one that requires only one. Design for what you need, not for completeness.
Answer the quiz correctly to continue →
Which of the following is a common ZK app pattern?