Note Discovery
Commitments on-chain are opaque 32-byte hashes. Nothing about them says who owns what. So how does a wallet find its notes?
The answer is the encrypted memo: a 180-byte blob attached to every commitment, encrypted so that only the recipient can read it. This page covers the memo format, the view tag that makes scanning cheap, and the stealth addresses that keep a recipient's notes unlinkable.
Because the memo is on-chain and its decryption key derives from your wallet signature, losing your device does not lose your funds. A fresh wallet rescans the chain and rebuilds the vault. See Private Vault → Recovery.
The encrypted memo
Every operation that creates a note carries a memo. The chain treats it as an opaque blob and only
checks its length — exactly 180 bytes, or the call fails with InvalidMemoSize.
nonce(12) | ciphertext + MAC(136) | ephPk(32) = 180 bytes
The 120-byte plaintext inside:
| Offset | Size | Field |
|---|---|---|
| 0 | 16 | value — 128-bit little-endian |
| 16 | 32 | owner_pk — the note's stealth owner key |
| 48 | 32 | blinding |
| 80 | 4 | asset_id |
| 84 | 32 | counterparty_pk — who sent it |
| 116 | 4 | circuit_version |
Those six fields are everything needed to reconstruct and later spend the note. circuit_version
travels in the memo so a wallet knows which proving key to use without extra lookups, and
counterparty_pk is what lets a recipient see who paid them.
Key derivation
Encryption is ECDH over Baby JubJub with ChaCha20-Poly1305:
ephSk = random scalar
ephPk = ephSk · Base8 ← published in the memo
sharedPoint = recipientIvk · ephSk ← sender's view
= ephPk · ivsk ← recipient's view
sharedSecret = Ax(sharedPoint), 32 bytes LE
key = SHA256(sharedSecret || commitment || "orbinum-note-encryption-v1")
Both parties compute the same sharedSecret from different halves. The key is bound to the
commitment, so it is unique per note.
View tags: scanning without decrypting everything
A naive scan would attempt AEAD decryption on every commitment in the pool. That is expensive and gets worse as the chain grows.
The view tag solves it. The first byte of the nonce is not random — it is derived from the shared secret:
nonce[0] = SHA256("orbinum-view-tag-v1" || sharedSecret)[0]
The wallet computes the same byte from its viewing key and compares. A mismatch means the note is not yours, and it skips decryption entirely. This discards 255 of every 256 foreign notes for the cost of one hash.
The tag is safe to publish: without the viewing key the shared secret is unknowable, so to any observer the byte is uniform noise.
Memos built before the view tag shipped have a random value in nonce[0], so the filter would
produce false negatives on them. Wallets track a tagActivationLeaf and only apply the filter from
that leaf onward. tryDecryptNote in the SDK handles this.
Stealth addresses
Notes you receive do not carry your global public key. Each one gets a fresh owner key derived from the shared secret:
stealthScalar = HKDF-SHA256(ikm=sharedSecret, salt=ownerPk_LE, info="orbinum-stealth-v1")
mod BABYJUB_SUBORDER
stealthPoint = stealthScalar · Base8 + ownerPkPoint
owner_pk = Ax(stealthPoint)
The sender derives it when building the note; the recipient derives the matching spending scalar
with deriveStealthSk when spending it.
Consequence worth understanding: two notes sent to the same person carry two different
owner_pk values, and nobody can tell they share a recipient. Your global key never appears in a
commitment.
This also shapes what a disclosure key reveals — see the caveat in Note Disclosure.
Putting it together
import { tryDecryptNote, deriveViewTag } from '@orbinum/sdk';
// For each commitment the wallet has not seen:
// 1. cheap view-tag check → discards ~255/256
// 2. full ECDH + AEAD decrypt → only on survivors
// 3. on success: reconstruct the note and store it in the vault
const note = tryDecryptNote(memo, commitment, viewingKey);
tryDecryptNote performs all three steps. Use tryDecryptNoteVerbose when you need to know why a
candidate failed — wrong tag, MAC failure, or malformed plaintext.
Related
- Private Vault — where decrypted notes live on your device
- Note Disclosure — proving a note's contents to a third party
- Privacy Architecture — the commitment and nullifier model
- SDK —
tryDecryptNote,deriveViewTag,selectNotes