A zero-knowledge nullifier circuit for Noir that proves a note can be spent exactly once without revealing which note is being spent.
The circuit proves the following in zero knowledge:
- Note existence — The prover knows a note commitment that exists in the global commitment tree (Sparse Merkle Tree membership proof)
- Nullifier correctness — The revealed nullifier is correctly derived from the prover's secret key and note randomness
- Double-spend prevention — The nullifier has NOT been revealed before (Sparse Merkle Tree non-membership proof against the nullifier set)
| Input | Description |
|---|---|
nullifier |
The nullifier being revealed (marks the note as spent) |
commitment_root |
Root of the note commitment tree |
nullifier_root |
Root of the nullifier set (already-spent nullifiers) |
| Input | Description |
|---|---|
sk |
Secret key (owner's private spending key) |
rho |
Randomness / nonce tied to the note at creation |
r |
Note blinding factor |
v |
Note value (amount) |
token |
Token identifier |
chain_id |
Chain identifier |
commitment_path |
Merkle path proving note exists in commitment tree |
commitment_index |
Index/key of the note in the commitment tree |
nullifier_path |
Merkle path proving nullifier is NOT in the nullifier set |
1. note_commitment = hash_note_commitment(sk, r, v, token, chain_id)
2. verify_membership(commitment_root, commitment_index, note_commitment, commitment_path)
3. computed_nullifier = hash_nullifier(sk, rho)
4. assert(computed_nullifier == nullifier) // public input match
5. verify_non_membership(nullifier_root, computed_nullifier, nullifier_path)
⊥ — The nullifier makes the statement "this note is still alive" false, permanently.
- poseidon2-interop-kit — Domain-separated Poseidon2 hashing (nullifier domain tag = 3, note commitment domain tag = 2)
- noir-sparse-merkle — Sparse Merkle Tree membership and non-membership proofs
[dependencies]
noir_nullifier = { git = "https://github.com/Mimir-Collective/noir-nullifier" }use noir_nullifier::spend;
// In your circuit:
spend(
nullifier, // pub
commitment_root, // pub
nullifier_root, // pub
sk, // private
rho, // private
r, // private
v, // private
token, // private
chain_id, // private
commitment_path, // private
commitment_index, // private
nullifier_path, // private
);┌─────────────────────────────────────────────────────┐
│ PRIVATE WITNESS │
│ │
│ sk, r, v, token, chain_id │
│ │ │
│ ▼ │
│ note_commitment = Poseidon2(2, sk, r, v, token, │
│ chain_id) │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ SMT Membership Proof │◄── commitment_path │
│ │ (note exists) │ │
│ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ commitment_root ══════════════════► PUBLIC │
│ │
│ sk, rho │
│ │ │
│ ▼ │
│ nullifier = Poseidon2(3, sk, rho) │
│ │ │
│ ├──────────────────────────────► PUBLIC │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ SMT Non-Membership Proof │◄── nullifier_path │
│ │ (not yet spent) │ │
│ └──────────┬────────────────┘ │
│ │ │
│ ▼ │
│ nullifier_root ═══════════════════► PUBLIC │
│ │
└─────────────────────────────────────────────────────┘
- Privacy: No observer can link a nullifier to the original note commitment
- Soundness: A valid proof guarantees the note exists and hasn't been spent
- Binding: The nullifier is deterministically derived from
skandrho, so the same note always produces the same nullifier (preventing double-spend)
- Architecture design
- Noir circuit implementation
- Rust test fixture generation
- Integration tests with noir-sparse-merkle
- Gas benchmarks
- Security review
MIT