Skip to main content

Note Discovery

Commitments on-chain are opaque hashes that say nothing about who owns what. A wallet finds its notes through the encrypted memo attached to every commitment, which only the recipient can open.


The encrypted memo​

Every operation that creates a note publishes a 180-byte memo beside its commitment. The chain only checks the size; the split of those 180 bytes (nonce, ciphertext, MAC, ephemeral public key) is described in Notes. Inside is a 120-byte plaintext:

OffsetSizeField
016value — 128-bit little-endian
1632owner_pk — the note's one-time stealth owner key
4832blinding — or, when the payment names its sender by address, that sender's viewing-key y coordinate
804asset_id
8432source_pk — how the sender chose to be named; on a change note, a sealed recipient-book entry
1164circuit_version — the circuit version the note is spent under

These fields are everything needed to rebuild and spend the note; the wallet keeps a note only if the commitment it recomputes from them matches the one on-chain.

Encryption​

Sealing is an ECDH over Baby JubJub followed by ChaCha20-Poly1305:

ephPk        = ephSk · Base8                         ← published in the memo
sharedSecret = (ephSk · ivk).x = (ivsk · ephPk).x ← sender's view = recipient's view
key = SHA256(sharedSecret || commitment || "orbinum-note-encryption-v1")

ivk comes from the recipient's privacy address. The key is bound to the commitment, so a memo moved onto another note does not open.

The ephemeral scalar ephSk comes from one of three sources, all indistinguishable on-chain:

SourceUsed for
SHA256("orbinum-self-eph-v3" ‖ ivsk ‖ u32le(i))Notes to yourself: shields, change, self-transfers
SHA256("orbinum-outgoing-eph-v3" ‖ ovk ‖ u32le(i))Payments to someone else — see Outgoing viewing key
RandomPayments sent before the counters are reconciled after an unlock

View tags​

The first byte of each memo's nonce is a view tag derived from the shared secret:

nonce[0] = SHA256("orbinum-view-tag-v1" || sharedSecret)[0]

A scanning wallet computes the same byte and skips AEAD decryption on a mismatch, discarding 255 of every 256 foreign notes for one hash. Without the viewing key the byte is uniform noise.


Stealth addresses​

A note sent to you does not carry your published owner_pk. Each one is committed to a fresh owner key derived from the shared secret:

stealthScalar = HKDF-SHA256(ikm = sharedSecret, salt = owner_pk (LE), info = "orbinum-stealth-v1") mod l
owner_pk_note = (stealthScalar · Base8 + owner_pk_point).x

The recipient spends it with (stealthScalar + spending_key) mod l. Two notes paid to the same person carry unrelated owner keys, neither linked to the published address — which is also why a disclosure key proves one note's contents, not who owns a set of notes.


Sender identity​

What source_pk is — and is not​

source_pk does not identify the sender by default: for an anonymous payment it is zero. Naming the sender is the sender's choice per payment, under Show the recipient who paid. Anonymous is the default because a durable sender identifier links every payment that carries it, and disclosing one such note would expose the sender without consent.

The top two bits of the 32 bytes (bits 254–255, little-endian) tag the form:

TagModeThe 32 bytesWhat the recipient learns
00Anonymous (default)ZeroNothing about the sender
01Pseudonym1–24 ASCII letters or digits in bytes 0–23, zero paddedA name the sender typed — unverified
1sAddressBits 0–253: the sender's owner_pk. s: the sign bit of the sender's packed viewing key, whose y coordinate rides in the memo's blinding fieldThe sender's full orbpriv3 address, verified

A wallet reads the field strictly: any value not in one of these canonical forms reads as anonymous.

Authenticating an address without a signature​

An orbpriv3 address is 64 bytes and the memo is a fixed 180, so there is no room for an address and a signature. An address-mode payment therefore does not transmit its blinding — both sides derive it, which frees the blinding field for the sender's viewing key:

blinding = HKDF-SHA256(
ikm = sharedSecret
|| ECDH(sender ivsk, recipient ivk) ← binds the viewing key
|| ECDH(sender spending_key, recipient ivk), ← binds owner_pk
info = "orbinum-sender-identity-v1" || owner_pk || viewing key
) 48 bytes, mod r

The recipient reaches the same static secrets from its side — ECDH(own ivsk, claimed viewing key) and ECDH(own ivsk, claimed owner_pk point) — derives the blinding and recomputes the commitment. A match is the verification; a forged claim does not match, so the note never opens as that sender. Naming an address takes both of its secrets, so someone holding only its viewing key cannot claim it. Outsiders cannot compute the blinding, which also depends on the ephemeral shared secret.

