diff --git a/docs/rfcs/2026-09-16-stealth-transfers.md b/docs/rfcs/2026-09-16-stealth-transfers.md index 533569e2fe..1c351097ce 100644 --- a/docs/rfcs/2026-09-16-stealth-transfers.md +++ b/docs/rfcs/2026-09-16-stealth-transfers.md @@ -12,8 +12,8 @@ alone, on a device with no chat database, because that is the only recovery [wallet keys](./2026-09-10-wallet-keys.md) leaves them. This adds the keys and the flow for both: sending a name to someone with no -handshake, receiving one without being bound to it, and rediscovering received -names from the phrase. Nothing here is implemented yet. The same mechanism +handshake, receiving one without being bound to it, and finding received names +again from the phrase. Nothing here is implemented yet. The same mechanism carries anything else an address can hold, which is why the keys sit where they do. @@ -30,7 +30,6 @@ can recognise: r random, R = r*G s = keccak256(x || y of r*P_view) the point, SEC1 prefix dropped destination = addr(P_spend + s*G) -view tag = s[0] ``` The recipient recovers the same point from the other side, and with it the key: @@ -41,20 +40,19 @@ their key for it = (p_spend + s') mod n ``` `Simplex.Messaging.Eth.Stealth` in the pinned simplexmq already implements this -(`metaAddress`, `stealthDestination`, `stealthMatch`, `stealthPrivateKey`), -with the encodings pinned to the EIP author's reference implementation and -tested against an independent one. It needs no build flag: the module -multiplies points directly rather than calling `secp256k1_ecdh`, whose built-in -hash is SHA-256 where ERC-5564 wants keccak256. Nothing new is needed one layer -down. +(`metaAddress`, `stealthDestination`, `stealthPrivateKey`), with the encodings +pinned to the EIP author's reference implementation and tested against an +independent one. It needs no build flag: the module multiplies points directly +rather than calling `secp256k1_ecdh`, whose built-in hash is SHA-256 where +ERC-5564 wants keccak256. Nothing new is needed one layer down. **The destination key is not at a derivation path.** It is the spending key tweaked by a scalar that only `R` and the viewing key produce. So the wallet has to hold a key as "spending key plus ephemeral public key" rather than as a -path, which is what the table in the schema section stores. That is also why -the stealth keys belong to a profile and not to a name: a meta-address is an -identity published once, while a name is a thing that arrives many times, each -on its own tweaked key. +path, which is what the table in the schema section stores and what the backup +carries. That is also why the stealth keys belong to a profile and not to a +name: a meta-address is an identity published once, while a name is a thing +that arrives many times, each on its own tweaked key. ## Derivation @@ -81,6 +79,16 @@ people hold assets at it. `0'` for spend and `1'` for view is BIP-352's convention, reused so that one mental model covers every chain. +The backup in its own section needs keys that are not secp256k1, so they do not +come from a BIP-32 path. They come from one secret: + +``` +backup secret = HKDF(bip39 seed, "SimpleX wallet backup"), 32 bytes +``` + +Everything the backup queue needs is derived from that secret, and nothing else +about it may ever change, for the same reason as a path. + **This reintroduces the profile account that wallet keys removed**, and for the opposite reason. Names were moved to a device counter because which profile owns a name is in the record, not in the key, so a profile dimension there @@ -88,15 +96,13 @@ carried a mapping nothing read. A meta-address is different: it is published per profile and it is what tells two profiles apart, so the dimension is load bearing. -**One meta-address per device is rejected.** It would collapse the recovery -scan to one pass, but the viewing key is published inside the meta-address, so -any contact who saw two of your profiles could link them. The same argument -rejects sharing one viewing key across accounts. +**One meta-address per device is rejected.** The viewing key is published +inside the meta-address, so any contact who saw two of your profiles could link +them. The same argument rejects sharing one viewing key across accounts. **Indices are allocated densely and never reused.** A profile takes one when it first publishes a meta-address, not when it is created, so the live set is -`1..N` with holes only where profiles were deleted. Dense allocation is what -makes the gap limit in the recovery section work. +`1..N` with holes only where profiles were deleted. ## What the meta-address discloses @@ -115,13 +121,12 @@ against the counterparty. And **the relayer sees the destination**, so unlinkability holds against chain analysis, not against SimpleX. Publication is opt-in per profile, and follows `contactDomain`: a field on -`Profile`, carried to group members through `redactedMemberProfile` and -**gated on `allowDirect` the same way**. That refines the earlier plan, which -passed it through -unconditionally so that gifting would work for people known only through a -group. Gating loses nothing: a member who cannot direct message you has no -channel to tell you the ephemeral key either, so the gift would be inert -anyway. +`Profile`, carried to group members through `redactedMemberProfile` and **gated +on `allowDirect` the same way**. That refines the earlier plan, which passed it +through unconditionally so that gifting would work for people known only +through a group. Gating loses nothing: a member who cannot direct message you +has no channel to tell you the ephemeral key either, and without that channel a +gift is unreachable, as the backup section says. **ERC-6538 is not used.** The on-chain meta-address registry buys nothing here. The profile already distributes the meta-address to exactly the people who can @@ -131,53 +136,49 @@ out of something with no reason to be public. ## Sending The transfer is the registrar's sponsored path, signed by the name's key and -submitted by the relayer, with the ephemeral key and view tag inside the signed -struct: +submitted by the relayer: ```solidity function transferWithSig(address from, address to, uint256 tokenId, - uint256 nonce, uint256 deadline, bytes calldata sig, - bytes calldata ephemeralPubKey, bytes1 viewTag) external; + uint256 nonce, uint256 deadline, bytes calldata sig) external; ``` ``` -TransferName(address from,address to,uint256 tokenId,bytes ephemeralPubKey,bytes1 viewTag,uint256 nonce,uint256 deadline) +TransferName(address from,address to,uint256 tokenId,uint256 nonce,uint256 deadline) ``` EIP-712 domain `SimplexNames`, version `1`, `verifyingContract` the registrar. -`ephemeralPubKey` is dynamic `bytes`, so the digest carries -`keccak256(ephemeralPubKey)`; `viewTag` is `bytes1`, left aligned. The -ephemeral key is the 33-byte compressed form, exactly what `stealthDestination` -returns. -Signing those two fields is what makes the announcement trustworthy. Outside -the signed struct a relayer could attach a fabricated derivation to a genuine -transfer, or suppress the real one. +**There is no announcement.** The registrar as written today carries the +ephemeral key and a view tag inside the signed struct and emits ERC-5564's +`Announcement`, so that a recipient restoring from the phrase alone can scan +the chain for gifts. That is replaced by the backup below, and the fields and +the event go before `.simplex` is deployed, which is possible because it is not +yet deployed. What that buys, beyond a smaller contract: a stealth transfer is +indistinguishable on chain from a plain one, so the chain does not even record +that a gift happened, and the service needs no scan endpoint. -An empty `ephemeralPubKey` emits no announcement, so a plain transfer to an -address the recipient gave you costs nothing extra and stays out of everyone's -scan. +`.testing` keeps whatever typehash it was deployed with, so the client still +keys the signed shape off the deployment it is addressing, not off its own +build. -Then the sender tells the recipient, in an ordinary chat message carrying the -name and `R`. The view tag does not travel, because the recipient recomputes -it. The encoding of that message belongs to the names protocol and is not -decided here. - -**The client keys the signed shape off the deployment it is addressing, not off -its own build.** `BaseRegistrarImplementation` is not upgradeable, so the old -and new typehashes cannot coexist in one deployment and cannot be migrated, and -the live `.testing` registrar keeps the old typehash and a bespoke event -permanently. That is a fork in the client, not a migration. +The sender tells the recipient in an ordinary chat message carrying the name +and `R`. **The message goes first.** It is handed to the agent, which persists +it, before the transfer is signed and submitted, so a sender that dies in +between leaves a message and no transfer, which is harmless, rather than a +transfer and no message, which would strand the name. The encoding of the +message belongs to the names protocol and is not decided here. ## Receiving **The message is not trusted.** The client derives the address itself from `R` and its own keys, and confirms on chain that the name is held there. A sender -cannot name a gift something it is not. +cannot name a gift something it is not, and a message whose transfer never +landed is dropped after a while. -Only then does the destination become a row, with `accepted_at` null. -**Nothing is claimed, displayed as yours, or attached to a profile until the -user accepts.** An unaccepted name is not yours. +Only then does the destination become a row, with `accepted_at` null. **Nothing +is claimed, displayed as yours, or attached to a profile until the user +accepts.** An unaccepted name is not yours. Declining deletes the row. It writes nothing on chain and leaves no trace, which is the whole point: acceptance is the act that creates the public link, @@ -199,52 +200,101 @@ against the node: the contracts no longer grant or count them, and A free gift is therefore not free to use, and the screen has to say so before the purchase sheet opens rather than after. -## Recovery from the phrase alone +## Backup -There is **no background scan and no scheduler**. A sender can only derive the -destination if they hold your meta-address, and that reached them over an -established connection, so the sender always has a channel by construction. -Discovery is a message, not a search. +The phrase carries entropy and nothing else. Which received names exist, and +which indices are taken, is knowledge the device has and the phrase does not. +The earlier plan put that knowledge on chain, as an announcement per gift, and +rebuilt it on restore by scanning. This puts it where SimpleX already keeps +data that has to outlive a device: the link data of a contact address queue. -The on-chain announcement exists for one case: a device restored from the -phrase with no chat database, where there is no message to read. It is -ERC-5564's `Announcement` verbatim, emitted by our registrar rather than the -canonical singleton announcer, so a scan covers SimpleX name transfers only -instead of every stealth transfer on the chain. That is what keeps it cheap. +**What a contact queue offers.** Its link data is two encrypted blobs, a fixed +one of 2 KB and a mutable one of 13.7 KB, read by `LGET` with no +authentication, addressed by a 24 byte link ID. The client supplies that ID and +the server does not check it (`Server.hs`, `createQueue`); it is the first 24 +bytes of an HKDF of the link key, and the encryption key is the rest +(`Crypto/ShortLink.hs`, `contactShortLinkKdf`). Writes go through `LSET` and +need one of the queue's recipient keys, which the client chooses at `NEW`. And +nothing on the server expires a contact queue or its link data: there is no +such config key, no sweep, no deletion by inactivity. A queue goes when its +owner sends `DEL` or an operator removes it. -```solidity -event Announcement(uint256 indexed schemeId, address indexed stealthAddress, - address indexed caller, bytes ephemeralPubKey, bytes metadata); +**The queue is derived from the backup secret.** The link key is `sha3_256` of +the fixed data, and a reader checks that, so the fixed data has to come out +byte for byte the same on every device that holds the seed. Everything in it +comes from the backup secret or is a constant: the root signing key, the e2e +key, the nonce that fixes the sender ID, the server, the entity ID, and version +ranges pinned once and never following the client's. That gives the link ID and +the decryption key. Writing needs more: the queue's recipient key and DH key, +derived from the same secret, and the recipient ID, which the server assigns +and the blob therefore carries. With that, a device holding nothing but the +phrase derives the link ID, reads the blob, decrypts it, and can write it +again. + +**Where it lives.** One queue on a server of each preset operator +(`Simplex.Chat.Operators.Presets`, SimpleX Chat and Flux today), under an agent +user of its own, so the session it is written from shares nothing with any +profile. Restore reads every preset server of both operators and takes the +newest blob. + +**What is in the blob**, in the order a restore applies it: + +``` +version a counter, newest wins +recipient id this queue's, on this server +next_name_index the device's names high-water mark +next_account_index the profile accounts high-water mark +accounts: index, display name which account was which profile +received: account index, chain, R, accepted ``` -`metadata` is `viewTag || 0x23b872dd || registrar || tokenId`, so a match -learns what it was sent without a second lookup. **`caller` is the relayer, not -the gifter**, and a scanner must not read it as the sender. +Which name sits at a destination is not in it: that is on chain and the +registrar is enumerable, and storing it would trust the sender's word. About +forty bytes per received name leaves room for several hundred. Beyond that a +second link key is derived the same way with an index appended, and never +reused for anything else. -A scan runs on seed import and behind an explicit action, never otherwise. Per -account it costs one point multiplication and one hash per announcement, and -the view tag discards about 255 in 256 before the point addition. The viewing -key never leaves the device: the service serves announcement ranges and the -client does the arithmetic, so no delegated-scanning privacy trade arises. +**When it is written.** On every change to any of the above, to both servers. +The blob is padded to its fixed size, so the length never says how much is in +it. -Profile accounts are walked `i = 1, 2, 3, ...` until ten consecutive accounts -match nothing, the BIP-44 gap limit idiom, with a "scan further accounts" -action to extend it. The walk needs no profile to exist, because each -candidate's viewing key comes from the seed. For stealth the scan is its own -probe, so an account that only ever received is found rather than missed. It is -still missed if it sits behind ten accounts that received nothing, which is -what the extend action is for. +**What an operator sees.** A link ID nobody else ever uses, read and written at +some times. It cannot tell whose it is, what is in it, or that it is a wallet +at all. It can delete it, and it can serve an old version, which the counter +makes visible and a second operator makes survivable. -The registrar's `TransferToSelf` revert is what keeps this bounded. Without it -an owner could self-transfer in a loop with a fresh ephemeral key for the price -of gas and inflate every recipient's scan without limit. +**What this rests on**, said plainly: availability is now the operators', not +Ethereum's. No code, config or policy promises to keep a queue for years; the +server's own design notes contemplate expiring contact queues after about three +years of inactivity, unimplemented today. A live client touches its backup on +every change, and reading it also counts as activity on the server, so a device +that is in use keeps it alive. A device that is dead for years relies on the +operators having kept it. That is the trade for having no public marker per +gift, no scan, and a restore that also brings back which account was which +profile, which no scan could. -Two gaps remain, both benign. The transfer and the message are not atomic, so a -sender can submit and crash before sending. And a contact can forward your -meta-address to someone you have no channel with, who can then send without -being able to tell you. In both cases nothing is lost, because the name sits at -an address the seed already controls and a rescan finds it, and nothing is -attached without consent. +**When the backup is gone**, two things still work. Names bought on this device +are found by the probe that wallet keys deferred to the registrar: `balanceOf` +over `k = 1, 2, 3, ...` until ten in a row own nothing, which also sets +`next_name_index`. And received names come back from their senders, next. + +## Senders keep what they sent + +The sender always knows `R`. So the sender keeps every gift it sent, keyed by +the verified name of the contact it went to, and when a contact verified under +that name connects again, it sends them all again. A restored recipient is a +new contact, not the old row, which is why the key is the name and not the +contact. The recipient verifies each one as it would any message, and ignores +what it already has or cannot use. + +This costs nothing and covers the case the backup cannot: a device dead long +enough for the operators to have dropped the queue. It reaches only recipients +who own a bought name, because that is what lets a contact find them again, and +only gifts whose sender is still around. It is a supplement, not a path. + +A gift sent to a forwarded meta-address, by someone with no channel to the +recipient, is unsupported. It is not lost, because the address is one the seed +controls, but nothing will tell the recipient it is there. ## Other assets @@ -262,8 +312,8 @@ m/44'/128'/i'/0' Monero, then SHA3 and sc_reduce32 to spend and view ``` Nothing is designed for them here and no storage anticipates them beyond the -`chain` column below. What is being fixed is the path layout, because that is -the one thing later work cannot change. +`chain` column below and the chain tag in the blob. What is being fixed is the +path layout, because that is the one thing later work cannot change. ## Schema @@ -272,22 +322,20 @@ Postgres lists. ```sql -- High-water mark for profile accounts. It cannot be read off users: after a --- phrase-only restore that table is empty while accounts already hold keys, so --- the next profile would republish a recovered account's meta-address. The scan --- sets this, and new profiles allocate above it. +-- restore that table is empty while accounts already hold keys, so the next +-- profile would republish a recovered account's meta-address. The backup +-- restores it, and new profiles allocate above it. ALTER TABLE wallet_seeds ADD COLUMN next_account_index INTEGER NOT NULL DEFAULT 1; - --- Resume point of the last scan, so a repeat scan is not a rescan from zero. --- Not a live watermark: normal discovery is a message. -ALTER TABLE wallet_seeds ADD COLUMN scanned_to TEXT; +-- The blob's counter as last written, so a restore knows what is newer. +ALTER TABLE wallet_seeds ADD COLUMN backup_version INTEGER NOT NULL DEFAULT 0; ALTER TABLE users ADD COLUMN wallet_account_index INTEGER; ALTER TABLE contact_profiles ADD COLUMN stealth_meta_address BLOB; --- Destinations learned from a message or rediscovered by a scan. The chain is --- carried from the start so that BTC and XMR need no migration, only rows with --- a different value. +-- Destinations learned from a message or restored from the backup. The chain +-- is carried from the start so that BTC and XMR need no migration, only rows +-- with a different value. CREATE TABLE wallet_destinations ( wallet_destination_id INTEGER PRIMARY KEY AUTOINCREMENT, wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE RESTRICT, @@ -302,17 +350,27 @@ CREATE TABLE wallet_destinations ( CREATE INDEX idx_wallet_destinations_account ON wallet_destinations(wallet_seed_id, account_index); + +-- What this device sent, so it can send it again. Keyed by the recipient's +-- verified name, because that is what a restored recipient is found by. +CREATE TABLE wallet_sent ( + wallet_sent_id INTEGER PRIMARY KEY AUTOINCREMENT, + contact_domain TEXT NOT NULL, + chain TEXT NOT NULL, + ephemeral_pub_key BLOB NOT NULL, + sent_at TEXT NOT NULL +) STRICT; ``` **Destinations hang off the seed and an account index, not off a user.** The -earlier plan keyed them on `users`, which cannot hold what a scan finds: after -a phrase-only restore an account has keys and received names while no profile -exists yet. `users.wallet_account_index` is the join, so adopting a recovered -account for a new profile attaches its destinations with no row rewriting. -`scanned_to` moves onto the seed for the same reason. +earlier plan keyed them on `users`, which cannot hold what a restore brings +back: an account has keys and received names while no profile exists yet. +`users.wallet_account_index` is the join, so adopting a recovered account for a +new profile attaches its destinations with no row rewriting. -No private key is stored. `ephemeral_pub_key` is what re-derives one from the -seed, so the table is a cache: losing it costs a rescan, not an asset. +No private key is stored, here or in the blob. `ephemeral_pub_key` is what +re-derives one from the seed, so the table is a cache: losing it costs a +restore, not an asset. `ON DELETE RESTRICT` changes what `/_wallet delete` does: it starts failing once a destination exists, rather than destroying control of the names at them. @@ -325,9 +383,9 @@ was received and is the only copy. For a profile of your own it is derivable from the seed and the account index, and stored only so that one column serves both. -**Which name is at a destination is not stored**, because it is on chain and -the registrar is enumerable. Storing it would mean trusting the sender's word -for it. +The backup queue itself is an ordinary connection in the agent database. What +is new is that its keys are derived rather than drawn, which is the dependency +named under scope. ## Commands @@ -340,13 +398,15 @@ it returns, so stealth follows that shape. /_wallet meta off stop publishing it /_wallet send derive a destination and sign the transfer /_wallet received destinations for this profile's account -/_wallet scan rediscover destinations from announcements +/_wallet backup write the blob now, and say where it went +/_wallet restore read the blob and apply it /_wallet export stealth one destination's secret, 0x and 64 hex digits ``` Reading never allocates. `meta on` is the opt-in and the only thing that takes an account index, matching the rule that a seed is never created as a side -effect of reading. +effect of reading. `backup` exists so the write can be driven and checked; in +use the write is automatic. `export stealth` returns an ordinary secp256k1 key that MetaMask imports, and it discloses that one address and nothing else: not the seed, not the other @@ -363,44 +423,55 @@ feature. This is a positive check, not an omission to rely on. Hidden profiles are as wallet keys left them: they should not register a name, and they should not publish a meta-address either. The seed is the device's, so whoever unlocks any profile can derive every profile's stealth keys, hidden -ones included. +ones included, and the backup lists every profile's account by name. ## What the phrase does not carry -The phrase carries entropy. Which profile accounts were taken is not in it, and -`next_account_index` starts at 1 after an import, so until a scan raises it a -new profile would publish a meta-address that a recovered account already -published. Nothing can act on the stale mark until publishing exists, and the -scan lands with it. +The phrase carries entropy. The backup carries the rest, and the phrase is what +finds the backup. With both, a restore is complete: indices, accounts, which +profile each was, and every received name, whether or not its message ever +arrived. -Which profile an account was is gone with the chat database, and what an -account received is the only thing left that identifies it. Names bought on -this device do not help: they are counted per device and hang off no account at -all. +With the phrase alone, bought names come back by the probe and received names +only from their senders. Which profile an account was is gone. ## Risks - Stealth does not protect against the sender, who can prove the derivation. -- Unlinkability is against chain observers, not against SimpleX: the relayer sees - destinations and later relays intents from them. -- Anyone who can direct message you can send you a name. Spam surface, bounded by - the sender paying to register. +- Unlinkability is against chain observers, not against SimpleX: the relayer + sees destinations and later relays intents from them. +- The backup's availability rests on the preset operators. Nothing promises + years, and a device dead long enough loses it. Two operators and the senders + are the mitigations, not a guarantee. +- The backup is a map of everything the seed controls, in one place. It is + encrypted under a key only the seed produces and padded to a fixed size, so + what is exposed is that a blob exists and when it changes. +- Anyone who can direct message you can send you a name. Spam surface, bounded + by the sender paying to register. - Accepting a gift costs money, so a free gift is not free to use. -- Received names need our software or a reimplementation: they sit at addresses - that are at no derivation path, announced by our own registrar, so no - third-party wallet finds them from a phrase. `export stealth` keeps the - per-name guarantee true, but one address at a time. -- A crash between transfer and message loses the notification, though not the - name. The recipient hears nothing until the sender resends or a scan runs. -- The derivation paths cannot be changed after anyone holds an asset at one. +- Received names need our software or a reimplementation: they sit at + addresses that are at no derivation path, findable only through the backup + or the sender, so no third-party wallet finds them from a phrase. + `export stealth` keeps the per-name guarantee true, but one address at a + time. +- The registrar changes shape before deployment. What is built and tested + today carries the announcement; removing it is work in the contracts, and + `.simplex` must not deploy with it in. +- The derivation paths and the backup secret cannot be changed after anyone + holds an asset at one. ## Scope Here: the stealth keys and their paths, the profile account, meta-address -publication, the send and receive flows, the recovery scan, and the storage all -of that needs. +publication, the send and receive flows, the backup and what it carries, the +senders' re-send, and the storage all of that needs. Not here: buying a name, the names protocol and the encoding of the message -that carries the ephemeral key, the registrar and relayer, the service that -serves announcement ranges, the top-up purchase, any UI, several seeds per +that carries the ephemeral key, the registrar and relayer, the contract change +that removes the announcement, the top-up purchase, any UI, several seeds per device, a profile's own addresses, Bitcoin, Monero and ERC-20. + +Two changes in simplexmq are a dependency and are not designed here: a way to +create a contact link from supplied keys and nonce rather than from the agent's +random generator (`prepareConnectionLink'` draws both), and a way to register a +queue that already exists on the server, for the device that restores.