docs: cut the rfc to what stands on its own

Drops the references to other documents and to what is or is not in a pull
request, so the design reads without them, and the list of tests, which
restated their names and would drift. 164 lines to 134, against a median of
117 for the rfcs here that define schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
This commit is contained in:
Alain Brenzikofer
2026-09-11 06:51:10 +00:00
co-authored by Claude Opus 5
parent f45647e113
commit 044e0fc0d6
+63 -93
View File
@@ -2,20 +2,17 @@
## Problem
A name bought in the app has to be owned by an address, and the client has to be
able to derive that address again after a restart, after a database restore, or
on a new device. Without that, a bought name is lost the moment the device is.
A name bought in the app has to be owned by an address, and the client has to
derive that address again after a restart, after a database restore, or on a new
device. Otherwise the name is lost with the device.
Buying a name is not in this PR. The key that will own it is, so that the key
material can be reviewed and merged on its own, ahead of the names protocol, the
registrar and signing.
The wider design is `2026-08-18-in-app-name-purchase-mvp.md`, which lands with
the names work. This document covers only what ships here.
Buying is not here. The key lands first, so the key material can be reviewed on
its own, before the names protocol, the registrar and signing.
## Design
One BIP-39 seed per device, one BIP-44 account per chat profile, one key per name.
One BIP-39 seed per device, one BIP-44 account per chat profile, one key per
name.
```
seed (BIP-39)
@@ -23,9 +20,9 @@ seed (BIP-39)
└── m/44'/60'/i'/0/k one key per name; k = 0 is the profile's first
```
Nothing here is a custom layout: `account` and `address_index` are what BIP-44
has those levels for, so the addresses line up with wallets people already use.
The tests pin that against the standard `abandon ... about` mnemonic:
This is plain BIP-44: `account` and `address_index` are what those levels are
for, so the addresses match wallets people already use. The tests pin that
against the standard `abandon ... about` mnemonic:
```
m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 MetaMask account 1
@@ -34,49 +31,45 @@ m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 Ledger Live acco
```
So profile 0's names are MetaMask's account list in order, and each profile's
first name is the matching Ledger Live account. Both exports are importable
there: the mnemonic as a recovery phrase, a single secret as a private key.
first name is the matching Ledger Live account. The mnemonic imports there as a
recovery phrase, a single secret as a private key.
**Why not one key per profile.** Exporting it would hand over every name that
profile owns, and the resolver keeps one nonce per signer, so a shared key would
serialise every name's record edits behind one counter. An index per name avoids
both. A name's secret is a leaf, with no chain code, so exporting it hands over
that name and nothing else.
profile owns, and the resolver keeps one nonce per signer, so one key would
serialise every name's record edits behind one counter. A name's secret is a
leaf, with no chain code, so exporting it hands over that name only.
**Why the account level is not hashed from the profile.** An index derived from
the display name would survive a restore in any profile order, and would remove
the index gaps that disclose a hidden profile. It would also break when a profile
is renamed, and it would take profile accounts off the list MetaMask and Ledger
Live enumerate, which is the compatibility the layout is for. The account stays a
counter, and a profile that needs a specific one asks for it.
**Why the account is a counter and not a hash of the profile.** A hashed index
would survive a restore in any profile order, and would leave no gap to disclose
a hidden profile. It would also change when a profile is renamed, and it would
move profile accounts off the list MetaMask and Ledger Live enumerate, which is
the point of the layout.
Purpose `5564'` is where stealth addresses will attach, at the profile level.
This PR uses purpose `44'` only, so the two do not meet.
Stealth addresses will attach at purpose `5564'`, at the profile level. Only
purpose `44'` is used here.
## Commands
API only. Nothing here is user facing: the names commands will call these, and
the user-facing surface is `/name keys ...` in the wider design.
Internal API. The names commands will call these; users will not.
```
/_wallet the first two name addresses of this profile's
account, and the other profiles on the same seed,
by name
/_wallet create generate the seed. Refused if the device has one
/_wallet import <phrase> store a seed from a phrase. Refused if the device has one
/_wallet bind take the next free account
/_wallet bind <account> claim one account. Refused if another profile holds it
/_wallet export the seed mnemonic
/_wallet this profile's first two name addresses, and
the other profiles on the seed, by name
/_wallet create generate the seed. Refused if the device has one
/_wallet import <phrase> store a seed. Refused if the device has one
/_wallet bind take the next free account
/_wallet bind <account> claim one. Refused if another profile holds it
/_wallet export the seed mnemonic
/_wallet export <account> <name> one derived secret, as 0x and 64 hex digits
/_wallet delete delete the seed, unbinding every profile
/_wallet delete delete the seed, unbinding every profile
```
Creating the seed and claiming an account are separate commands. `create` and
`import` bind no profile, so no profile is ever put on an account it did not ask
for. Neither is done at startup or as a side effect of reading.
Creating the seed and claiming an account are separate. `create` and `import`
bind no profile, so no profile is put on an account it did not ask for. Neither
runs at startup or as a side effect of reading.
Wallet commands are not forwarded to a remote host: the recovery phrase must not
leave the device, and the raw command would be logged there.
None of them is forwarded to a remote host: the recovery phrase must not leave
the device, and the raw command would be logged there.
## Schema
@@ -93,72 +86,49 @@ ALTER TABLE users ADD COLUMN wallet_account_index INTEGER;
`next_account_index` is a high-water mark, deliberately not
`MAX(users.wallet_account_index)`. After a restore from the phrase alone that
column is empty while accounts already hold names, so a new profile would
silently reuse a recovered account's keys.
column is empty while accounts already hold names, so a new profile would reuse
a recovered account's keys.
The table models several seeds because the follow-up needs them. One per device
is `single_seed` plus a unique index on it, which multi-seed drops with a
`DROP INDEX` and a `DROP COLUMN`. It is a named index rather than an inline
`UNIQUE` for exactly that reason: SQLite cannot drop a `UNIQUE` column or its
automatic index, so the inline form would have forced a table rebuild.
The table models several seeds, which a later change needs. One per device is
`single_seed` and a unique index on it, lifted later by a `DROP INDEX` and a
`DROP COLUMN`. It is a named index rather than an inline `UNIQUE` because SQLite
cannot drop a `UNIQUE` column or its automatic index, which would force a table
rebuild.
The migration is `20260908_wallet_seeds`, not the prototype's `20260818`.
Migrations are sorted by name and the runner requires the ones a database has
applied to be a prefix of the list, so a name sorting before `20260822_forward_link`
would give every existing database a state error. The follow-up migrations need
renaming past `20260908` for the same reason.
Migrations are sorted by name, and the runner requires the ones a database has
applied to be a prefix of that list. A later wallet migration has to sort after
`20260908_wallet_seeds`, or every existing database fails to start.
## What the phrase does not carry
A phrase carries entropy and nothing else. Two things are not in it, not on
chain, and not derivable.
**Which profile held which account.** A chat database backed up after the seed
carries the binding, and restoring it is the whole story. A backup older than the
seed comes back with the profiles and no binding, and nothing says which profile
was account 0. `/_wallet bind <account>` is how the user says so, and the counter
moves past what is claimed by hand.
**Which profile held which account.** A database backed up after the seed
carries the binding. One backed up before it comes back with the profiles and no
binding, and nothing records which profile was account 0. `/_wallet bind
<account>` is how the user states it, and the counter moves past what is claimed.
**Which accounts are already taken.** `next_account_index` starts at 0 after an
import, so `/_wallet bind` with no account can hand out one that already owns
names. Only a scan of owned names can restore the mark, and that lands with the
registrar.
**Which accounts are taken.** `next_account_index` starts at 0 after an import,
so `/_wallet bind` with no account can hand out one that already owns names.
Only a scan of owned names can restore the mark.
## Hidden profiles
`/_wallet` names the other profiles on the seed and never numbers them, so a
hidden profile leaves no gap in a list of account indexes.
That hides its existence from the listing, and nothing more. The seed is one per
device, so whoever unlocks any profile can export the phrase and derive every
account, including a hidden profile's. A hidden profile's names are not
pseudonymous against someone who already holds the device and one password. This
is a consequence of one seed per device, and a key per profile rather than per
device is what would change it.
That hides it from the listing and nothing more. The seed is one per device, so
whoever unlocks any profile can export the phrase and derive every account,
including a hidden profile's. Hiding a profile does not make its names
pseudonymous against someone holding the device and one password. A key per
profile rather than per device is what would change that.
## Scope
Not here, and unchanged from the prototype: buying a name, the names protocol,
the registrar, signing, `wallet_name_keys`, the recovery scan, several seeds per
device, stealth addresses.
Not here: buying a name, the names protocol, the registrar, signing, the
recovery scan, several seeds per device, stealth addresses.
`/_wallet` shows the first two addresses of the active profile's account, which
is enough to check the derivation against another wallet. Once `wallet_name_keys`
exists it will show the names actually held instead.
## What is verified
Each of these is a test, not a claim.
- The three addresses and the private key above reproduce from the standard
mnemonic, so a name bought here is reachable from any BIP-44 wallet.
- A secret whose first byte is zero keeps its 64 hex digits.
- Nothing is created until asked for, and a second create or import is refused.
- A second profile gets its own account, and its addresses do not intersect the
first profile's.
- The key and the addresses survive a restart.
- A database backed up after the seed needs no import; one backed up before it is
rebound by index and every address comes back; a seed imported before the
restore is replaced by what the backup held.
- A profile binds once, and only to an account BIP-32 can harden.
- An account index at or above 2^31 is refused rather than folded onto a low one.
`/_wallet` shows the first two addresses of the active profile's account, enough
to check the derivation against another wallet. It will show the names actually
held once those are recorded.