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.