Post-quantum Note Encryption
When an Action creates an output note, the chain stores only its commitment cm. The note's plaintext travels in two ciphertexts published alongside it:
C_enc: for the recipient, openable with the incoming viewing keyivkand the recipient's ML-KEM-768 private key.C_out: for the sender (or an auditor withovk), so the sender can recover what they sent from chain data alone, with no local state.
Encryption is always hybrid (classical ECDH + post-quantum ML-KEM-768). There is no classical-only mode: a sender who cannot resolve the recipient's ML-KEM public key (via the PqPubkeyRegistry or the recipient's published address) cannot send them a private note.
Setup
esk ← random BJJ scalar (fresh per output)
epk = [esk] · gd_new # ephemeral pubkey, published on-chain
ssEcdh = [esk] · pkd_new = [ivk] · epk # classical DH leg (commutative)
(ssPq, mlkemCT) ← ML-KEM-768.Encapsulate(recipient.mlkemPubkey) # PQ leg
K_enc = HKDF-SHA256(ssEcdh ‖ ssPq ‖ epk ‖ mlkemCT, info="platus.action.kenc.v1")
The DH identity [esk]·pkd_new = [esk·ivk]·gd_new = [ivk]·epk is why sender and recipient derive the same ssEcdh. The circuit constrains epk = esk·gd_new (and esk ≠ 0), so a sender cannot publish a valid commitment with an epk that doesn't match their ciphertext — that would brick the note for the recipient.
Breaking confidentiality requires breaking both legs. A future quantum adversary that defeats the BabyJubJub ECDH still faces ML-KEM-768; an adversary who defeats ML-KEM still faces ECDH. This is the harvest-now-decrypt-later defense for the publicly stored ciphertexts.
View tags: fast scanning
Each output publishes a 1-byte view tag:
Only the recipient (holding ivk) or the sender can recompute ssEcdh, so the tag is uniform-random to anyone else and leaks nothing.
C_enc — recipient ciphertext
The plaintext is constant length regardless of memo size, so the on-chain ciphertext length never leaks how long the memo is:
P_enc = d (11) ‖ value (16, LE) ‖ rho (32, LE) ‖ rcm (32, LE) ‖ psi (32, LE)
‖ memoLen (2, LE) ‖ memo (zero-padded to 512) # 637 bytes total
C_enc = mlkemCT (1568) ‖ ChaCha20-Poly1305(K_enc, nonce = 0…0).encrypt(P_enc)
C_out — sender recovery
C_out lets the sender (or ovk holder) recover the note from chain data alone. Its key is per-output unique:
ock = HKDF-SHA256(ovk ‖ cvNet ‖ cm_new ‖ epk, info="platus.action.ock.v1")
P_out = pkd_x (32) ‖ pkd_y (32) ‖ esk (32) ‖ ssPq (32) # 128 bytes
C_out = ChaCha20-Poly1305(ock, nonce = 0…0).encrypt(P_out)
The stashed ssPq is the key trick: with pkd and esk the sender recomputes ssEcdh = [esk]·pkd, and with ssPq they re-derive K_enc and open C_enc without the recipient's ML-KEM private key — recovering the full (value, rho, rcm, psi, memo).
Post-quantum hybrid encryption
The ML-KEM-768 keypair is derived deterministically from the spending key:
pqSeed = PRF^expand(sk, 0x0c) # SHA512(sk ‖ 0x0c), 64 bytes
(mlkemPubkey, mlkemPrivkey) ← ML-KEM-768.KeyGen(pqSeed)
PaymentAddress carries mlkemPubkey so any sender can encrypt, recipients publish it to the on-chain PqPubkeyRegistry (write-once, keyed by their BJJ transmission key, authorized by a BJJ Schnorr signature) so even an unknown sender can resolve it.