Nullifier System
The nullifier system is the mechanism that prevents double-reveal (double-spending) in the Specter data protocol. Every commitment has exactly one valid nullifier. When a commitment is revealed, its nullifier is published on-chain and marked as spent. Any subsequent attempt to reveal the same commitment will be rejected because the nullifier has already been used.
The critical privacy property of nullifiers is that an observer cannot link a nullifier to the commitment it corresponds to. The nullifier is derived from the commitment using private inputs known only to the commitment holder. To everyone else, the nullifier appears as an opaque 254-bit value with no discernible connection to any on-chain commitment.
Nullifier Derivation
The nullifier is computed using two nested PoseidonT3 (2-input Poseidon) hashes:
innerHash = Poseidon2(nullifierSecret, commitment)
nullifier = Poseidon2(innerHash, leafIndex)
This two-step derivation uses three inputs total:
| Input | Source | Purpose |
|---|---|---|
nullifierSecret | Part of the phantom key (kept private) | Provides secrecy — only the key holder can compute the nullifier |
commitment | The commitment hash stored in the Merkle tree | Binds the nullifier to a specific commitment |
leafIndex | The position of the commitment in the Merkle tree | Makes the nullifier position-dependent |
Why Three Inputs?
Each input to the nullifier derivation serves a distinct security purpose. Removing any one of them would create a vulnerability:
nullifierSecret Provides Secrecy
Without nullifierSecret, anyone who can see the commitment hash and knows the leaf index (both are public on-chain data) could compute the nullifier. This would allow an observer to precompute all possible nullifiers for all on-chain commitments and immediately link any published nullifier to its source commitment — completely destroying privacy.
The nullifierSecret is a random 254-bit value known only to the phantom key holder. Even though the commitment and leaf index are public, the nullifier is unpredictable without this secret.
commitment Binds to a Specific Deposit
Without binding to the commitment, a single nullifierSecret could be used to construct a nullifier for a different commitment. By including the commitment hash as an input, the nullifier is cryptographically tied to exactly one committed data entry or token deposit.
leafIndex Makes It Position-Dependent
Without the leafIndex, two commitments with identical content (same secret, same data or amount) would produce identical nullifiers. This would mean:
- The second commitment could never be revealed (its nullifier would already be marked as spent)
- An observer could detect that two commitments share the same underlying data
By including the leaf index, even identical commitments at different tree positions produce different nullifiers. This is especially important for change commitments in partial withdrawals — when a user partially reveals a token commitment, the remaining balance is re-committed to the tree. The change commitment inherits the same secret and nullifierSecret but gets a new leafIndex, so it gets a fresh nullifier.
On-Chain Registry
The on-chain component of the nullifier system is deliberately simple. The NullifierRegistry contract maintains a single mapping:
mapping(bytes32 => bool) public nullifiers;
When a nullifier is checked and marked as spent:
- The vault contract calls
checkAndMarkNullifier(nullifier) - The registry checks:
nullifiers[nullifier] == false(not previously spent) - If not spent: sets
nullifiers[nullifier] = trueand returns success - If already spent: reverts the transaction
Once set to true, a nullifier can never be unset. There is no admin function, no governance override, and no expiration. This is by design — the irreversibility of nullifier spending is what makes double-reveal impossible.
Only the vault contract (set at deployment) can write to the registry. External callers can read nullifier status but cannot mark them as spent.
Separate Registries
Different vaults use different nullifier registries to maintain clean separation:
| Registry | Used By | Purpose |
|---|---|---|
| NullifierRegistry | CommitRevealVault / BatchCommitRevealVault | Token reveal nullifiers |
| OpenNullifierRegistry | OpenGhostVault, PersistentKeyVault (revocation only) | Data reveal and key revocation nullifiers |
| ForkingNullifierRegistry | ForkingVault | Forking commitment nullifiers |
This separation means a nullifier spent in the token privacy system cannot interfere with a nullifier in the data privacy system, even if they happen to have the same value (which is astronomically unlikely but theoretically possible).
Privacy Property
The fundamental privacy property of nullifiers is unlinkability: an observer who sees a nullifier published on-chain cannot determine which commitment it corresponds to.
An observer sees:
- A set of commitments added to the tree over time
- A set of nullifiers published during reveals
But they cannot determine which nullifier corresponds to which commitment. Every possible mapping is equally likely from the observer's perspective. This is because:
- The
nullifierSecretis never revealed — it is a private input to the ZK proof - The ZK proof proves the nullifier was correctly derived without revealing any of the three inputs
- Poseidon's preimage resistance ensures the inputs cannot be recovered from the nullifier value
What an Observer Sees vs. What They Cannot Determine
| Observable (Public) | Hidden (Private) |
|---|---|
| Commitment hashes in the tree | Which commitment was revealed |
| Nullifier values when spent | Which commitment a nullifier corresponds to |
| Reveal transaction (recipient, amount for tokens) | The sender or source commitment |
| Timing of commits and reveals | Connection between any specific commit and reveal |
Nullifiers in the ZK Circuit
The nullifier is not simply published and trusted — the ZK proof cryptographically guarantees that the nullifier was correctly derived from the commitment being revealed.
Inside the redemption.circom circuit (for token reveals):
- The prover provides
nullifierSecret,commitment, andleafIndexas private inputs - The circuit computes
innerHash = Poseidon2(nullifierSecret, commitment) - The circuit computes
nullifier = Poseidon2(innerHash, leafIndex) - The circuit constrains the computed
nullifierto equal the public inputnullifier - The leaf index is not provided directly — it is reconstructed from the Merkle path indices (
pathIndices[0..19]interpreted as a binary number), ensuring consistency with the Merkle proof
This means:
- The prover cannot publish a fake nullifier (the circuit would reject the proof)
- The prover cannot reuse a nullifier from a different commitment (the circuit binds it to the specific commitment being proven)
- The prover cannot reveal the same commitment twice (the same inputs always produce the same nullifier, which will be rejected by the on-chain registry)
Nullifiers vs. Access Tags
For persistent phantom keys (PersistentKeyVault), the protocol uses a different anti-replay mechanism called access tags instead of nullifiers. This is because persistent keys are designed to be accessed multiple times — spending a nullifier would destroy the key after one use.
| Property | Nullifier | Access Tag |
|---|---|---|
| Derivation | Poseidon2(Poseidon2(nullifierSecret, commitment), leafIndex) | Poseidon2(nullifierSecret, sessionNonce) |
| Effect on commitment | Permanently spent (commitment is consumed) | Not spent (commitment remains active) |
| Reusability | One-time only | New tag per session (different sessionNonce) |
| Anti-replay scope | Permanent (nullifier can never be reused) | Per-session (each accessTag is unique but commitment persists) |
| Used by | CommitRevealVault, OpenGhostVault | PersistentKeyVault |
Access tags replace the commitment and leafIndex binding with a sessionNonce — a fresh random value per access session. This means the same phantom key produces a different access tag every time, preventing session replay without consuming the underlying commitment.
When a persistent key needs to be permanently destroyed, the PersistentKeyVault falls back to the standard nullifier mechanism: a reveal proof is generated (spending the nullifier via OpenNullifierRegistry), which permanently prevents any further access.
Security Considerations
Nullifier entropy. Nullifiers are 254-bit Poseidon hashes with three random-or-derived inputs. The probability of a nullifier collision (two different commitments producing the same nullifier) is negligible — approximately 2^(-127), well below any practical attack threshold.
Determinism. For any given commitment, there is exactly one valid nullifier. This is enforced by the ZK circuit — the prover cannot choose or manipulate the nullifier value. This determinism is what makes the simple boolean mapping sufficient for anti-replay protection.
Registry immutability. The nullifier registry has no admin functions, no upgrade mechanism for the mapping itself, and no way to reset a spent nullifier. This is a deliberate security property — the simplicity of the contract minimizes the attack surface. The contract's only write function is checkAndMarkNullifier, callable only by the associated vault.