diff --git a/docs/rfcs/2026-08-18-in-app-name-purchase-mvp.md b/docs/rfcs/2026-08-18-in-app-name-purchase-mvp.md index a49bf7ff8d..62b89a0cf9 100644 --- a/docs/rfcs/2026-08-18-in-app-name-purchase-mvp.md +++ b/docs/rfcs/2026-08-18-in-app-name-purchase-mvp.md @@ -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. | diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 109bfb13b7..c6a948c2cb 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -162,6 +162,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection Simplex.Chat.Store.Postgres.Migrations.M20260731_user_badges Simplex.Chat.Store.Postgres.Migrations.M20260818_wallet_seeds + Simplex.Chat.Store.Postgres.Migrations.M20260826_wallet_name_keys else exposed-modules: Simplex.Chat.Archive @@ -334,6 +335,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection Simplex.Chat.Store.SQLite.Migrations.M20260731_user_badges Simplex.Chat.Store.SQLite.Migrations.M20260818_wallet_seeds + Simplex.Chat.Store.SQLite.Migrations.M20260826_wallet_name_keys other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0026f0c2cf..719eeab4f8 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -842,8 +842,8 @@ data ChatResponse | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact} | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} - | CRNameRegistered {user :: User, regName :: Text, regOwner :: Text, regExpiry :: UTCTime, regTxHash :: TxHash} - | CRNameAddress {user :: User, nameAddress :: Maybe Text} + | CRNameRegistered {user :: User, regName :: Text, regOwner :: Text, regPath :: Text, regExpiry :: UTCTime, regTxHash :: TxHash} + | CRNameAddress {user :: User, nameAddresses :: [(Text, Text, Text)]} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} @@ -1475,7 +1475,7 @@ data ChatErrorType | CESimplexDomainNotReady {simplexDomain :: SimplexDomain, simplexDomainError :: SimplexDomainError} -- | the names service refused the registration; @nameRegCode@ is the protocol -- error code as sent on the wire, e.g. @name_taken@ - | CENameRegistrationFailed {nameRegCode :: Text, nameRegMessage :: Maybe Text} + | CENameRegistrationFailed {nameRegCode :: Text, nameRegMessage :: Maybe Text, nameRegRetryAfter :: Maybe Word32} | CENotResolvedLocally -- a name or link is not a known chat in the local store and online resolution is off (PRMNever) | CEUnsupportedConnReq | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 20909a44b2..9402be3e13 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -60,8 +60,8 @@ import Simplex.Chat.Library.Subscriber import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) import Simplex.Chat.Names.Protocol -import Simplex.Chat.Store.Wallets (boundAccount, getOrCreateAccountRef) -import Simplex.Chat.Wallet (AccountRef (..), accountAddress, deriveAccount, newSeed) +import Simplex.Chat.Store.Wallets (boundAccount, getNameKeys, getOrCreateAccountRef, recordNameKey, takeNameIndex) +import Simplex.Chat.Wallet (AccountRef (..), WalletSeed (..), accountAddress, deriveAtPath, deriveNameKey, newSeed, renderNameKeyPath) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -1465,7 +1465,7 @@ processChatCommand cxt nm = \case resp <- either (const $ throwCmdError "invalid service response") pure $ J.eitherDecodeStrict' respData pure $ CRServiceResponse user resp APINameRegister sendTarget nm' sLink -> withUser $ \user -> do - owner <- deriveNameOwner user + (seedId, nameIx, acctIx, owner) <- deriveNameOwner user cReq <- resolveServiceTarget nm user sendTarget g <- asks random secret <- NameSecret <$> atomically (C.randomBytes 32 g) @@ -1477,7 +1477,7 @@ processChatCommand cxt nm = \case let req = NamesRequest currentNamesVersion c respData <- withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq Nothing Nothing (LB.toStrict $ J.encode req) either (const $ throwCmdError "invalid names response") pure (J.eitherDecodeStrict' respData) >>= \case - NRPError {nrCode, nrMessage} -> throwChatError $ CENameRegistrationFailed (textEncode nrCode) nrMessage + NRPError {nrCode, nrMessage, nrRetryAfter} -> throwChatError $ CENameRegistrationFailed (textEncode nrCode) nrMessage nrRetryAfter r -> pure r progress phase waitMs = toView $ CEvtNameRegistrationProgress user nm' phase waitMs progress NRPhaseCommitting Nothing @@ -1492,22 +1492,40 @@ processChatCommand cxt nm = \case NRPRegistered {nrExpiry, nrTxHash} -> pure (nrExpiry, nrTxHash) _ -> throwCmdError "unexpected reveal response" progress NRPhaseRegistered Nothing - pure $ CRNameRegistered user nm' (tshow owner) expiry' txHash' + -- Recorded only after the name is actually registered: an index consumed by + -- a failed attempt is simply skipped, which costs nothing, while recording + -- a name we do not own would make the list lie. + let path = renderNameKeyPath acctIx nameIx + withFastStore' $ \db -> recordNameKey db seedId path nm' + pure $ CRNameRegistered user nm' (tshow owner) path expiry' txHash' where -- The seed is created on first registration and persisted, so the owner -- address survives restart — a name whose key we cannot re-derive is lost. + -- + -- One key per name: the index is taken here, so a second registration by + -- the same profile lands on a different address rather than sharing one. deriveNameOwner user = do g <- asks random (seed, AccountRef {arIndex}) <- withFastStore' $ \db -> getOrCreateAccountRef db user (atomically $ newSeed MS256 g) - either (throwCmdError . ("wallet: " <>)) (pure . accountAddress) $ deriveAccount seed arIndex + nameIx <- withFastStore' $ \db -> takeNameIndex db user + acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed arIndex nameIx + pure (wsId seed, nameIx, arIndex, accountAddress acc) APINameAddress -> withUser $ \user -> do -- Read-only: never creates a seed. A key appears when you register a name, -- not when you ask which address you have. + -- + -- There is no single "profile address" any more: each name has its own key, + -- so this answers with one row per name. acc_ <- withFastStore' $ \db -> boundAccount db user - addr <- forM acc_ $ \(seed, AccountRef {arIndex}) -> - either (throwCmdError . ("wallet: " <>)) (pure . tshow . accountAddress) $ deriveAccount seed arIndex - pure $ CRNameAddress user addr + addrs <- case acc_ of + Nothing -> pure [] + Just (seed, AccountRef {arIndex}) -> do + named <- withFastStore' $ \db -> getNameKeys db (wsId seed) + forM named $ \(nm_, path) -> do + acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveAtPath seed arIndex path + pure (nm_, tshow (accountAddress acc), path) + pure $ CRNameAddress user addrs APISendServiceResponse userId requestId responseData -> withUserId userId $ \user -> do let AgentInvId invId = requestId connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 6392c829a6..dcd2391765 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -47,6 +47,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection import Simplex.Chat.Store.Postgres.Migrations.M20260818_wallet_seeds +import Simplex.Chat.Store.Postgres.Migrations.M20260826_wallet_name_keys import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -93,7 +94,8 @@ schemaMigrations = ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), - ("20260818_wallet_seeds", m20260818_wallet_seeds, Just down_m20260818_wallet_seeds) + ("20260818_wallet_seeds", m20260818_wallet_seeds, Just down_m20260818_wallet_seeds), + ("20260826_wallet_name_keys", m20260826_wallet_name_keys, Just down_m20260826_wallet_name_keys) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260826_wallet_name_keys.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260826_wallet_name_keys.hs new file mode 100644 index 0000000000..ab6df4578b --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260826_wallet_name_keys.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260826_wallet_name_keys where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260826_wallet_name_keys :: Text +m20260826_wallet_name_keys = + [r| +CREATE TABLE wallet_name_keys ( + wallet_name_key_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + wallet_seed_id BIGINT NOT NULL REFERENCES wallet_seeds ON DELETE RESTRICT, + derivation_path TEXT NOT NULL, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (wallet_seed_id, derivation_path), + UNIQUE (wallet_seed_id, name) +); + +CREATE INDEX idx_wallet_name_keys_wallet_seed_id ON wallet_name_keys(wallet_seed_id); + +ALTER TABLE users ADD COLUMN wallet_next_name_index BIGINT NOT NULL DEFAULT 0; +|] + +down_m20260826_wallet_name_keys :: Text +down_m20260826_wallet_name_keys = + [r| +ALTER TABLE users DROP COLUMN wallet_next_name_index; + +DROP INDEX idx_wallet_name_keys_wallet_seed_id; + +DROP TABLE wallet_name_keys; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 785b59ed72..4e926872ee 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -170,6 +170,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection import Simplex.Chat.Store.SQLite.Migrations.M20260818_wallet_seeds +import Simplex.Chat.Store.SQLite.Migrations.M20260826_wallet_name_keys import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -328,6 +329,7 @@ schemaMigrations = ("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges), ("20260818_wallet_seeds", m20260818_wallet_seeds, Just down_m20260818_wallet_seeds), ("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders), + ("20260826_wallet_name_keys", m20260826_wallet_name_keys, Just down_m20260826_wallet_name_keys), ("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services), ("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at), ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain), diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260826_wallet_name_keys.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260826_wallet_name_keys.hs new file mode 100644 index 0000000000..2a364136f6 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260826_wallet_name_keys.hs @@ -0,0 +1,43 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260826_wallet_name_keys where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- | One key per name, at BIP-44 address index k under the profile's account. +-- +-- Which key owns which name is recorded nowhere else: it is not on chain, and a +-- name found by a future recovery scan may sit on a layout that is not ours (a +-- name bought in a dapp is typically at the master key, with no derivation at +-- all). So the path is stored literally rather than as indices. +m20260826_wallet_name_keys :: Query +m20260826_wallet_name_keys = + [sql| +CREATE TABLE wallet_name_keys ( + wallet_name_key_id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE RESTRICT, + derivation_path TEXT NOT NULL, -- "m/44'/60'/0'/0/1", or "m" for a root key + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (wallet_seed_id, derivation_path), + UNIQUE (wallet_seed_id, name) +) STRICT; + +-- required: the .lint check enforces an index on every foreign key +CREATE INDEX idx_wallet_name_keys_wallet_seed_id ON wallet_name_keys(wallet_seed_id); + +-- High-water mark for k, per profile. Same reason as next_account_index: after +-- recovery from the phrase alone nothing else knows which indices are taken. +ALTER TABLE users ADD COLUMN wallet_next_name_index INTEGER NOT NULL DEFAULT 0; +|] + +down_m20260826_wallet_name_keys :: Query +down_m20260826_wallet_name_keys = + [sql| +ALTER TABLE users DROP COLUMN wallet_next_name_index; + +DROP INDEX idx_wallet_name_keys_wallet_seed_id; + +DROP TABLE wallet_name_keys; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 4b69c7a2bd..8831a88f67 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -55,7 +55,8 @@ CREATE TABLE users( is_user_chat_relay INTEGER NOT NULL DEFAULT 0, client_service INTEGER NOT NULL DEFAULT 0, wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT, - wallet_account_index INTEGER, -- 1 for active user + wallet_account_index INTEGER, + wallet_next_name_index INTEGER NOT NULL DEFAULT 0, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE RESTRICT @@ -856,6 +857,15 @@ CREATE TABLE wallet_seeds( -- profile would silently reuse a recovered account's keys. next_account_index INTEGER NOT NULL DEFAULT 0 ) STRICT; +CREATE TABLE wallet_name_keys( + wallet_name_key_id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE RESTRICT, + derivation_path TEXT NOT NULL, -- "m/44'/60'/0'/0/1", or "m" for a root key +name TEXT NOT NULL, +created_at TEXT NOT NULL DEFAULT(datetime('now')), + UNIQUE(wallet_seed_id, derivation_path), + UNIQUE(wallet_seed_id, name) +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1391,6 +1401,9 @@ CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON chat_items( item_signed_by_group_member_id ); CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id); +CREATE INDEX idx_wallet_name_keys_wallet_seed_id ON wallet_name_keys( + wallet_seed_id +); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index b1cd8bb732..ddc684802e 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -13,15 +13,19 @@ module Simplex.Chat.Store.Wallets ( getOrCreateAccountRef, boundAccount, + takeNameIndex, + recordNameKey, + getNameKeys, ) where import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Maybe (listToMaybe) +import Data.Text (Text) import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Chat.Types (User (..)) -import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), SeedId (..), WalletSeed (..)) +import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), NameIndex, SeedId (..), WalletSeed (..)) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -123,3 +127,37 @@ getNextAccountIndex db (SeedId sId) = <$> ( maybeFirstRow fromOnly $ DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) ) + +-- | Take the next name index for this profile and advance its high-water mark. +-- +-- Names are BIP-44 address indices under the profile's account, so this counter +-- is per profile, not per seed. Stored for the same reason as +-- 'next_account_index': after recovery from the phrase alone nothing else knows +-- which indices already own names on chain. +takeNameIndex :: DB.Connection -> User -> IO NameIndex +takeNameIndex db User {userId} = do + ix <- + maybe 0 (fromIntegral :: Int64 -> NameIndex) + <$> ( maybeFirstRow fromOnly $ + DB.query db "SELECT wallet_next_name_index FROM users WHERE user_id = ?" (Only userId) + ) + DB.execute db "UPDATE users SET wallet_next_name_index = ? WHERE user_id = ?" (fromIntegral ix + 1 :: Int64, userId) + pure ix + +-- | Record which key owns a name, once it is registered. Without this the +-- client cannot tell which of a profile's keys owns which name: the binding is +-- not on chain and is not derivable. +recordNameKey :: DB.Connection -> SeedId -> Text -> Text -> IO () +recordNameKey db (SeedId sId) path name = + DB.execute + db + "INSERT INTO wallet_name_keys (wallet_seed_id, derivation_path, name) VALUES (?, ?, ?)" + (sId, path, name) + +-- | Every name this seed owns, with the path its key was derived at. +getNameKeys :: DB.Connection -> SeedId -> IO [(Text, Text)] +getNameKeys db (SeedId sId) = + DB.query + db + "SELECT name, derivation_path FROM wallet_name_keys WHERE wallet_seed_id = ? ORDER BY wallet_name_key_id" + (Only sId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 65dbc43e5a..87cd1964d2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -190,10 +190,15 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"] CRServiceResponse u resp -> ttyUser u ["service response: " <> viewJSON resp] CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] - CRNameAddress u addr_ -> - ttyUser u [maybe "no name address yet - it is created when you register a name" (plain . ("name address: " <>)) addr_] - CRNameRegistered u nm owner expiry txHash -> - ttyUser u [plain $ "name registered: " <> nm <> " -> " <> owner <> " (expires " <> tshow expiry <> ", tx " <> safeDecodeUtf8 (strEncode txHash) <> ")"] + CRNameAddress u addrs -> + ttyUser u $ case addrs of + [] -> ["no name addresses yet - one is created for each name you register"] + as -> "name addresses:" : map (\(nm, addr, path) -> plain $ " " <> nm <> " -> " <> addr <> " " <> path) as + CRNameRegistered u nm owner path expiry txHash -> + ttyUser u + [ plain $ "name registered: " <> nm <> " -> " <> owner <> " (expires " <> tshow expiry <> ", tx " <> safeDecodeUtf8 (strEncode txHash) <> ")", + plain $ " derivation path: " <> path + ] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results @@ -2808,7 +2813,12 @@ viewChatError isCmd logLevel testView = \case CEAgentNoSubResult connId -> ["no subscription result for connection: " <> sShow connId] CEServerProtocol p -> [plain $ "Servers for protocol " <> strEncode p <> " cannot be configured by the users"] CECommandError e -> ["bad chat command: " <> plain e] - CENameRegistrationFailed code msg -> ["name registration failed: " <> plain code <> maybe "" ((": " <>) . plain) msg] + CENameRegistrationFailed code msg retryAfter -> + [ "name registration failed: " + <> plain code + <> maybe "" ((": " <>) . plain) msg + <> maybe "" (\s -> plain (" (retry after " <> tshow s <> "s)")) retryAfter + ] CEAgentCommandError e -> ["agent command error: " <> plain e] CEInvalidFileDescription e -> ["invalid file description: " <> plain e] CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"] diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index b4649dbb52..78bdc7bdec 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -1,13 +1,20 @@ {-# LANGUAGE OverloadedStrings #-} --- | The wallet: BIP-39 seeds, and the per-chat-profile accounts derived from --- them. +-- | The wallet: BIP-39 seeds, and the keys derived from them. -- --- * __seed__ — BIP-39 entropy. Generic and profile-scoped, /not/ name-specific. --- * __account__ — a profile's slot in a seed, index @i@, holding the main --- address that owns the names the profile registers. +-- * __seed__ — BIP-39 entropy. Generic, /not/ name-specific. +-- * __account__ — a profile's slot in a seed, BIP-44 account index @i@. +-- * __name key__ — @m\/44'\/60'\/i'\/0\/k@: one key per name, at BIP-44 +-- address index @k@ under the profile that bought it. This is what the +-- registry records as the name's owner. -- * __wallet__ — this module: creation and derivation. -- +-- One key per name, not one per profile. A per-profile key would mean exporting +-- it hands over every name that profile owns, and would put every name's signed +-- record edits behind one shared nonce counter on the resolver. Both are avoided +-- by giving each name its own address index. @k = 0@ is the profile's first +-- name. +-- -- Names are a /consumer/ of the wallet, which is why this sits here rather than -- under "Simplex.Chat.Names". -- @@ -23,10 +30,14 @@ module Simplex.Chat.Wallet ( SeedId (..), WalletSeed (..), AccountIndex, + NameIndex, AccountRef (..), WalletAccount (..), newSeed, - deriveAccount, + deriveNameKey, + deriveAtPath, + nameKeyPath, + renderNameKeyPath, accountAddress, ) where @@ -35,11 +46,13 @@ import Control.Concurrent.STM import Crypto.Random (ChaChaDRG) import Data.ByteString (ByteString) import Data.Int (Int64) +import Data.Text (Text) +import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Word (Word32) import qualified Simplex.Messaging.Crypto.BIP32 as B32 import qualified Simplex.Messaging.Crypto.BIP39 as B39 import qualified Simplex.Messaging.Crypto.Secp256k1 as S -import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey, ethereumPath) +import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey) newtype SeedId = SeedId Int64 deriving (Eq, Ord, Show) @@ -47,6 +60,9 @@ newtype SeedId = SeedId Int64 -- | BIP-44 account index within a seed. One per chat profile. type AccountIndex = Word32 +-- | BIP-44 address index within a profile account. One per name. +type NameIndex = Word32 + -- | A seed, held as BIP-39 entropy. Stored in the chat database so it rides the -- existing archive export and Migrate-to-another-device flows. -- @@ -83,14 +99,50 @@ instance Show WalletAccount where newSeed :: B39.MnemonicStrength -> TVar ChaChaDRG -> STM ByteString newSeed strength g = B39.mnemonicToEntropy <$> B39.randomMnemonic strength g --- | Derive the account at @m\/44'\/60'\/i'\/0\/0@. -deriveAccount :: WalletSeed -> AccountIndex -> Either String WalletAccount -deriveAccount s ix = do +-- | @m\/44'\/60'\/i'\/0\/k@ — the standard BIP-44 layout, with the profile at +-- the account level and the name at the address level. Nothing here is a custom +-- path, so profile @i@'s names are the account list an ordinary Ethereum wallet +-- would show for that account. +nameKeyPath :: AccountIndex -> NameIndex -> [Word32] +nameKeyPath acc nm = [B32.hardened 44, B32.hardened 60, B32.hardened acc, 0, nm] + +-- | The path a name's key was derived at, for display. Users need it only to +-- import a single name into a third-party wallet. +renderNameKeyPath :: AccountIndex -> NameIndex -> Text +renderNameKeyPath acc nm = decodeLatin1 . B32.renderPath $ nameKeyPath acc nm + +-- | Derive the key that owns one name. +deriveNameKey :: WalletSeed -> AccountIndex -> NameIndex -> Either String WalletAccount +deriveNameKey s acc nm = do m <- B39.entropyToMnemonic (wsEntropy s) master <- B32.masterKey (B39.mnemonicToSeed m "") - xk <- B32.derivePath master (ethereumPath ix) - pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = ix}, waKey = B32.xkKey xk} + xk <- B32.derivePath master (nameKeyPath acc nm) + pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = acc}, waKey = B32.xkKey xk} --- | The Ethereum address that owns names registered by this account. +-- | Derive at a path given literally, e.g. @"m\/44'\/60'\/0'\/0\/1"@ or @"m"@ +-- for the master key with no derivation. +-- +-- Names record the path they were derived at rather than an index, because a +-- name found on an imported seed may sit on a layout that is not ours — a name +-- bought in a dapp is typically at the master key. Re-deriving from the stored +-- path keeps those usable without special-casing them. +deriveAtPath :: WalletSeed -> AccountIndex -> Text -> Either String WalletAccount +deriveAtPath s acc path = do + m <- B39.entropyToMnemonic (wsEntropy s) + master <- B32.masterKey (B39.mnemonicToSeed m "") + ixs <- B32.parsePath (encodeUtf8 path) + xk <- B32.derivePath master ixs + pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = acc}, waKey = B32.xkKey xk} + +-- | The Ethereum address that owns the name this key was derived for. accountAddress :: WalletAccount -> Address accountAddress = addressFromPrivateKey . waKey + +-- Stealth addresses, when they arrive, hang off the same profile account but +-- are not at a derivation path at all. A profile publishes one meta-address — +-- a spend key and a viewing key, both hardened under purpose 5564' — and a +-- sender derives a fresh destination from it as @spend + H(r·view)·G@. The +-- recipient's key for that destination is @spend + H(view·R)@, recomputed from +-- the sender's ephemeral public key @R@ rather than from an index. So the +-- wallet must be able to hold a key that is "spend key plus a scalar", which is +-- why one meta-address per profile is enough for any number of received names. diff --git a/tests/Bots/NamesServiceTests.hs b/tests/Bots/NamesServiceTests.hs index a72ffabf96..571a9348c7 100644 --- a/tests/Bots/NamesServiceTests.hs +++ b/tests/Bots/NamesServiceTests.hs @@ -16,6 +16,8 @@ import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Eth.Address (parseAddress) import Simplex.Messaging.Eth.Keccak (keccak256) import Test.Hspec hiding (it) +import qualified Simplex.Messaging.Crypto.BIP39 as B39 +import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, deriveNameKey) import qualified Test.Hspec as Hspec namesServiceTests :: SpecWith TestParams @@ -23,12 +25,27 @@ namesServiceTests = do it "registers a name via commit/reveal and rejects a taken name" testNamesRegister it "rejects a reveal with no matching commitment" testRevealWithoutCommit it "shows the owner address without creating one" testNameAddress - it "derives the same owner address after restart" testSeedPersists + it "gives each name its own key, still derivable after restart" testSeedPersists -- | Pins the wire format. The end-to-end test cannot catch a key renamed on -- both sides at once, so the encodings are asserted literally here. namesProtocolTests :: Spec namesProtocolTests = do + -- Name keys are plain BIP-44, so they line up with wallets users already have. + -- Pinned against the standard test mnemonic: profile 0's names are exactly + -- MetaMask's account list (m/44'/60'/0'/0/k), and each profile's first name is + -- the matching Ledger Live account (m/44'/60'/i'/0/0). That is what lets an + -- owner move a single name into another wallet, and a name bought in a dapp be + -- found here. + it "name keys line up with other wallets' derivation" $ \_ -> do + let mn = either error id $ B39.parseMnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + sd = WalletSeed {wsId = SeedId 1, wsEntropy = B39.mnemonicToEntropy mn} + addrOf i k = either error (show . accountAddress) (deriveNameKey sd i k) + -- MetaMask account 1 and 2 for this phrase + addrOf 0 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + addrOf 0 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" + -- Ledger Live account 2 for this phrase + addrOf 1 0 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" Hspec.it "encodes commit and reveal requests" $ do -- on-chain byte values are 0x-prefixed hex, as Ethereum writes them encodes (NamesRequest 1 (NRCommit $ Commitment "0123456789abcdef")) $ @@ -75,7 +92,9 @@ testNamesRegister ps = -- the final progress event and the command response arrive on separate channels client <### [ ConsoleString "name alice.simplex: registered", - StartsWith "name registered: alice.simplex -> 0x" + StartsWith "name registered: alice.simplex -> 0x", + -- one key per name: the profile's first name is address index 0 + ConsoleString " derivation path: m/44'/60'/0'/0/0" ] -- re-running the identical command is rejected too, not silently accepted: -- the owner is the same within a session, so this is the duplicate a user hits. @@ -99,14 +118,15 @@ testNameAddress ps = withBadgeService ps $ \client bsLink -> do -- asked repeatedly before any registration: still no address, none created client ##> "/name address" - client <## "no name address yet - it is created when you register a name" + client <## "no name addresses yet - one is created for each name you register" client ##> "/name address" - client <## "no name address yet - it is created when you register a name" + client <## "no name addresses yet - one is created for each name you register" client ##> ("/name register " <> bsLink <> " carol.simplex simplex:/contact#/x") owner <- ownerOf client "carol.simplex" - -- now it exists, and reports the address the name was registered to + -- now it exists, and reports the address and path the name was registered to client ##> "/name address" - client <## ("name address: " <> owner) + client <## "name addresses:" + client <## (" carol.simplex -> " <> owner <> " m/44'/60'/0'/0/0") -- | The front-running defence: a reveal only registers a name if that exact -- commitment was published first. Sent as a raw service request, because the @@ -124,6 +144,12 @@ testRevealWithoutCommit ps = -- | The seed is persisted, so a name registered in one session is still owned by -- an address the next session can derive. Without this the key is unrecoverable -- after restart and the name is orphaned. +-- +-- It also pins the other half of one-key-per-name: the second registration must +-- land on a /different/ address, at the next BIP-44 address index under the same +-- profile account. Sharing one key across a profile's names is what this +-- replaces — it would put every name behind one resolver nonce and make +-- exporting one name's key hand over all of them. testSeedPersists :: HasCallStack => TestParams -> IO () testSeedPersists ps = do let opts = mkBadgeServiceOpts ps @@ -143,20 +169,29 @@ testSeedPersists ps = do owner2 <- withTestChat ps "client" $ \client -> do client ##> ("/name register " <> bsLink <> " second.simplex simplex:/contact#/y") ownerOf client "second.simplex" - owner2 `shouldBe` owner1 + owner2 `shouldNotBe` owner1 + -- and both are still derivable in a third session, each at its own path + withTestChat ps "client" $ \client -> do + client ##> "/name address" + client <## "name addresses:" + client <## (" first.simplex -> " <> owner1 <> " m/44'/60'/0'/0/0") + client <## (" second.simplex -> " <> owner2 <> " m/44'/60'/0'/0/1") -- | Reads past startup and progress lines to the registration result, returning -- the owner address. Keeps reading until the final progress event has arrived -- too — it races with the command response and would otherwise be left -- unconsumed, failing the next assertion or the session close. ownerOf :: HasCallStack => TestCC -> String -> IO String -ownerOf client nm = go (40 :: Int) Nothing False +ownerOf client nm = go (40 :: Int) Nothing False False where pfx = "name registered: " <> nm <> " -> " + pathLine = " derivation path: " lastEvt = "name " <> nm <> ": registered" - go _ (Just a) True = pure a - go 0 _ _ = error $ "no registration line for " <> nm - go n addr seen = do + -- three lines must be consumed before the next assertion: the progress + -- event, the registration line, and the derivation path under it + go _ (Just a) True True = pure a + go 0 _ _ _ = error $ "no registration line for " <> nm + go n addr seen path = do l <- getTermLine client let addr' = if pfx `isPrefixOf` l then Just (takeWhile (/= ' ') $ drop (length pfx) l) else addr - go (n - 1) addr' (seen || l == lastEvt) + go (n - 1) addr' (seen || l == lastEvt) (path || pathLine `isPrefixOf` l)