mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 17:58:47 +00:00
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:
co-authored by
Claude Opus 5
parent
f45647e113
commit
044e0fc0d6
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user