Nullifiers
Spending a private note has a paradox to solve: the chain must reject double-spends of a note it is not allowed to identify. Nullifiers are the standard resolution, and ZUL uses them in their cleanest form.
Definition
nf = Poseidon(commitment, leaf_index, s)
// s is derived from the owner's secret key —
// only the owner can compute a note's nullifier,
// and nothing about (commitment, leaf_index) can be
// recovered from nf without sEvery spend publishes the nullifiers of its input notes. The node keeps a global nullifier set; a transaction whose nullifier already exists is rejected before execution. That is the entire double-spend story — no amounts, no identities, one set-membership check.
Why this preserves privacy
- Unlinkable — the nullifier is a one-way function of the note. Observers see
nfbut cannot walk it back to a commitment in the tree without the owner's secret. - Deterministic — a given note has exactly one nullifier, so two spends of the same note collide no matter who builds them.
- Proof-bound — the transfer circuitproves the published nullifier was computed correctly from a real note in the tree, so you cannot publish a junk nullifier to "burn" someone else's funds.
What an observer learns
From a transfer, exactly this: some previously-created notes were spent, and some new notes were created. The counts are public (the explorer shows +commitments / −nullifiers per pool transaction); the mapping between them is not — a 2-in/2-out join-split reveals no pairing between inputs and outputs.
The nullifier total on /explorer/shielded is the count of notes ever spent. Subtracting it from total commitments bounds the number of live notes — that bound is public; which notes are live is not.