Skip to main content

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.

This is also your recovery path

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:

OffsetSizeField
016value — 128-bit little-endian
1632owner_pk — the note's stealth owner key
4832blinding
804asset_id
8432source_pk — a one-time key of the other party, or zero
1164circuit_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.

Paying someone back

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.

Legacy memos carry a random byte

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

Custody code — not publicly installable

@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.

Why match on the ephemeral, not the commitment

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.

A changeless transfer has no book entry

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.