diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ea502913..61231f8248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ needs a wallet, ETH and a browser: service - metering is off chain. - See what you own with `/names` and `/name info`, and find names your keys already hold with `/name rescan`. -- Recovery keys: `/name keys` (names grouped by the account they sit under), +- Recovery keys: `/name keys` (names grouped by account, each with its index), `keys export` (every key, not just the one in use), `keys import` (adds, never replaces), `keys init`, `keys use [ []]`. - Recovering on a new device: `/name rescan` restores the derivation marks from 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 3d9a6f6b5e..7c683f590f 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 @@ -74,9 +74,12 @@ Recovery keys: ``` /name keys 1: (in use) - account 0 alice.simplex + account 0 + name 0 alice.simplex + name 1 lizzy.simplex 2: (not written down) - other lucy.simplex (m) + other + lucy.simplex m /name keys export every key's phrase, each labelled with the names it controls - never just the one in use @@ -88,8 +91,9 @@ Recovery keys: /name keys use 2 0 5 ...and where its next name sits ``` -Names are listed under the account they were derived at, because that grouping -is the only surviving record of which profile owned what — see *Recovery*. +Names are listed under the account they were derived at, with their index in it +— the two numbers `/name keys use` takes. That grouping is the only surviving +record of which profile owned what; see *Recovery*. A new device, with nothing but the phrase: @@ -457,8 +461,8 @@ it, not on chain, and not derivable: * **Which profile held which account.** Recreate two profiles on a new device and nothing says which was account 0. Only the user knows, so `/name keys` groups - names by the account in their stored path and `/name keys use ` - pins a profile back to one. + names by the account and index in their stored path, and + `/name keys use ` pins a profile back to one. * **Which indices are already taken.** Both high-water marks — `next_account_index` per seed, `wallet_next_name_index` per profile — start at 0 after an import, while accounts and names already exist under the phrase. A scan is the only diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6a014b0c90..d179e6e682 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -861,7 +861,7 @@ data ChatResponse | CRNameInfo {user :: User, regName :: Text, regOwner :: Text, regPath :: Text, nameContact :: [Text], nameChannel :: [Text], regExpiry :: UTCTime, nameEditsLeft :: Word32} | CRNameLinkSet {user :: User, regName :: Text, nameRecord :: Text, regTxHash :: TxHash} | CRNameRescan {user :: User, namesFound :: [(Text, Text)]} - | CRNameKeys {user :: User, walletKeys :: [(Int, [(Maybe AccountIndex, [(Text, Text)])], Bool, Bool)]} + | CRNameKeys {user :: User, walletKeys :: [(Int, [(Maybe AccountIndex, [(Maybe NameIndex, Text, Text)])], Bool, Bool)]} | CRNameKeyPhrases {user :: User, walletPhrases :: [(Int, Text, [Text])]} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 6e5b40499f..f2d7134b44 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -66,7 +66,7 @@ import Simplex.Chat.Names.Protocol import Simplex.Chat.Names.Snrc (Intent (..), SnrcDeployment (..), intent712, parseRecordKey) import Simplex.Messaging.Eth.Address (Address, mkAddress) import Simplex.Chat.Store.Wallets (bindSeedAccount, boundAccount, createSeed, currentSeed, getNameKeys, getOrCreateAccountRef, listSeeds, markBackedUp, nameKeyPathTaken, raiseNextAccountIndex, raiseNextNameIndex, recordNameKey, seedOfName, setCurrentSeed, setNextNameIndex, takeNameIndex) -import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), SeedId, WalletAccount, WalletSeed (..), accountAddress, deriveAtPath, deriveNameKey, ethSignatureBytes, importRecoveryKey, newSeed, parseNameKeyPath, recoveryKeyPhrase, renderNameKeyPath, signIntent) +import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), NameIndex, SeedId, WalletAccount, WalletSeed (..), accountAddress, deriveAtPath, deriveNameKey, ethSignatureBytes, importRecoveryKey, newSeed, parseNameKeyPath, recoveryKeyPhrase, renderNameKeyPath, signIntent) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -5246,13 +5246,18 @@ deriveNameOwner user = do taken <- withFastStore' $ \db -> nameKeyPathTaken db sId path if taken then freeNameIndex sId acctIx (tries + 1) else pure (nameIx, path) --- | Names a seed owns, grouped by the account they were derived under. Names on --- a layout that is not ours have no account of ours and group under Nothing. -groupByAccount :: [(Text, Text)] -> [(Maybe AccountIndex, [(Text, Text)])] +-- | Names a seed owns, grouped by the account they were derived under and +-- carrying the name index within it — the two numbers @\/name keys use@ takes. +-- Names on a layout that is not ours have neither, and group under Nothing. +groupByAccount :: [(Text, Text)] -> [(Maybe AccountIndex, [(Maybe NameIndex, Text, Text)])] groupByAccount ns = -- stable, so the accounts keep their order and the ungrouped names sort last - sortOn (isNothing . fst) . M.toAscList . M.fromListWith (flip (<>)) $ - map (\(n, path) -> (fst <$> parseNameKeyPath path, [(n, path)])) ns + map (fmap (sortOn (\(ix_, _, _) -> ix_))) . sortOn (isNothing . fst) . M.toAscList . M.fromListWith (flip (<>)) $ + map entry ns + where + entry (n, path) = case parseNameKeyPath path of + Just (acctIx, nameIx) -> (Just acctIx, [(Just nameIx, n, path)]) + Nothing -> (Nothing, [(Nothing, n, path)]) -- | Names this device holds a key for, with the path each key sits at. ownedNames :: User -> CM [(Text, Text)] diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ae037a1ec3..b87401b744 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -232,13 +232,18 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te -- the phrase, so after importing on a new device this listing is what tells -- the user which account to pin a profile back to. CRNameKeys u rows -> - let name acct (n, path) = if isJust acct then n else n <> " (" <> path <> ")" - accountRow (acct, ns) = + let pad w t = t <> T.replicate (max 1 (w - T.length t)) " " + -- the index is shown because it is the third argument of + -- /name keys use; a name on a foreign layout has none, so it shows the + -- path that replaces it + nameRow (ix_, n, path) = plain $ - " " - <> maybe "other " (\a -> "account " <> tshow a) acct - <> " " - <> T.intercalate ", " (map (name acct) ns) + " " + <> pad 9 (maybe "" (\ix -> "name " <> tshow ix) ix_) + <> n + <> maybe (" " <> path) (const "") ix_ + accountRow (acct, ns) = + plain (" " <> maybe "other" (\a -> "account " <> tshow a) acct) : map nameRow ns keyRow (i, groups, cur, backed) = plain ( tshow i @@ -246,7 +251,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te <> (if cur then " (in use)" else "") <> (if backed then "" else " (not written down)") ) - : if null groups then [" no names yet"] else map accountRow groups + : if null groups then [" no names yet"] else concatMap accountRow groups in ttyUser u $ case rows of [] -> ["no recovery keys yet - one is created when you buy a name"] _ -> concatMap keyRow rows diff --git a/tests/Bots/NamesServiceTests.hs b/tests/Bots/NamesServiceTests.hs index ec8ad36a79..75844205da 100644 --- a/tests/Bots/NamesServiceTests.hs +++ b/tests/Bots/NamesServiceTests.hs @@ -154,7 +154,8 @@ testNameAddress ps = -- and now exactly one key exists, controlling the name just bought client ##> "/name keys" client <## "1: (in use) (not written down)" - client <## " account 0 carolname.simplex" + client <## " account 0" + client <## " name 0 carolname.simplex" -- | Recovery on a new device must not re-use a key a recovered name already -- owns. Neither high-water mark survives an import - the phrase records only @@ -197,8 +198,10 @@ testRecoverMarks ps = do -- the scan moved both marks past index 0, so the new name is elsewhere client ##> "/name keys" client <## "1: (in use) (not written down)" - client <## " account 0 lostname.simplex" - client <## " account 1 foundname.simplex" + client <## " account 0" + client <## " name 0 lostname.simplex" + client <## " account 1" + client <## " name 1 foundname.simplex" -- | A scan is one round trip per candidate path, taken sequentially, and -- outruns the harness's five second per-line read timeout. Retrying is not @@ -263,7 +266,9 @@ testSeedPersists ps = do withTestChat ps "client" $ \client -> do client ##> "/name keys" client <## "1: (in use) (not written down)" - client <## " account 0 firstname.simplex, secondname.simplex" + client <## " account 0" + client <## " name 0 firstname.simplex" + client <## " name 1 secondname.simplex" -- | Reads past startup and progress lines to the registration result, returning -- the owner address. Keeps reading until the final progress event has arrived