protocol

Nullifiers

Double-spend prevention and unlinkability

Overview#

Nullifiers are the mechanism that prevents double-spending in the anonymity pool while maintaining unlinkability. Each deposit note has a unique nullifier that is revealed only when the note is spent. The on-chain program records spent nullifiers by creating PDA accounts.

Nullifier Lifecycle

Spending Key

User's private key

Counter

Per-key deposit counter

Nullifier

Poseidon(key, domain, counter)

Nullifier Hash

Poseidon(nullifier) — public

Leaf

Poseidon(nullifier, secret)

Merkle Proof

Path in tree (20 levels)

On-chain PDA

b'nullifier' + hash, created by unshield

Nullifier Derivation#

nullifier    = Poseidon(spendingKey, DOMAIN_SEPARATOR, counter)
nullifierHash = Poseidon(nullifier)

The nullifier is derived from three values:

| Input | Source | Purpose | |---|---|---| | spendingKey | User's protocol spending key | Binds nullifier to key holder | | DOMAIN_SEPARATOR | Poseidon(["mixer_nullifier"]) | Domain separation from other protocols | | counter | Per-key monotonic counter | Ensures uniqueness across notes |

The counter is incremented per (spending key, denomination) pair, ensuring that no two notes from the same user produce the same nullifier.

Never Reuse Nullifiers

If the same nullifier is revealed for two different withdrawals, an observer can definitively link both withdrawals to the same deposit. This breaks unlinkability permanently for the affected note. The circuit and on-chain program enforce uniqueness, but improper key derivation or counter management could lead to collisions.

On-chain Recording#

When unshield processes a withdrawal:

1. Extract nullifier_hash from public_inputs[1]
2. Derive nullifier PDA: ["nullifier", nullifier_hash]
3. Verify the PDA account does not exist (empty)
4. Create account via system_instruction::create_account
5. Serialize NullifierAccount { hash, bump }

The nullifier PDA is 41 bytes:

  • 8 bytes: Anchor account discriminator
  • 32 bytes: nullifier hash
  • 1 byte: PDA bump seed

Nullifier PDA Rent#

| Variant | Seed | Space | Rent | |---|---|---|---| | Regular | "nullifier" | 41 bytes | ~0.00089 SOL | | Compressed | "nullifier_c" | 41 bytes | ~0.000015 SOL |

The compressed variant uses unshield_compressed and saves approximately 0.000875 SOL per withdrawal by using a smaller account allocation path.

Security Properties#

  • Binding. The nullifier is cryptographically bound to the spending key. Only the key holder can derive the correct nullifier for a given note.
  • Uniqueness. The counter ensures no two notes from the same key have the same nullifier. The 254-bit random secret ensures cross-key collisions are astronomically unlikely.
  • Early rejection. The on-chain nullifier check happens before the vault transfer, preventing wasted compute on invalid withdrawals.
  • Irreversibility. Once a nullifier PDA is created, the note is permanently spent. There is no "un-spend" mechanism.

Relation to Merkle Tree#

The nullifier and the leaf commitment are related but separate:

| Value | Role | Where Revealed | |---|---|---| | commitment = Poseidon(nullifier, secret) | Merkle tree leaf | Shield event | | nullifierHash = Poseidon(nullifier) | Spend marker | Unshield public input | | nullifier = Poseidon(key, domain, counter) | Private witness | Never |

An observer sees the commitment at shield time and the nullifier hash at unshield time. They cannot link the two without knowing the secret and spending key. The ZK circuit proves that the commitment is in the tree and that the nullifier hash corresponds to the same nullifier — without revealing either the nullifier or the secret.