UPP — Universal Private PoolConcepts

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

TierKey SharedWhat It RevealsSpend Risk
1. Per-note viewing keyNote-specific AES keySpecific note contents onlyNone
2. Master viewing keyviewingSecretAll notes (past and future)None
3. Nullifier keynullifierKeyAll notes + outbound transfer trackingNone
4. Spending secretspendingSecretEverything + full spend authorityTotal

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:

On-chain note layoutsearchTag8 bytesownerHash32 bytesnoteNonce32 bytesAES-GCM ciphertextvariable⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ 72-byte fixed header ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

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:

  1. Each nonce in the export is the keccak hash of the previous one
  2. The chain is unbroken — no gaps between consecutive nonce indices
  3. 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.

On this page