names: derive one key per name at m/44'/60'/i'/0/k

This commit is contained in:
Alain Brenzikofer
2026-08-26 20:40:23 +02:00
parent 5fc7215f3b
commit 8a62fc49c1
13 changed files with 366 additions and 47 deletions
@@ -23,6 +23,75 @@ The client keeps no local names record.
chain, payment, stealth/gifting, GUIs, min-commitment-age enforcement,
tx-hash inclusion verification, anti-grief deposits.
## One key per name
A name is owned by a key derived at `m/44'/60'/i'/0/k` — the profile's BIP-44
account `i`, and the name at address index `k`. `k` is taken when the name is
registered, so a profile's second name lands on a different address.
```
seed (BIP-39)
└── profile account i
└── m/44'/60'/i'/0/k one key per name; k = 0 is the profile's first
```
Worked through, for a profile that buys two names and later imports a second
seed it had used in a dapp:
```
seed1 generated by the CLI
└── profile Alice = account 0
├── m/44'/60'/0'/0/0 0x69A6…2d32 owns alice.simplex
└── m/44'/60'/0'/0/1 0x4C1f…9Ab7 owns lizzy.simplex
seed2 imported later
└── m 0x1D07…4bE9 owns lucy.simplex (root, no derivation)
```
Nothing here is a custom layout: `account` and `address_index` are what BIP-44
has those levels for, and the addresses line up with wallets users already have.
Pinned in the tests against the standard `abandon … about` mnemonic:
```
m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 MetaMask account 1
m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 MetaMask account 2
m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 Ledger Live account 2
```
So **profile 0's names are exactly MetaMask's account list, in order**, and each
profile's first name is the matching Ledger Live account. Moving a single name
between this wallet and another one is therefore a derivation question already
answered — but the commands to do it (recovery-phrase import, single-name key
export) are follow-up work, not in this PR.
**Why not one key per profile.** Exporting it would hand over every name that
profile owns, and `SimplexResolver` keeps one nonce per signer shared across
every node it owns — so a shared key would serialise every name's record edits
behind one counter. Both go away with an index per name.
**Which key owns which name is not on chain and not derivable**, so it is
recorded locally (`wallet_name_keys`). The path is stored literally rather than
as indices, because a name found on an imported seed may sit on a layout that is
not ours — `lucy.simplex` above stays at the root and is re-derived from `"m"`.
### Where stealth addresses will attach
Not in this PR, but the layout has to leave room. A profile publishes **one
meta-address**: a spend key and a viewing key, both hardened under purpose
`5564'` at the profile level, `m/5564'/60'/i'/0'/0` and `m/5564'/60'/i'/1'/0`.
A sender derives a fresh destination from it without any handshake — shared
secret `s = keccak256(r · P_view)` for a random ephemeral `r`, destination
`addr(P_spend + s·G)` — and the recipient recomputes `s` from the sender's
ephemeral public key `R` as `keccak256(p_view · R)`, holding the name with
`p_spend + s`.
So a received name's key is **not at a derivation path**: it is the spend key
plus a scalar, recoverable from `R` rather than from an index. One meta-address
per profile therefore serves any number of received names, which is why the
meta-address sits at the profile level while owned names sit at the address
level.
## Why two RPCs for one call
`purchaseName` splits into **commit** then **reveal** so the registrar cannot
@@ -146,8 +215,8 @@ a user registering the same name again.
| Component | This PR | Extends to |
|---|---|---|
| **Wallet** | `Wallet.newSeed` + `deriveAccount` + `accountAddress`, already built. No signing. | `signIntent` (also built) unlocks edits/transfers with no shape change. |
| **Wallet storage** | the prototype's `wallet_seeds` migration and `Store.Wallets` verbatim: one seed per DB, one account index per profile, allocated from a stored high-water mark. | recovery import, `backed_up` reminder and the one-time-address table are already in the schema; they need code only. |
| **Wallet** | `Wallet.newSeed` + `deriveNameKey` / `deriveAtPath` + `accountAddress`. No signing. | `signIntent` (also built) unlocks edits/transfers with no shape change. |
| **Wallet storage** | `wallet_seeds` plus `wallet_name_keys`: one account index per profile, one address index per name, both from stored high-water marks. | recovery import, `backed_up` reminder and the one-time-address table are already in the schema; they need code only. |
| **RPC transport** | badges' `APISendServiceRequest` / `APISendServiceResponse`, unchanged. | shared. |
| **Service** | extend `BadgeService/Service.hs` `handleServiceRequest` to dispatch `NRCommit`/`NRReveal`; add an in-memory chain mock (a `TVar (Map name entry)`, like `Names.Service.Mock`). | swap the mock for a relayer to a deployed SNRC. |
| **Resolution** | the mock chain is the record: the name resolves there to owner and SimpleX link. No client-side names store. | a resolver read against a deployed SNRC; add a local cache only if a listing UX needs it. |