UPP — Universal Private PoolConcepts

Viewing Keys

Per-transaction viewing key derivation — how to share selective read access with auditors without compromising spending ability.

Design Status

The viewing key derivation scheme is in active development. The per-note key hierarchy for selective disclosure is stable in principle; cryptographic details may evolve.

Key Hierarchy

Wallet Signature


┌─────────────────────────────────────┐
│  Master Viewing Key (MVK)           │
│  MVK_priv: scalar from signature    │
│  MVK_pub: MVK_priv * G              │
└─────────────────────────────────────┘

       │ derives using R.x as nonce

┌─────────────────────────────────────┐
│  Per-Note Viewing Keys              │
│  EVK: encryption key (sender uses)  │
│  DVK: decryption key (recipient)    │
└─────────────────────────────────────┘

Why Per-Note Keys?

Sharing your master viewing key exposes your entire history. Per-note keys let you:

  • Share specific notes for auditing
  • Keep all other notes private
  • Future notes remain protected even after sharing

The R.x Nonce Approach

Each note has an ephemeral public key R generated by the sender. The note-specific keys are derived using R.x as a nonce:

// Sender side
const r = randomScalar()
const R = r * G

// Derive per-note keys using R.x
const offset = Poseidon(MVK_pub.x, MVK_pub.y, R.x)

// EVK = MVK_pub + offset * G  (sender encrypts with this)
// DVK = MVK_priv + offset     (recipient decrypts with this)

Why R.x and not the leaf index? The sender doesn't know the leaf index when creating the note — it's determined when the transaction mines. R.x is known at creation time.

Encryption Flow

Sender side:

const r = randomScalar(), R = r * G

// 1. Derive EVK using R.x
const evk = deriveEVK(recipientMVKPub, R)

// 2. ECDH shared secret
const shared = r * evk

// 3. Encrypt note with AES-GCM
const ciphertext = encrypt(shared, noteData)

// 4. Include R for the recipient
return { ciphertext, R }

Recipient side:

const R = note.ephemeralPubKey

// 1. Derive DVK using same R.x
const dvk = deriveDVK(mvkPriv, mvkPub, R)

// 2. ECDH shared secret
const shared = dvk * R

// 3. Decrypt
const noteData = decrypt(shared, ciphertext)

Audit Export

Share read access to specific notes without exposing your DVK scalar:

const auditExport = await exportViewingKeysForAudit(keys, address, [
  { leafIndex: 42, ephemeralPubkeyX: note1.R.x },
  { leafIndex: 57, ephemeralPubkeyX: note2.R.x },
])

// Export contains ECDH shared secrets (points), not DVK scalars
// viewingKey: [sharedSecret.x, sharedSecret.y]

Security

Audit exports contain the ECDH shared secret point, not the DVK scalar. Recovering the DVK from the shared secret requires solving the Elliptic Curve Discrete Logarithm Problem (~128-bit security). Auditors can decrypt specified notes but cannot derive keys for other notes.

Search Tag Optimization

To avoid decrypting every note, a 64-bit search tag filters candidates:

const tag = Poseidon(sharedPoint.x, 0n) & ((1n << 64n) - 1n)

Recipients compute the expected tag, skip non-matches, and only fully decrypt potential matches. This filters ~99.999% of irrelevant notes.

On this page