ROOTVIELROOTVIEL
← Academy

Module 5 · Research case studies

Why in-protocol decoys break value safety

The tempting answer to coercion is to hide a decoy inside the withdrawal circuit: a note binds a real and a decoy nullifier, and a branchless selector makes a coerced withdrawal's proof look identical to a genuine one. The paper shows this whole class of design is unsound — not because of the proof system, hash or selector, but because of how the contract accounts for value. A single commitment that admits two independently spendable identifiers, with no shared consumed-state, lets one deposit be withdrawn twice. The paper proves this (Proposition 1), reports reproducing it on the real verifier, and states the corrective as a value-safety invariant (Theorem 1): one note, one spend.

Learning objective

Follow the paper's negative result: why building a decoy spending path into the withdrawal circuit is unsound under per-identifier nullifier accounting, how the paper proves it and what it reports testing, and why the fix is an invariant — one note, one spend — rather than a patch.

Track
Research
Module
5 · Research case studies
Prerequisites
The coercion problem in privacy pools (case study, part 1) · What is a zero-knowledge proof

The tempting in-protocol answer

The natural way to make a coerced withdrawal indistinguishable from a real one is to push deniability into the circuit. A note's commitment binds two nullifiers — C = H(s, N_real, N_decoy) — and a branchless arithmetic selector chooses between them inside the zero-knowledge witness, so the public proof is intended to look identical whether the real or the decoy path is taken. Under duress you reveal the decoy; the adversary sees a valid proof and cannot tell it apart from a genuine spend. The paper notes that an early prototype of theirs took exactly this route.

It looks elegant. The paper's result is that it is unsound, independently of how the selector is implemented.

Proposition 1 — in-protocol decoy unsoundness (PROVEN)

The paper fixes a pool with fixed (or value-equivalent) denominations and three conditions on its accounting:

C1 — monotone spent set
A withdrawal is accepted only if its spend identifier (nullifierHash) is appended to a monotone spent set S, and any identifier already in S is rejected.
C2 — multiple identifiers per note
A single deposited commitment C admits k ≥ 2 distinct identifiers nh_1,…,nh_k, each carrying a valid withdrawal proof that consumes the same economic unit committed by C.
C3 — no shared consumed-state
The contract binds no shared consumed-state across these identifiers: membership in S is the only spend predicate, and C itself is never marked consumed.

Under C1–C3, an adversary holding C can withdraw k denominations against a single deposit. The proof is short and purely about accounting: the only gate is the test nh_i ∉ S. The identifiers are pairwise distinct (C2), so each in turn passes the gate, verifies, and is appended — nothing references C or relates the identifiers to one another (C3). Inductively all k withdrawals are accepted, each consuming the economic unit of C once, so one deposit yields k payouts. With k ≥ 2 this exceeds the deposit and violates value safety.

Note

PROVEN, and note the scope: the argument uses only the spent-set semantics of C1–C3. It is unaffected by the choice of proof system, hash function, or the mechanism that produces the multiple identifiers. The fault is in the contract's value-safety accounting — NOT in Groth16, Poseidon, or the in-circuit selector. An input with no in-circuit binding constrains nothing, so the solvency failure survives any clever selector.

The attack trace (k = 2)

The dual-nullifier commitment is the case k = 2: the same note is withdrawn once with the decoy nullifier and again with the real one, draining an extra denomination. The paper traces it step by step.

attack trace for the dual-nullifier case (k = 2)
# Action Spent set S Outcome 1 deposit commitment C {} one note funded (1 denomination) 2 withdraw, nh_decoy {nh_decoy} nh_decoy not in S -> accepted; payout #1 3 withdraw, nh_real {nh_decoy, nh_real} nh_real not in S -> accepted; payout #2 Result: one deposited note -> two payouts -> value safety violated.

The paper is careful to state (not assume away) why C3 is required. A design COULD bind shared consumed-state — e.g. a per-note counter decremented on each spend — and then admit several identifiers without over-withdrawing. Such a design does not contradict the proposition; it violates C3 and, in doing so, consumes the economic unit of C globally rather than per identifier. The proposition therefore targets the prevailing model for nullifier-based pools: deduplicate per spend identifier with no shared consumed-state. Within that model, multiple independent identifiers per note are unsafe.

What the paper reports testing (TESTED)

