Viewing Keys & Key Hierarchy
4-tier graduated key disclosure — from per-note viewing keys to full spending authority, with nonce chain completeness for provable audits.
Key Hierarchy
UPP uses a 4-tier key hierarchy that enables graduated disclosure. Each tier reveals more information but never more than necessary for the auditor's purpose.
4 Disclosure Tiers
| Tier | Key Shared | What It Reveals | Spend Risk |
|---|---|---|---|
| 1. Per-note viewing key | Note-specific AES key | Specific note contents only | None |
| 2. Master viewing key | viewingSecret | All notes (past and future) | None |
| 3. Nullifier key | nullifierKey | All notes + outbound transfer tracking | None |
| 4. Spending secret | spendingSecret | Everything + full spend authority | Total |
Graduated Disclosure
Most audits only need Tier 1 or 2. The nullifier key (Tier 3) is separated from the spending secret via a domain-separated derivation: nullifierKey = Poseidon(spendingSecret, 0). Knowing the nullifier key doesn't allow spending — it only reveals which notes have been consumed.
Per-Note Encryption
Each note is encrypted with a unique AES-GCM key derived from the viewing secret and a per-note nonce:
noteKey = keccak256(Poseidon(viewingSecret, noteNonce))The noteNonce is included in the unencrypted on-chain header alongside the search tag and owner hash:
Why per-note keys? Sharing the decryption key for one note doesn't expose any other note. An auditor given Tier 1 access to specific notes can decrypt only those notes — all others remain opaque.
Nonce Chain Completeness
To prove that an audit export contains all notes (not just a convenient subset), UPP uses a deterministic nonce chain:
nonce_0 = keccak256(viewingSecret || domain)
nonce_1 = keccak256(nonce_0)
nonce_2 = keccak256(nonce_1)
...
nonce_n = keccak256(nonce_{n-1})An auditor verifies completeness by checking:
- Each nonce in the export is the keccak hash of the previous one
- The chain is unbroken — no gaps between consecutive nonce indices
- The signed
totalNoteCount(EIP-712) matches the chain length
If any note is omitted, the chain breaks — the auditor detects the gap immediately.
Signed Note Count
The account holder signs an EIP-712 typed message committing to their total note count:
const typedData = buildNoteCountTypedData({
address: walletAddress,
noteCount: notes.length,
chainId,
})
// User signs this — auditor verifies the signature
const signature = await signTypedData(typedData)This prevents the user from claiming they have fewer notes than they actually do.
Search Tag Filtering
To avoid decrypting every note on-chain, a 64-bit search tag lets recipients filter candidates:
const tag = Poseidon(sharedPoint.x, 0n) & ((1n << 64n) - 1n)Recipients compute their expected tag, skip non-matches, and only fully decrypt potential matches. This filters ~99.999% of irrelevant notes while maintaining privacy.
Audit Export Format
The SDK's exportViewingKeysForAudit() produces a JSON file containing:
interface AuditKeyExport {
version: 5
address: Address // Account holder's address
chainId: number // Chain ID
notes: AuditNoteEntry[] // Per-note viewing data
nonces: string[] // Nonce chain for completeness verification
totalNoteCount: number // Signed note count
noteCountSignature: Hex // EIP-712 signature
nullifierKey?: string // Optional — Tier 3 disclosure
}The export is directly consumable by the upp-audit CLI tool. See the Audit Reconstruction page for verification workflows.