Keys and ZIP 32
1. Why this chapter exists
Sapling has eight named keys that derive from one root, plus a diversifier key,
plus the ephemeral keys used for encryption. The relationships among them are
not documented in any single comment in the source; you have to read four files
(keys.rs, zip32.rs, address.rs, spec.rs) and the protocol spec to
reconstruct the tree.
By the end you should be able to draw, from memory, the derivation DAG from
seed to PaymentAddress, name each edge function, and
locate each node in the source.
1.5 A one-page reminder of how a Sapling spend works
Before naming eight keys it helps to remember what actually happens when a shielded user spends. The keys are not arbitrary; each one exists because some step below needs exactly that capability and no more. Read this section as a checklist of "what must someone be able to do", then read section 2 as "which key gives them that ability".
1.5.1 The state on chain
A shielded balance is not an account. It is a set of notes. A note is a tuple
where identifies the recipient (the
PaymentAddress), is the value, and is the commitment
trapdoor. The chain never stores the note. It stores only
- the note commitment , appended to a global incremental Merkle tree (the note commitment tree);
- the nullifier set, a set of opaque 32-byte tags that record which notes have already been spent.
Spending a note is therefore: prove that some note exists in the tree, reveal its nullifier, and create new notes for the recipients of the spend.
1.5.2 The four things the spender must do
For each input note the spender must be able to:
- Prove the note is in the tree. Knowledge of a Merkle path from to the published anchor. No key needed for the path itself, but the spender must know the note, which means the spender must have been able to decrypt it on receipt.
- Prove that the note has not already been spent, by revealing its nullifier . The chain refuses any transaction whose nullifier is already in the set. Deriving requires a secret tied to the note (otherwise anyone could mark anyone's notes as spent).
- Authorize the spend, so that only the legitimate holder of the note can move it. This is a signature over the transaction, tied to a public value committed inside the Spend proof.
- Produce the proof itself, a Groth16 Spend proof certifying that (1), (2), and (3) are consistent: the spender knows a note in the tree, the revealed nullifier matches that note, and the signing key matches the note's owner.
The same spender must also, for each new recipient, create an output: build a fresh note, encrypt it to the recipient, and prove (in the Output circuit) that the encrypted note is well-formed and its value commitment is consistent with the spend.
Add to that the bookkeeping side: a wallet observer must be able to recognize own activity without holding the spending key, both on the incoming side (notes addressed to me) and the outgoing side (notes I sent, which I want to remember after I no longer have the plaintext).
1.5.3 Mapping each ability to a key
Each ability in 1.5.2 is granted by exactly one secret, and the names of the keys track that one-to-one mapping:
| Ability | Secret used | Name |
|---|---|---|
| decrypt an incoming note to learn | incoming viewing key | |
| compute the nullifier of a note I own | nullifier secret | (or its public form ) |
| sign the spend authorization | spend authorizing secret | (public form ) |
| build the Spend proof without holding | proving capability | |
| re-decrypt a note I sent (audit / change tracking) | outgoing viewing key | |
| publish a fresh unlinkable receive address | diversifier layer | |
| derive many diversifiers deterministically from an HD seed | diversifier key | |
| encrypt one output to its recipient | per-output ephemeral secret |
Two consequences fall out of this table and explain the rest of the chapter:
- The reason
ProofGenerationKey = (ak, nsk)exists is that abilities 1 (decrypt), 2 (nullifier), and 4 (proof) can all be done without ability 3 (sign). That is the hardware-wallet split: the slow machine proves, the small machine signs. - The reason
FullViewingKey = (ak, nk, ovk)exists is that audit needs to recognize both incoming and outgoing activity without ever signing or proving. It cannot move funds because it lacks (cannot produce a valid in-circuit nullifier preimage) and (cannot sign).
Section 2 now restates this as a capability ladder and gives the workflows that motivate each rung.
2. Why so many keys
Sapling could in principle drive everything from one secret. It does not. The wallet is instead split into a capability ladder: each rung holds strictly less power than the rung above, and the names of the keys track which capability they grant. The point is least privilege, so each counterparty (signer, prover, viewer, payment processor, recipient) only ever holds what it needs. That is what produces the long list of keys: each key isolates one capability that some real workflow wants to hand out independently.
2.1 The capability ladder
From most powerful to least powerful:
| Rung | Key bundle | What it lets you do |
|---|---|---|
| Spend | ExpandedSpendingKey | Sign spends, build Spend proofs, decrypt own outgoing notes. Full control of funds. |
| Prove | ProofGenerationKey | Build Spend proofs (and the in-circuit nullifier) without ask. Cannot sign, so cannot move funds. |
| View (full) | FullViewingKey | Detect own spends, decrypt incoming notes, decrypt own outgoing notes. Read access to all activity. |
| View (incoming only) | Trial-decrypt incoming notes. Cannot detect own spends, cannot see outgoing. | |
| View (outgoing only) | Re-decrypt notes this wallet sent (change tracking, audit). | |
| Receive (one unlinkable label) | PaymentAddress | Receive funds. Many d values under one ivk give unlinkable addresses on chain. |
Workflows that motivate the splits:
- A hardware wallet holds and signs spends. A faster online machine holds and runs the heavy Groth16 prover. The online machine cannot move funds even if it is compromised.
- An auditor or watch-only wallet holds the
FullViewingKey. It sees the wallet's entire transaction history on both sides but cannot spend. - A payment processor holds only to detect incoming payments and learns nothing about what the receiver later spends.
- A user advertises many
PaymentAddressvalues (one per counterparty) under one wallet, so that on-chain observers cannot link them.
The picture inside the Spend circuit is the mirror image of this ladder: the circuit witnesses the proving-capability keys and derives every downstream key in zero knowledge, so the proof certifies "I hold the proving capability for this note" without revealing any key directly.
2.2 A mnemonic for the names
The alphabet soup is regular once you see the prefixes:
- a stands for "auth". is the spend-authorizing secret key; is the matching validating point.
- n stands for "nullifier". is the nullifier secret key; is its public deriving point.
- i stands for "incoming". is the incoming viewing key.
- o stands for "outgoing". is the outgoing viewing key.
- d is the diversifier layer: is the 88-bit diversifier; is its hash to a Jubjub base; is the diversified transmission key; is the AES-256 key used by FF1 (ZIP 32) to map indices to diversifiers.
In one sentence: every secret/public pair is " to by scalar multiplication on a fixed generator"; comes from a hash of because the incoming-view capability is logically downstream of both auth and nullifier; the diversifier layer at the bottom is freshly generated as needed.
2.3 Where each key shows up (cheat sheet)
| Key | Lives in | Used by Spend circuit? | Used by Output circuit? |
|---|---|---|---|
| spending key only | no (signing is out of circuit) | no | |
| proving / viewing | yes: witness, feeds and ivk preimage | no | |
| proving / spending | yes: witness, | no | |
| viewing | yes: derived in-circuit, feeds + ivk | no | |
| incoming viewing | yes: derived in-circuit, gives | no | |
| outgoing viewing | no | no (used by note encryption only) | |
| address | no | no | |
| address | yes: witness, small-order check, note commitment | yes: witness, small-order check, , commit | |
| address | yes: , in note commit | yes: witnessed without on-curve check, in note commit | |
| ZIP 32 | no | no | |
| per-output secret | no | yes: witness, |
The two viewing keys (, ) never enter a circuit themselves; they live in the note-encryption pipeline. Everything in the "proving" rung (, ) is what the Spend circuit actually witnesses.
3. Definitions
3.1 The Sapling key tree (below ZIP 32)
Starting from a 32-256 byte spending key (typically output by ZIP 32 child derivation):
Definition 7.1 (ask, ak). The spend authorizing key is
,
restricted to nonzero scalars, and is wrapped by
SpendAuthorizingKey. The spend validating key is
,
wrapped by SpendValidatingKey. The Sapling RedJubjub
signature scheme uses as its signing /
verification key pair.
Role: signs the spend authorization signature over the SIGHASH out of circuit; it never enters either circuit. is witnessed in the Spend circuit and used in two places: the re-randomized form is exposed as a public input (clause 2 of ), and is the first half of the in-circuit preimage (clause 4).
Definition 7.2 (nsk, nk). The nullifier secret key is
.
The nullifier deriving key is
,
wrapped by NullifierDerivingKey. The pair
is the ProofGenerationKey;
the pair is the viewing key.
Role: is witnessed in the
Spend circuit, where clause 3 of
enforces .
The derived is then mixed into the in-circuit nullifier
(clause 10) and into the preimage (clause 4). Out of circuit, a
holder of can recognise their own notes' nullifiers once they have
decrypted the note (this is the "detect own spends" capability of the
FullViewingKey).
Definition 7.3 (ivk). The incoming viewing key is
specifically with the
most-significant 5 bits cleared so it fits in the Jubjub scalar field. It is
computed by crh_ivk and exposed as
ViewingKey::ivk.
Role: out of circuit, the receiver uses to trial-decrypt every new on-chain Output (see Note encryption). Inside the Spend circuit, is recomputed from (clause 4) and immediately used to derive (clause 6); this is how the proof certifies that the spent note's recipient was indeed the wallet behind this .
Definition 7.4 (ovk). The outgoing viewing key,
OutgoingViewingKey, is a 32-byte opaque value
truncated to 32 bytes.
It is used to let the sender re-decrypt their own outputs without revealing them
to the receiver's ivk.
Role: is purely out of circuit. Note encryption derives the
OutgoingCipherKey from to wrap
into the out_ciphertext; a wallet that keeps can later recover
the recipient address and amount of any Output it created. Neither the Spend nor
the Output circuit sees .
Definition 7.5 (d, g_d, pk_d). A diversifier
Diversifier defines the base of a
payment address. Each diversifier yields
;
about 50% of diversifiers fail (g_d = None). The diversified
transmission key is ,
computed by
DiversifiedTransmissionKey::derive. The
payment address is a
PaymentAddress.
Role: the diversifier itself never enters either circuit; only its image does, in both. In the Spend circuit, is witnessed and small-order-checked, then used both to derive (clause 6) and to feed the note commitment (clause 8). In the Output circuit, is witnessed and small-order-checked, then used to expose the sender's ephemeral key (clause 3) and to feed the note commitment (clause 4); the Output circuit witnesses without an on-curve check, because the recipient's decryption will detect any malformed value off chain.
3.2 ZIP 32
Definition 7.6 (ZIP 32 extended key). An extended Sapling spending key is a quintuple
where is the ExpandedSpendingKey and
is the DiversifierKey. Child derivation uses
instances indexed by ZIP 32's tag
constants (ZIP32_SAPLING_*) to mix in the child index and the
parent chain code. Hardened-only children: ZIP 32 forbids non-hardened Sapling
derivation because the deterministic derivation of a child verification key from
a parent's is not desired here.
Role of : is a 32-byte AES-256 key. It is used by FF1 to map a 11-byte diversifier index to the 88-bit candidate diversifier (Section 4.3); the same therefore enumerates the family of addresses under one wallet. It never enters either circuit and is not part of any viewing key shared with third parties.
Invariant 7.7 (non-zero ask). must be nonzero. The
probability of a zero ask from is negligible
( ish), but the code explicitly handles it: every constructor of
SpendAuthorizingKey checks the scalar and returns
None on zero. The
ExpandedSpendingKey::from_spending_key
constructor panics if this triggers, accepting the negligible probability of an
unrecoverable seed.
Invariant 7.8 (ak prime-order). must be an element of
and not the identity. The deserializer rejects
non-prime-order points:
SpendValidatingKey::from_bytes uses
jubjub::SubgroupPoint::from_bytes and an explicit
!p.is_identity() check.
4. The code
4.1 The bottom layer: keys.rs
keys.rs defines every key in the tree except the ZIP 32 extended ones. It is
the file you will reread most often. Three regions to internalise:
- The signing key types (
SpendAuthorizingKey,SpendValidatingKey) and theirFrom<&SpendAuthorizingKey>impl that derives the validating key. These are wrappers aroundredjubjubtypes. ExpandedSpendingKey,ProofGenerationKey,ViewingKey,FullViewingKey. The chain ofFrom<&Foo> for Barimpls plus named methods (to_viewing_key,from_expanded_spending_key).SaplingIvkandPreparedIncomingViewingKey. The "prepared" variant exists to amortise aWnafScalarprecomputation across many trial-decryption attempts.
loading...
The derivation chain
seed -> ExpandedSpendingKey -> ProofGenerationKey -> ViewingKey -> SaplingIvk -> PaymentAddress
is wired through the Into / method impls:
loading...
loading...
4.2 The ZIP 32 layer: zip32.rs
zip32.rs is the largest single file in the crate (1876 lines). It mostly does
plumbing: serialise/deserialise extended keys, derive children, find
diversifiers via FF1 over AES-256.
loading...
loading...
A subtle structural detail is the internal full viewing key: a derivative of
a "normal" DiversifiableFullViewingKey used to
detect transactions that the wallet itself originated (e.g. change outputs). The
derivation mixes in a separate personalisation
(ZIP32_SAPLING_INT_PERSONALIZATION) so
that the internal and external scopes do not share an ivk.
4.3 Diversifier search via FF1
ZIP 32 reuses FF1 over AES-256 as a 88-bit format-preserving pseudo-random permutation to map a diversifier index to an actual 88-bit diversifier:
loading...
The find-loop in DiversifierKey::diversifier
walks consecutive indices until it lands on one whose output happens to be a
valid Jubjub-friendly diversifier (about 1 in 2). On average two indices are
tried per address.
5. Failure modes
- Confusing ask and ak. The signing key is
ask(a scalar); the verification key isak(a curve point). TheFrom<&SpendAuthorizingKey> for SpendValidatingKeyimpl inkeys.rsmakes derivation look like a coercion; if you seeSpendValidatingKey::from(&ask), that is a real scalar-multiplication, not a no-op cast. See issue #106. Caught by: review. - Trying to derive a non-hardened child. ZIP 32 for Sapling forbids
non-hardened derivation. The
KeyIndex::newhelper rejects this; trying to derive yieldsDecodingError::UnsupportedChildIndex. Caught by: that error variant and the unit tests inzip32.rs. pk_d = identity. Ifivk= 0(or the diversifier produces ang_dthat happens to multiply to the identity), the resultingpk_dis the identity and there is no recipient. TheDiversifiedTransmissionKeyconstructors reject this:PaymentAddress::from_parts_uncheckedchecksis_identityand returnsNone. Caught by: that check. See also issue #168 for the related "makeivk = 0unrepresentable" refactor.- Lossy "prepared" form drift.
PreparedIncomingViewingKeycaches aWnafScalar; ifSaplingIvkchanges its scalar encoding but the prepared form is not regenerated, decryption silently fails. Caught by: trial decryption tests innote_encryption.rs.
6. Spec pointers
- Zcash Protocol Specification, §4.2.2 (Sapling Key Components) is the master source for every relationship above.
- ZIP 32 (HD wallets for Zcash), specifically
§"Sapling extended spending keys", is the master source for
zip32.rs. - ZIP 316 (Unified addresses and viewing keys)
is the source for the
DiversifiableFullViewingKeyshape and the "internal" FVK derivation.
7. Exercises
- Trace one derivation. Pick a 32-byte spending key (e.g. all zeros). Step
by step, compute
ask,ak,nsk,nk,ivk,ovkusing the public APIs inkeys.rs. Verify each intermediate againstExpandedSpendingKey::from_spending_key. - Add a
Displayimpl forSaplingIvk. Pick a stable encoding (hex of the scalar's little-endian representation) and addimpl fmt::Display for SaplingIvk. Add a doctest that matches against a known value derived from a fixed test vector (you can grab one from the existingspend_auth_sig_test_vectors). - Compute a diversifier index round-trip. Use
DiversifierKey::diversifier_indexon a known diversifier and confirm the index returned, when fed back throughdiversifier, yields the original byte string. This exercises the FF1 round-trip.
Answers in the code. For exercise 1, the four "PRF expand" calls
(SAPLING_ASK, SAPLING_NSK, SAPLING_OVK, SAPLING_ZIP32_*) live in the
zcash_spec crate. For exercise 3, note that "valid diversifier" is a property
of the 88-bit output, not the index; the index round-trip works on any 11-byte
buffer but only some of those buffers represent valid Sapling diversifiers.