Skip to Content
ConceptsNote Delivery

Note Delivery

A note created for you is just a leaf in a Merkle tree. Nothing about it says “this belongs to that address.” So two problems need solving: how a sender finds out what to build the note against, and how you find out it exists.

OwnerKeyRegistry — finding a recipient

Building a note for someone requires their public ownerKey. A wallet address alone doesn’t reveal it, since deriving it needs that wallet’s own signature.

OwnerKeyRegistry is a small standalone contract where a wallet publishes its ownerKey once:

function register(uint256 ownerKey) external; function isRegistered(address account) external view returns (bool); mapping(address => uint256) public ownerKeyOf;

pay looks the recipient up here before building the output commitment. If a recipient hasn’t registered, the app refuses rather than inserting an unspendable leaf.

The contract is deliberately standalone — ShieldedVault never reads it. It is a lookup helper for callers building proof inputs off-chain. There is also no removal or rotation function: a wallet can always overwrite its own entry with a fresh ownerKey, and the contract never has to reason about which one is “current” beyond the latest value on file.

See the Pay guide for the user-facing flow.

StealthAnnouncer — discovering an incoming note

Once a note exists on-chain, its recipient still needs the private values — the blinding in particular — to ever spend it.

StealthAnnouncer is an on-chain announcement log following the EIP-5564 Announcement event shape:

event Announcement( uint256 indexed schemeId, address indexed stealthAddress, address indexed caller, bytes ephemeralPubKey, bytes metadata );

Anyone may announce. Umbra uses two scheme IDs of its own:

Scheme IDCarries
2A pay-created or matched-proceeds note: assetId, amount, blinding, commitment
3A placed order or a partial fill’s residual order: assetIn, assetOut, amountIn, minAmountOut, blinding, commitment, originalAmountIn

Scheme ID 1 is left alone — it is reserved by the contract’s own documentation for real EIP-5564 secp256k1 announcements.

PrivacyKeyRegistry — encrypting metadata and tagging pay()

metadata is ECIES-encrypted (secp256k1 ECDH + AEAD), not sent in the clear. Each wallet publishes a compressed secp256k1 public key once via PrivacyKeyRegistry (derived from the same wallet signature spendingKey uses — see Shielded Notes — so this costs no extra signature prompt, only one extra published value, since unlike ownerKey a real ECDH key has to be a genuine curve point, not a hash):

function register(bytes calldata privacyKey) external; function isRegistered(address account) external view returns (bool); mapping(address => bytes) public privacyKeyOf;

For pay() specifically, the same ephemeral key used to encrypt also derives stealthAddress as a one-time tag (recipientPubKey + hash(sharedSecret)·G, address-encoded) instead of publishing the recipient’s real address — a recipient recognizes it’s theirs by recomputing the same derivation from their own private key and the event’s ephemeralPubKey, not by matching a literal address. Order/residual delivery on scheme 3 keeps stealthAddress as the trader’s own real address (encryption only, no tag) — that trader’s identity is already public via their own placeOrder transaction, so there’s no counterparty left to hide there.

Two honest limits on this. First, it degrades gracefully: an announcement to a counterparty who hasn’t published a privacy key yet (or on a network where PrivacyKeyRegistry isn’t deployed) falls back to the legacy plaintext-metadata, real-address form instead of failing. Second, for pay(), only the recipient’s address is hidden by the tag — the sender still submits announce() from their own wallet (this project’s caller isn’t relayed), so which address sent a private payment stays visible even though who received it doesn’t.

Residual orders come back to you automatically

When a partial fill leaves a residual, it is announced on scheme ID 3 and — since the matcher already holds every detail needed to keep it tradeable — re-listed on the order book immediately. You don’t have to notice and resubmit it.

Scanning

Your wallet reconstructs its notes by scanning the chain’s full event history from the vault’s deploy block, matching announcements and commitments against your derived keys. Results are cached in IndexedDB.

Scheme 3 (self-directed — stealthAddress is always the real address) can still be filtered by address in the getLogs call itself. Scheme 2 can’t, now that stealthAddress is a one-time tag rather than a literal address: the wallet fetches every scheme-2 announcement and checks each one locally by re-deriving the tag from its own private key — the same “scan everything, verify locally” shape the deposit/leaf scan already uses, just extended to this scan too.

That means clearing site data is recoverable — the scan rebuilds everything — but the first scan on a new device walks a lot of history and takes a moment.

Last updated on