The paper does not stop at the proof; it reports reproducing the failure on the real verifier, and distinguishes levels of assurance. Read these as the paper's reported results, not independent benchmarks:

  • ▸Against the dual-nullifier circuit, a note deposited once is withdrawn with the decoy nullifier and then again with the real one, and the paper reports the second withdrawal SUCCEEDS (a named "before-fix" Foundry test) — demonstrating the bug is real on the real on-chain Groth16 verifier, not a mock. The paper labels this a [V] result: run against the real snarkjs-generated verifier under Foundry.
  • ▸Against the corrected single-nullifier circuit, the same note yields one deterministic nullifierHash and the paper reports the second withdrawal REVERTS ("Nullifier used", a named "after-fix" test).
  • ▸The paper separates [V] (real on-chain verifier) and [C] (in-circuit, a violating witness admits no proof) from [M] (mock-verifier plumbing), and explicitly EXCLUDES [M] from all soundness claims — a mock test attests the contract state machine only, never a zero-knowledge property.
Note

TESTED is narrower than PROVEN. A [V] result shows the deployed verifier rejects the stated violating input under the development setup; it does not by itself establish production security (which needs a multi-party trusted-setup ceremony) or any anonymity property. A [C] result shows a constraint is binding in the circuit, not that a contract path exercises it.

The corrective: one note, one spend

Proposition 1 isolates a single cause — more than one spendable identifier per economic note — and so dictates a single cure, stated as an invariant rather than a patch. Principle (one note, one spend): under spend-identifier accounting without shared consumed-state, a privacy pool is value-safe only if every deposited economic note maps to exactly one spendable identifier; equivalently, at most one value-consuming withdrawal is accepted per committed note.

Theorem 1 is the constructive counterpart. Assume (H1) a valid withdrawal proves Merkle membership of the spent commitment under the verified root; (H2) the circuit constrains nullifierHash = H(nullifier, s) so the witness determines a unique public nullifierHash per committed note — no accepted proof for C publishes a second value; (H3) the contract records every accepted nullifierHash and rejects repeats; and (H4) the deployed Groth16 verifier is sound under its trusted setup. Then for every commitment accepted under the verified root, at most one value-consuming withdrawal is accepted. A single deterministic nullifier negates condition C2 of Proposition 1, so the double-withdrawal cannot arise.

Note

LIMITATION: Theorem 1 is conditional on H1–H4. The paper states the result is "exactly as strong as the trusted setup": the deployed verifier uses a development powers-of-tau, so the [V] guarantee is against the real generated verifier under the development setup, not a production security guarantee — which would require a multi-party ceremony with a published transcript.

The invariant also settles WHERE a coercion countermeasure may live. Any mechanism that introduces a second spendable identifier for a note reintroduces the case k ≥ 2; under this accounting, a coercion countermeasure on the spend path is therefore a value-safety hazard. That is what motivates moving deniability off the spend path, to the wallet — the subject of Lesson 3.

Exercise

Note

A colleague proposes making in-protocol decoys safe by replacing the branchless selector with a smarter one that is provably indistinguishable in-circuit. Using Proposition 1, explain why this does not fix the problem, and state the one structural change that does.

Expected direction: Proposition 1 depends only on the spent-set semantics C1–C3, not on the selector: the contract gates on nh_i ∉ S, and if one commitment yields two distinct, independently valid identifiers with no shared consumed-state, both pass the gate and both pay out — regardless of how indistinguishable the in-circuit selection is. An input with no in-circuit value binding constrains nothing. The structural fix is to negate C2: bind a single deterministic nullifier to each commitment so a note maps to exactly one spendable identifier (one note, one spend), which is Theorem 1.

Check your understanding

Where does the dual-nullifier failure actually live — the cryptography or the contract?
The contract's value-safety accounting. Proposition 1 uses only spent-set semantics (C1–C3) and is independent of the proof system, hash, or selector. Groth16 and Poseidon behave correctly.
Why is condition C3 (no shared consumed-state) stated rather than assumed away?
Because a design that binds shared consumed-state (e.g. a per-note counter) could admit several identifiers without over-withdrawing — it violates C3 and consumes the note globally. The proposition targets the prevailing per-identifier model with no shared consumed-state.
What does the paper report for the before-fix vs after-fix tests, and at what assurance level?
Before the fix, the second (dual-nullifier) withdrawal succeeds on the real on-chain Groth16 verifier, confirming the bug ([V]); after the fix, the single-nullifier construction makes it revert with "Nullifier used". [M] mock results are excluded from soundness claims.

References

  1. [1]J. Groth — On the Size of Pairing-Based Non-Interactive Arguments (Groth16), EUROCRYPT 2016
  2. [2]Grassi, Khovratovich, Rechberger, Roy, Schofnegger — Poseidon: A New Hash Function for Zero-Knowledge Proof Systems, USENIX Security 2021
  3. [3]Buterin, Illum, Nadler, Schär, Soleimani — Privacy Pools: Making Blockchain Privacy Compliant

References point to public, well-known technical sources for further study.

Related articles