Notes and Nullifiers
1. Why this chapter exists
The Sapling pool of shielded UTXOs is, in implementation terms, a set of two
append-only structures: the Merkle tree of note commitments (chapter 6) and a
flat set of nullifiers maintained by each full node. A spend reveals a
nullifier; nodes reject any block that contains a nullifier that already
appeared in the chain. The nullifier formula has a subtle property called
faerie-gold defence that prevents an attacker who shares a cmu with
another note from being able to spend it twice.
This chapter spells out the formula, the position-in-tree parameter, and the
path from Note::from_parts to Nullifier.
2. Definitions
Definition 8.1 (nullifier). Given a note with , its nullifier at tree position under the nullifier-deriving key is
where and
is the BLAKE2s instance personalised with
b"Zcash_nf", keyed by serialising followed by the perturbed
commitment. Code:
Nullifier::derive,
spec::mixing_pedersen_hash,
spec::prf_nf.
Lemma 8.2 (faerie-gold defence). Two notes whose commitments coincide
() at distinct tree positions () have distinct
nullifiers under any fixed . Proof sketch: the perturbed inputs to
the PRF are and .
These are equal iff , which in
requires . Since
and , distinct
positions never collide. The PRF then injects this distinction into the 32-byte
output. Without the position mixing, an attacker could find two distinct notes
with the same commitment (e.g. by guessing rseed) and spend one twice.
Invariant 8.3 (nullifier uniqueness is a global property). No single full
node enforces nullifier uniqueness by examining a block in isolation: a block
must be checked against the entire history of nullifiers seen so far. This crate
does not maintain that set; it only computes nullifiers and exposes them.
Maintenance is the caller's job (typically zebrad or zcashd).
Definition 8.4 (Rseed and rcm). A note's seed randomness is either
pre-ZIP-212 (a scalar used as rcm
directly) or post-ZIP-212 (a 32-byte buffer from which both rcm and the
ephemeral encryption secret esk are derived via PrfExpand::SAPLING_RCM /
SAPLING_ESK). Code:
Rseed,
Note::derive_esk.
3. The code
3.1 The nullifier
Nullifier is a 32-byte newtype. The derivation is two lines:
loading...
Note that Nullifier::derive is pub(super), so callers cannot construct one
without going through Note::nf. The only public entry point is the
byte-level
Nullifier::from_slice,
which is for parsing wire data, not for deriving.
3.2 Position mixing
mixing_pedersen_hash is the function that adds the
position to the commitment. Despite its name, it is a single scalar
multiplication plus an addition, not a Pedersen hash:
loading...
The name comes from the spec: in the protocol document this is called
MixingPedersenHash because the protocol's editorial convention treats this as
a one-input Pedersen hash where the generator is selected by position. The
implementation uses scalar multiplication directly because no other
Pedersen-hash machinery is needed.
3.3 The PRF
loading...
Both inputs go through Jubjub's compressed encoding (to_bytes), then through
BLAKE2s with the b"Zcash_nf" personalisation. The output is exactly 32 bytes;
the result is constant-size regardless of the inputs' representations.
3.4 Notes
The Note type ties it all together:
loading...
A note can also be a dummy: a synthetic note with value = 0 used to pad
bundles to a minimum size. The dummy constructor generates a fresh master key,
derives an address from it, and samples a fresh rseed:
loading...
The dummy spend's nullifier is well-defined but not consensus-relevant (no real note's nullifier can collide with it for an honest user). The dummy exists so that every spend in a bundle has the same shape and the bundle's size leaks no information about the spending pattern.
4. Failure modes
- Position omitted from the nullifier formula. A naive
formulation lets an attacker who finds
two notes with the same
cmu(e.g. by maliciously choosingrseed) spend each one twice. The position mixing defeats this. Caught by: the Spend circuit computes the perturbed commitment in-circuit and feeds it to a BLAKE2s gadget; without the perturbation the constraint count changes and thecs.num_constraints() == 98777assertion fails. - rseed mistakenly used as rcm directly post-ZIP-212. A refactor that elided
the
Rseed::rcmbranch would cause post-ZIP-212 notes to use the raw rseed bytes as a Jubjub scalar, which is a different commitment than the spec'sPrfExpandderivation. Caught by: the existing test vectors intest_vectors::note_encryptionexercise both pre- and post-ZIP-212 paths. - Nullifier byte order swapped. The 32 bytes returned by
prf_nfare written into the wire format as-is. Reversing them on the wire (e.g. by accidentally calling.reverse()somewhere) produces nullifiers that other nodes' indexes do not match. Caught by: nothing automatic; a deviation here breaks consensus immediately and is caught on the first block built.
5. Spec pointers
- Zcash Protocol Specification, §5.4.2 (Pseudo Random Functions)
defines
PRF^nfSaplingand the personalisation tags. - Zcash Protocol Specification, §4.16 (Note Commitments and Nullifiers)
defines
DeriveNullifierand motivates the faerie-gold defence. - ZIP 212 explains the rseed derivation and the grace period semantics.
6. Exercises
- Show that the position generator is non-trivial. Inspect
NULLIFIER_POSITION_GENERATORand confirm via the existingnullifier_position_generatortest that it equalsfind_group_hash(&[], NULLIFIER_POSITION_IN_TREE_GENERATOR_PERSONALIZATION). Run that test. - Compute one nullifier by hand. Construct a
Notewith value zero, deterministic recipient, andRseed::BeforeZip212(0). Compute itscm_full_pointby hand using the windowed Pedersen commitment. Then mix inposition = 0. Then BLAKE2s with personalisationb"Zcash_nf", keyed bynkserialized as 32 bytes. Compare tonote.nf(&nk, 0).0. - Add a constant-time-equality test for
Nullifier.Nullifieralready implsConstantTimeEq. Add a unit test that checks two equal nullifiers returnChoice::from(1)and two unequal ones returnChoice::from(0). Verify the test passes undercargo test --all-features.
Answers in the code. For exercise 1, NULLIFIER_POSITION_GENERATOR is
derived from the personalisation b"Zcash_J_"; see
constants.rs line 40
and the test that recomputes it from the personalisation tag. For exercise 2,
the position-0 mixing is trivial (0 * NULLIFIER_POSITION_GENERATOR = identity,
so the perturbed commitment equals the original commitment).