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. On a fresh wallet you can rebuild the vault by importing a note backup (instant) or by running a manual rescan of the chain — the scan never runs on its own. 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 | source_pk — a one-time key of the other party, or zero |
| 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.
What source_pk is — and is not
The wallet SDK calls it sourcePk. The distinction from its original name — counterparty_pk, which
survives only in an unused Rust crate — matters because that name invited the assumption that this
field identifies the sender. It does not.
What it carries is the owner_pk of the note that was spent — and only when that key is
one-time, meaning it came from a stealth-addressed transfer and is used for exactly one note.
When the spent note has no such key, the field is zero.
The concrete case: a note you shielded to yourself is self-addressed, with no stealth derivation,
so its owner_pk is your permanent public key. Stamping that into a recipient's note would
hand them a stable identifier for you — every payment you ever made from a shielded note would
link to the same key, they could match it against your published privacy address, and disclosing
that one note would expose you without your consent. So on that path the field stays zero. Zcash
puts nothing about the sender in the recipient's note for the same reason.
Because the recipient's note holds no durable identifier of the sender, "reply to this payment" is not something the note itself can support. The sender opts in instead, by sharing a payment slip out of band.
The field means something different depending on which note carries it. On a change note — the one a transfer returns to its sender — those 32 bytes hold a sealed recipient-book entry instead, readable only by the sender. It is the same slot because in both cases the value is metadata the commitment never covers.
Per-note ECDH
Encryption is ECDH over Baby JubJub with ChaCha20-Poly1305. This is the per-note key exchange — distinct from the wallet-wide derivation tree in Keys & Identity:
ephSk = derived, or random when nothing derives it ← see below
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")
A random ephSk is the fallback, not the norm. Three deterministic families cover the cases where
a wallet needs to find a note again later, each on its own domain: selfEph for notes you address
to yourself, pairwiseEph for a counterparty you already know, and the outgoing sequence below for
notes you send. Only the recipient's view changes nothing — an ephPk is a curve point either way,
and which family produced it is unknowable without the corresponding key.
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 wallet 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
@orbinum/wallet-sdk is a private package. This snippet shows the shape of the inputs, not
something you can install and run. Reaching this API requires
wallet SDK access.
import { tryDecryptNote, deriveViewTag } from '@orbinum/wallet-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
//
// The first arg is a ScanCommitment { commitmentHex, leafIndex, encryptedMemo };
// spendingKey is required (it derives the note's nullifier), ownOwnerPk is
// optional and enables stealth-note detection.
const note = tryDecryptNote(
{ commitmentHex, leafIndex, encryptedMemo },
viewingSecretKey,
spendingKey,
ownOwnerPk,
);
tryDecryptNote performs all three steps. Use tryDecryptNoteVerbose when you need to know why a
candidate failed — wrong tag, MAC failure, or malformed plaintext.
Outgoing viewing keys: recovering what you sent
Everything above is the receiving side. The sending side has the opposite problem: a memo is sealed toward the recipient, so its own author cannot reopen it. Restore a wallet from its seed phrase and the incoming notes all come back — but the record of what you sent does not, because it was never anywhere except your old device.
The outgoing viewing key (ovk) closes that gap, and it does so without publishing anything
extra on chain. Instead of wrapping a key for itself, the sender makes its own ephemerals
predictable — derived from the ovk rather than drawn at random:
ephSk_i = SHA256("orbinum-outgoing-eph-v3" || ovk || u32le(i))
Every note has always carried its ephPk in the memo's last 32 bytes. Deriving that point from the
ovk turns a field that was already there into a marker only its author can predict. A restored
wallet regenerates the sequence from its seed, and any note whose ephPk matches one of them is a
note it sent.
Recomputing a commitment needs the note's value — the very thing being recovered. The ephPk
depends on neither the amount nor the blinding, so a restored sender recognises its own payments
without knowing in advance what it is looking for.
The ovk is derived from your master seed as a sibling of the incoming viewing key, not from
it. That keeps the two capabilities separately delegable: handing someone your ovk lets them
audit what you sent without letting them see what you received.
The index is a counter, and it must not repeat
i is a u32 the wallet advances per sent note. Reusing one republishes an ephPk, and two notes
sharing an ephemeral are publicly linked as coming from the same sender — the exact leak the
sequence exists to avoid. The counter is recoverable: a restore reconstructs how far it got by
scanning for the highest index it can match, so it never has to fall back to random.
The domain string is separate from the one selfEph uses, rather than a reserved range within it.
A shared domain with an offset looks equivalent but is not: a wallet that publishes more self-notes
than the offset walks into the outgoing range and starts republishing. Distinct domains are
disjoint at every index.
The recipient book: who you paid
The ephemeral identifies which notes you sent. Reading one still needs the recipient's viewing key, and that lives in vault state a restore wipes.
The channel for it already exists. Every transfer also produces a change note, sealed toward
your own viewing key with a selfEph ephemeral — so a restored wallet reads it from the seed
alone. The recipient's viewing key rides in that change note's sourcePk:
key = HKDF(ikm = ovk, salt = payment_commitment, info = "orbinum-recipient-book-v3")
sealed = recipient_ivk XOR keystream(key)
Those 32 bytes are free: sourcePk is metadata, never part of
Poseidon4(value, assetId, ownerPk, blinding) and never an input to the circuit. Nothing
additional is published on chain.
It is sealed rather than stored in the clear because a viewing key is meant to be handed out — to an auditor, to a watch-only device. In plaintext, every such disclosure would also hand over the payment graph, a far larger secret than the amounts an incoming viewing key reveals. Keying it on the payment's commitment (not the change note's) is what binds each entry to the transfer it describes.
The book rides in the change note, so a transfer that leaves no change has nowhere to put one. The
ephPk match still identifies the payment as yours; only the recipient's key is missing.
What it does not weaken
Predicting these points is the capability "see what I sent", which is why the sequence hangs off
the outgoing viewing key and not the incoming one or the spending key. The derived ephemerals are
PRF outputs — uniformly distributed and indistinguishable from random ephemerals to anyone without
the ovk — so a transfer sent by a wallet that recovers looks exactly like one sent by a wallet
that does not. The same argument carries selfEph and BIP-32.
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
- Building on Orbinum — the public client, payment slips, and address helpers
- The Wallet SDK —
tryDecryptNote,deriveViewTag,selectNotes