Limits of sender identity
  • A pseudonym is unverified. It is whatever the sender typed; two senders can use the same one.
  • An address links. Every payment that names a sender by address is linked, for that recipient, to the same identity.
  • Reopening an address-mode payment from the sender's side takes the spending key. Viewing keys alone do not rebuild its blinding.

On a change note — the output a transfer returns to its sender — source_pk holds a sealed recipient-book entry instead, whatever mode the payment used. A wallet never reads a change note's source_pk as a sender.


How the wallet finds notes​

Notes the wallet creates for itself — shields, change and self-transfers — are placed in the tree when their transaction lands: the wallet reads the landing block's insert events for their leaf indices, so they never have to be searched for. While the vault is unlocked, a sync every two minutes completes the Merkle data of each note from the indexer's leaf windows, and places any note that landed without a position from the block it kept. A note with no known position cannot be spent; the app points to Recover Notes.

Notes others send you are found by scanning, which runs only when you ask: Sync scans from where the last scan stopped, and Recover Notes scans the whole pool from the start. A full scan saves its cursor as it goes, so one cut short resumes from it instead of starting again. A payment slip imports one received note without a scan.

The scan feed​

The wallet reads whole feeds from the indexer and does all matching locally:

FeedWhat the wallet does with it
Commitments and memos (sealed chunks, then a tail)Trial-decrypts every entry. Each entry's origin (shield, transfer, or unshield change) is recorded on a found note
Spent nullifiers (sealed chunks, then a tail)Intersects the whole set with its own nullifiers to mark spent notes
Leaf windows (1024 leaves each)Completes its notes' Merkle data for a spend

For each new commitment the wallet checks the view tag, decrypts only the survivors, rebuilds the note and recomputes its commitment. A candidate fails on a wrong tag, a MAC failure, a malformed plaintext or a commitment mismatch.

The feed is untrusted: the cursor advances only over leaves seen without a gap, sealed chunks must be contiguous and complete, and only a full scan whose spent set resolved completely clears a spent flag or removes notes. A full scan that was cut short and resumed removes none: deciding that a stored note is not on chain takes one pass that saw the whole pool.

Proving a found note is in the tree​

Opening a memo proves a note was addressed to you, not that the chain inserted it. A found note is not counted or spent until its Merkle data folds to a root the node serves, a check that reveals nothing about which note it is. See Merkle Forest.

Sent history is rebuilt locally

Finding notes, their spent state and the sent history all read only whole feeds. Each note carries the hash of the transaction that created it and of the one that spent it, so the wallet groups a transfer's inputs and outputs itself; no request names its notes.


Outgoing viewing keys: recovering what you sent​

A payment's memo is sealed toward its recipient, so its author cannot open it with the incoming key. The outgoing viewing key (ovk) recovers what you sent without publishing anything extra on-chain: instead of drawing a random ephemeral, the wallet derives it from the ovk and a counter:

ephSk_i = SHA256("orbinum-outgoing-eph-v3" || ovk || u32le(i))

A restored wallet regenerates the sequence and recognises any memo whose ephPk matches as one it sent — without knowing the amount in advance, which is what it recovers. The derived ephemerals are PRF outputs, indistinguishable from random ones without the ovk.

The counter must never repeat: two notes sharing an ephemeral are publicly linked. A scan recovers it as the highest index it can match, and until a scan has re-read the sequences after an unlock, new payments take a random ephemeral instead.

The recipient book​

The ephemeral identifies which notes you sent; reading one still needs the recipient's viewing key. Every transfer also produces a change note sealed to yourself, and its source_pk carries that key, sealed under the ovk:

key    = HKDF(ikm = ovk, salt = payment_commitment, info = "orbinum-recipient-book-v3")
sealed = recipient_ivk XOR keystream(key)

source_pk is not part of the commitment or the circuit, so nothing extra is published; sealing it means an incoming viewing key does not hand over the payment graph. An exact-amount transfer still has a zero-value change note carrying the entry.

For a payment sent with a random ephemeral the book is the only record: the scan pairs each change note with the payment output beside it, and accepts the pair only for an extrinsic that spent the wallet's own nullifiers and inserted both notes, so a feed cannot make the wallet claim a payment it did not send.

From these two sources a recovered wallet rebuilds its sent history — recipient, amount and fee — and re-issues the payment slips for those transfers. It matches them to its spends by transaction hash, from data it already holds, so the rebuild sends nothing.