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 ID | Carries |
|---|---|
2 | A pay-created or matched-proceeds note: assetId, amount, blinding, commitment |
3 | A 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.