Skip to content
Launch App >

Nullifiers are the key mechanism that prevents double-spending without revealing which deposit was spent.

In a private system, we can’t simply mark a deposit as “spent” - that would reveal which deposit corresponds to which withdrawal, breaking privacy.

When you withdraw, you reveal the hash of your nullifier (not the nullifier itself):

nullifierHash = poseidon(nullifier)

This hash is stored on-chain. If someone tries to withdraw the same deposit again, the same nullifierHash would be submitted, and the transaction would fail.

  1. Different nullifiers → Different hashes: Each deposit has a unique nullifier, so each has a unique nullifierHash
  2. Can’t reverse the hash: Seeing nullifierHash doesn’t reveal the nullifier or which commitment it corresponds to
  3. Proven in ZK: The withdrawal proof proves you know a valid nullifier without revealing it

When withdrawing, your proof proves:

  1. “I know a nullifier and secret such that commitment = poseidon(nullifier, secret)
  2. “This commitment exists in the Merkle tree with root R
  3. “The nullifierHash I’m revealing equals poseidon(nullifier)

The verifier learns only:

  • A valid withdrawal is happening
  • The nullifierHash (to prevent reuse)
  • The Merkle root being used

The verifier does NOT learn:

  • Which commitment is being spent
  • The nullifier or secret values
  • Any link to the original depositor

The zklsol program stores nullifier hashes in a NullifierHash PDA:

seeds = ["NullifierHash", mint, depth, nullifier_hash_bytes]

Before executing a withdrawal, the program checks if this PDA exists:

  • If it exists → Reject (already spent)
  • If it doesn’t exist → Create it and proceed
RiskMitigation
Nullifier leakKeep your nullifier secret. If leaked, someone could front-run your withdrawal.
Weak randomnessUse cryptographically secure random number generation for nullifiers.
Nullifier reuseNever reuse a nullifier across deposits - each deposit needs fresh randomness.
// Generating nullifier and computing its hash
import { poseidon } from '[PLACEHOLDER: POSEIDON_NPM_PACKAGE]';
import { randomBytes } from 'crypto';
// Generate random nullifier (32 bytes)
const nullifier = randomBytes(32);
// Compute nullifier hash for withdrawal
const nullifierHash = poseidon([nullifier]);
// This nullifierHash is what gets submitted with your withdrawal proof
  • Learn about Relayers and why they’re essential for privacy
  • See the Withdraw Guide for implementation details