/// The ML-KEM-768 KEM ciphertext produced during encapsulation (1088 bytes) of a message.
/// `EphemeralPublicKey` is used to not confuse the ciphertext of an encrypted private account.
type EphemeralPublicKey = [u8; 1088];
/// Private account Viewing Public Key is a ML-KEM-768 encapsulation key (1184 bytes).
type ViewingPublicKey = [u8; 1184];
/// The ML-KEM-768 shared secret (32 bytes).
type SharedSecretKey = [u8; 32];
struct EncryptedAccountData {
ciphertext: Ciphertext,
epk: EphemeralPublicKey,
view_tag: u8, // 1-byte view tag
}
```
#### Key agreement and shared secret
When creating a private account output, the sender uses the recipient's viewing public key to encapsulate a random message that is used to establish a shared secret between the sender and recipient:
- Sender: $(\mathsf{ss},\, \mathsf{epk}) = \mathsf{encapsulate}(\mathsf{vpk\_recipient})$. The 1088-byte ciphertext `epk` is included in the transaction as the `EphemeralPublicKey` field.
The `ssk` field carries the **shared secret key** — the 32-byte shared secret used to encrypt the post-state. Note that the key protocol uses `ssk` for "spending secret key" (the master key that derives `nsk` and `vsk`); here `ssk` means the per-output KEM shared secret. It is established via ML-KEM-768:
where `epk` is the ML-KEM-768 ciphertext (1088 bytes) stored as the `EphemeralPublicKey`, `vpk` is the recipient's `ViewingPublicKey` (1184 bytes), and `(vsk.d, vsk.z)` are the 32-byte seed halves of the recipient's `ViewingSecretKey`.
where `Npk` is the 32-byte nullifier public key and `Vpk` is the 1184 byte `ViewingPublicKey` of the recipient. On average only 1 in 256 outputs will pass this filter for a given account, avoiding expensive ML-KEM decapsulation on irrelevant outputs.
3. Run `kdf(ss, commitment, output_index)` to derive the symmetric key.
4. Decrypt the ciphertext with ChaCha20.
5. Parse the 81-byte header to recover `PrivateAccountKind`.
6. Parse the remaining bytes to recover the `Account`.
7. Recompute the account ID from the kind and verify that `Commitment::new(account_id, account)` equals the on-chain commitment. Discard on mismatch (false positive).
```rust
fn private_account_discovery(
tx: &PrivacyPreservingTransaction,
vsk: &ViewingSecretKey,
npk: &NullifierPublicKey,
vpk: &ViewingPublicKey,
) -> Vec<(PrivateAccountKind, Account)> {
let expected_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
let mut discovered = Vec::new();
for (output_index, (encrypted_account, commitment)) in tx.message.encrypted_private_post_states