Skip to main content

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:

InputSourcePurpose
nullifierSecretPart of the phantom key (kept private)Provides secrecy — only the key holder can compute the nullifier
commitmentThe commitment hash stored in the Merkle treeBinds the nullifier to a specific commitment
leafIndexThe position of the commitment in the Merkle treeMakes 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:

  1. The vault contract calls checkAndMarkNullifier(nullifier)
  2. The registry checks: nullifiers[nullifier] == false (not previously spent)
  3. If not spent: sets nullifiers[nullifier] = true and returns success
  4. 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:

RegistryUsed ByPurpose
NullifierRegistryCommitRevealVault / BatchCommitRevealVaultToken reveal nullifiers
OpenNullifierRegistryOpenGhostVault, PersistentKeyVault (revocation only)Data reveal and key revocation nullifiers
ForkingNullifierRegistryForkingVaultForking 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:

  1. The nullifierSecret is never revealed — it is a private input to the ZK proof
  2. The ZK proof proves the nullifier was correctly derived without revealing any of the three inputs
  3. 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 treeWhich commitment was revealed
Nullifier values when spentWhich commitment a nullifier corresponds to
Reveal transaction (recipient, amount for tokens)The sender or source commitment
Timing of commits and revealsConnection 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):

  1. The prover provides nullifierSecret, commitment, and leafIndex as private inputs
  2. The circuit computes innerHash = Poseidon2(nullifierSecret, commitment)
  3. The circuit computes nullifier = Poseidon2(innerHash, leafIndex)
  4. The circuit constrains the computed nullifier to equal the public input nullifier
  5. 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.

PropertyNullifierAccess Tag
DerivationPoseidon2(Poseidon2(nullifierSecret, commitment), leafIndex)Poseidon2(nullifierSecret, sessionNonce)
Effect on commitmentPermanently spent (commitment is consumed)Not spent (commitment remains active)
ReusabilityOne-time onlyNew tag per session (different sessionNonce)
Anti-replay scopePermanent (nullifier can never be reused)Per-session (each accessTag is unique but commitment persists)
Used byCommitRevealVault, OpenGhostVaultPersistentKeyVault

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.