Production-Style Examples
The three examples in this section are not “run and done.” They put real engineering problems into executable structures. Their common theme: you must handle state reuse, replay attacks, and result persistence. If you have only done minimal examples, this is the step that upgrades you from “demo-grade” to “ship-ready.”
The three examples each solve a real-world problem:
- Membership + nullifier: prevent the same credential from being reused.
- Proof bound to action: prevent a proof from being replayed for a different action.
- Web2 verify-only template: persist verification results in backend storage for permissions and auditing.
To make the structure clear, each example follows “goal → input structure → key logic → verification landing.” You can move these skeletons directly into your project and replace them with your own business fields.
Example 1: Membership + nullifier (prevent reuse)
Engineering goal: the same credential can only be used once. Typical scenarios include airdrop claims, single-use voting, and one-time coupons. You allow users to prove “I am on the list,” but you do not allow repeated submissions with the same identity.
Key idea: the proof shows membership; the nullifier provides “uniqueness.” The nullifier is derived from a private identity + scenario domain + random salt, but it is public. After verification, you record the nullifier; if it appears again, you reject it.
Input structure (illustrative):
publicInputs = { root, nullifier }privateInputs = { leaf, pathElements[], pathIndices[], secret }
Minimal circuit logic (pseudocode):
assert MerklePath(leaf, pathElements, pathIndices) == rootnullifier = Hash(secret, domain, salt)assert nullifier == public_nullifier
Verification landing (Web2 / on-chain):
// server-side pseudo logicif (db.nullifiers.has(nullifier)) {throw new Error("Already used")}db.nullifiers.add(nullifier)
Common pitfall: Treating the nullifier as private input and not writing it to public inputs. The verifier then cannot detect reuse, meaning the system has no replay protection.
💡 Tip: A nullifier is essentially a “public one-time identifier,” and it only works if it is in public inputs.
Example 2: Proof bound to action (anti-replay)
Engineering goal: the same proof cannot be used to trigger a different action. For example, you prove “I can claim A,” but someone else uses the proof to request B.
Key idea: write action metadata into public inputs so the proof is bound to a specific action. The action can be an endpoint, contract method, parameter digest, or even a time window.
Input structure (illustrative):
publicInputs = { root, actionHash }privateInputs = { leaf, pathElements[], pathIndices[], secret }
Action binding logic (illustrative):
const action = {method: "claim",paramsHash: hash(params),expiry: "2026-01-31"}const actionHash = hash(action)// actionHash must match publicInputs
Verification landing:
After verification, you must confirm the current request’s actionHash matches the actionHash in the proof, or reject the action. Otherwise the proof can be replayed in a different context.
Common pitfall: Storing the action only in the application layer and not in the proof. The proof becomes a “generic pass” and loses action binding.
⚠️ Warning: If a proof is not bound to an action, it is a replayable credential that anyone can reuse.
Example 3: Web2 verify-only template (persist results)
Engineering goal: persist verification results to the backend to form stable “verification records.” You do not need on-chain consumption, but you need auditing, retries, or access control.
Key idea: after receiving the ProofVerified event or job-status result, persist the statement and business context. On the next request, check the record first instead of verifying again.
Record structure (illustrative):
type VerificationRecord = {statement: stringuserId: stringaction: stringcreatedAt: stringstatus: "verified" | "failed"}
Minimal handling flow:
if (event.type === "ProofVerified") {await db.verifications.insert({statement: event.statement,userId,action,createdAt: new Date().toISOString(),status: "verified"})}
Common pitfall: Storing only “verified/failed” as a boolean and not the statement. Then you cannot link to a specific proof, and audits become untraceable.
💡 Tip: The statement is the unique fingerprint of a verification result. Use it as the audit index.
Which should you build first?
If your product needs “single-use credentials,” start with nullifier. If you worry about proofs being copied to other actions, start with action binding. If you just want to launch a verification loop quickly, start with verify-only records.
What to carry into your own project
These examples are mainly about three design decisions:
- whether your proof carries uniqueness or action semantics;
- whether the verification result is persisted well enough to audit and reuse;
- whether your consumption logic separates “verification succeeded” from “business action completed.”
The next section provides a single template you can reuse when you turn one of these patterns into a new use case.