From 70d4c128bd27c03d04e8ef36f1e54c82e23d59c8 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 9 Sep 2026 14:00:25 +0000 Subject: [PATCH] core: make the wallet an API, not a user-facing feature Names commands will use the wallet; users do not. The commands move to /_wallet, and the help section, its topic and the changelog entry are removed. The tests drive the API end to end. Comments cut to what the code does not say, dead exports removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- CHANGELOG.md | 9 -- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Help.hs | 27 +----- src/Simplex/Chat/Library/Commands.hs | 17 ++-- .../Migrations/M20260908_wallet_seeds.hs | 4 +- .../Migrations/M20260908_wallet_seeds.hs | 7 +- .../Store/SQLite/Migrations/chat_schema.sql | 7 +- src/Simplex/Chat/Store/Wallets.hs | 13 +-- src/Simplex/Chat/View.hs | 17 ++-- src/Simplex/Chat/Wallet.hs | 54 ++--------- tests/WalletTests.hs | 90 +++++++------------ 11 files changed, 69 insertions(+), 178 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a35756854..8487f0a53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,5 @@ # Release History -## Unreleased - -Wallet (in development, CLI only): creates the key that will own your SimpleX -names, so a name you buy later has an owner this app can still derive. One key -per device, one account per chat profile, each name at `m/44'/60'/'/0/` -(standard BIP-44, so your recovery phrase works in other wallets). `/wallet` -shows the addresses, `/wallet import` and `/wallet export` move the key, -`/wallet delete` removes it. You cannot buy a name yet. - ## v6.5 30 April, 2026 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 37e900f8a9..94492007d3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -325,7 +325,7 @@ data ChatController = ChatController contactMergeEnabled :: TVar Bool } -data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase | HSWallet +data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase deriving (Show) data ChatCommand diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 81be9ce8ac..56ed65fb4b 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -14,7 +14,6 @@ module Simplex.Chat.Help markdownInfo, settingsInfo, databaseHelpInfo, - walletHelpInfo, ) where @@ -89,7 +88,7 @@ chatHelpInfo = green "Create your address: " <> highlight "/address", "", green "Other commands:", - indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db", "wallet"], + indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db"], indent <> highlight "/profile " <> " - show / update user profile", indent <> highlight "/delete " <> " - delete contact and all messages with them", indent <> highlight "/chats " <> " - most recent chats", @@ -219,30 +218,6 @@ myAddressHelpInfo = "The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"] ] -walletHelpInfo :: [StyledString] -walletHelpInfo = - map - styleMarkdown - [ green "Wallet commands:", - indent <> highlight "/wallet " <> " - your key, and the addresses it derives", - indent <> highlight "/wallet create " <> " - create your key, or add this profile to it", - indent <> highlight "/wallet import " <> " - use a key you already have", - indent <> highlight "/wallet export " <> " - show your recovery phrase", - indent <> highlight "/wallet delete " <> " - delete the key, confirmed by the last word of the phrase", - "", - "Please note: this is in development. You cannot buy a name yet.", - "", - "One key per device, one account per chat profile. Each name gets its own", - "key at " <> highlight "m/44'/60'/'/0/" <> ". This is standard BIP-44, so your", - "phrase works in other wallets.", - "", - "The key is stored in the chat database. It is only encrypted if you set a", - "database passphrase with " <> highlight "/db encrypt" <> ", and it is included in " <> highlight "/db export" <> ".", - "", - "Anyone who has your recovery phrase controls your names. Keep it secret,", - "and keep a copy." - ] - incognitoHelpInfo :: [StyledString] incognitoHelpInfo = map diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 77018c177a..f80b1a23ca 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1496,7 +1496,7 @@ processChatCommand cxt nm = \case accs <- case seed_ of Nothing -> pure [] Just seed -> do - -- a hidden profile is left out, as it is by /users + -- hidden profiles are left out, as they are by /users as <- filter (\(_, _, active, hidden) -> active || not hidden) <$> withFastStore' (\db -> getSeedAccounts db (wsId seed)) forM as $ \(n, acct, active, _) -> do keys <- forM [0 .. walletNamesShown - 1] $ \k -> do @@ -5462,12 +5462,12 @@ withExpirationDate globalTTL chatItemTTL action = do let ttl = fromMaybe globalTTL chatItemTTL when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs --- | Name keys shown per profile by /wallet, to check derivation against other wallets. +-- | Name keys shown per profile, to check derivation against other wallets. walletNamesShown :: NameIndex walletNamesShown = 2 noKeyError :: String -noKeyError = "no wallet key for this profile - create one with /wallet create" +noKeyError = "no wallet key for this profile" chatCommandP :: Parser ChatCommand chatCommandP = @@ -5587,11 +5587,11 @@ chatCommandP = "/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)), "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), - "/wallet create" $> APIWalletCreate, - "/wallet import " *> (APIWalletImport <$> textP), - "/wallet export" $> APIWalletExport, - "/wallet delete " *> (APIWalletDelete <$> textP), - "/wallet" $> APIWallet, + "/_wallet create" $> APIWalletCreate, + "/_wallet import " *> (APIWalletImport <$> textP), + "/_wallet export" $> APIWalletExport, + "/_wallet delete " *> (APIWalletDelete <$> textP), + "/_wallet" $> APIWallet, "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), "/_call reject @" *> (APIRejectCall <$> A.decimal), @@ -5711,7 +5711,6 @@ chatCommandP = ("/help remote" <|> "/hr") $> ChatHelp HSRemote, ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help db" <|> "/hd") $> ChatHelp HSDatabase, - ("/help wallet" <|> "/hw") $> ChatHelp HSWallet, ("/help" <|> "/h") $> ChatHelp HSMain, ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs index 04b8b3a46f..382aa79178 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs @@ -12,9 +12,9 @@ m20260908_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, seed BYTEA NOT NULL, - -- Known issue: see the SQLite migration. + -- see the SQLite migration next_account_index BIGINT NOT NULL DEFAULT 0, - -- one key per device for now; drop when several are supported + -- one key per device for now single_seed SMALLINT NOT NULL DEFAULT 1 UNIQUE ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs index 36242b9647..fcd14ee593 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -11,11 +11,10 @@ m20260908_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes - -- Known issue: after importing a phrase this starts at 0, so a recovered - -- device can re-issue an account that already owns names. A recovery scan - -- will raise it. + -- known issue: after import this starts at 0, so a recovered device can + -- re-issue an account that already owns names next_account_index INTEGER NOT NULL DEFAULT 0, - -- one key per device for now; drop when several are supported + -- one key per device for now single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE ) STRICT; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index fa44bfe6fe..aa8adc9714 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -857,11 +857,10 @@ CREATE TABLE rcv_roster_transfers( CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes - -- Known issue: after importing a phrase this starts at 0, so a recovered - -- device can re-issue an account that already owns names. A recovery scan - -- will raise it. + -- known issue: after import this starts at 0, so a recovered device can + -- re-issue an account that already owns names next_account_index INTEGER NOT NULL DEFAULT 0, - -- one key per device for now; drop when several are supported + -- one key per device for now single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE ) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index ac1ef938fb..90ddf96653 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -68,8 +68,6 @@ getBoundAccount db user = Nothing -> pure Nothing Just r -> fmap (\s -> (s, r)) <$> getWalletSeed db (arSeedId r) --- | Profiles bound to this seed: display name, account index, whether active, --- whether hidden. getSeedAccounts :: DB.Connection -> SeedId -> IO [(Text, AccountIndex, Bool, Bool)] getSeedAccounts db (SeedId sId) = map toRow @@ -83,17 +81,13 @@ getSeedAccounts db (SeedId sId) = where toRow (n, ix, BI active, pwdHash) = (n, fromIntegral (ix :: Int64), active, isJust (pwdHash :: Maybe ByteString)) --- | Bind this profile to the device seed, creating it from @entropy@ if there --- is none. getOrCreateAccountRef :: DB.Connection -> User -> ByteString -> IO (WalletSeed, AccountRef) getOrCreateAccountRef db user entropy = getBoundAccount db user >>= \case Just bound -> pure bound Nothing -> getDeviceSeed db >>= maybe (createWalletSeed db entropy) pure >>= bindNewAccount db user --- | Nothing if the device already has a key. One transaction, so a phrase --- cannot be discarded in favour of a key created meanwhile; single_seed is --- UNIQUE, so a concurrent insert cannot add a second key either. +-- | Nothing if the device already has a key. importSeed :: DB.Connection -> User -> ByteString -> IO (Maybe (WalletSeed, AccountRef)) importSeed db user entropy = getDeviceSeed db >>= \case @@ -113,8 +107,7 @@ createWalletSeed db entropy = do sId <- insertedRowId db pure WalletSeed {wsId = SeedId sId, wsEntropy = entropy} --- | Incremented in SQL so that concurrent purchases cannot be handed the same --- account, and read back inside the same transaction. +-- | Incremented in SQL, so two profiles cannot be handed the same account. takeAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex takeAccountIndex db sId@(SeedId sId') = do DB.execute db "UPDATE wallet_seeds SET next_account_index = next_account_index + 1 WHERE wallet_seed_id = ?" (Only sId') @@ -127,7 +120,7 @@ getNextAccountIndex db (SeedId sId) = DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) ) --- | Profiles are unbound first: the foreign key is ON DELETE RESTRICT. +-- | Profiles are unbound first, as the foreign key is ON DELETE RESTRICT. deleteSeed :: DB.Connection -> SeedId -> IO () deleteSeed db (SeedId sId) = do DB.execute db "UPDATE users SET wallet_seed_id = NULL, wallet_account_index = NULL WHERE wallet_seed_id = ?" (Only sId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 73c599eee9..b770f46231 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -181,7 +181,6 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te HSRemote -> remoteHelpInfo HSSettings -> settingsInfo HSDatabase -> databaseHelpInfo - HSWallet -> walletHelpInfo CRWelcome user -> chatWelcome user CRContactsList u cs -> ttyUser u $ viewContactsList cs CRUserContactLink u UserContactLink {connLinkContact, addressSettings} -> ttyUser u $ connReqContact_ showFullLinks "Your chat address:" connLinkContact <> viewAddressSettings addressSettings @@ -190,21 +189,17 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRServiceResponse u resp -> ttyUser u ["service response: " <> viewJSON resp] CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] CRWallet u exists accs - | not exists -> ttyUser u ["no wallet key on this device - create one with " <> highlight' "/wallet create"] + | not exists -> ttyUser u ["no wallet key"] | otherwise -> ttyUser u $ - ("key 1" : concatMap accountRows accs) - <> ["this profile has no key yet - add one with " <> highlight' "/wallet create" | not (any (\(_, _, active, _) -> active) accs)] + concatMap accountRows accs + <> ["this profile has no wallet key" | not (any (\(_, _, active, _) -> active) accs)] where accountRows (n, acct, active, keys) = - plain (" account " <> tshow acct <> " (" <> n <> (if active then ", active" else "") <> ")") + plain ("account " <> tshow acct <> " (" <> n <> (if active then ", active" else "") <> ")") : zipWith nameRow [0 :: Int ..] keys - nameRow k (path, addr) = plain $ " name " <> tshow k <> " " <> path <> " " <> addr - CRWalletPhrase u phrase -> - ttyUser u - [ "write this down - anyone who knows these words controls the names this key owns:", - plain $ " " <> phrase - ] + nameRow k (path, addr) = plain $ " name " <> tshow k <> " " <> path <> " " <> addr + CRWalletPhrase u phrase -> ttyUser u [plain phrase] 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 diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index e35efc7a98..8a15eb806b 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -1,39 +1,20 @@ {-# LANGUAGE OverloadedStrings #-} --- | The wallet: BIP-39 seeds, and the keys derived from them. +-- | BIP-39 seeds and the keys derived from them. -- --- * __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 buys 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. --- --- The schema allows several seeds; a profile binds to exactly one plus its own --- account index. Only one seed per device is reachable today. --- --- This module is pure. Persistence lives in "Simplex.Chat.Store.Wallets". --- --- There is no signing here yet, so nothing can be bought or edited: this is the --- key material and the derivation only. +-- One key per name, not one per profile, so that exporting a name's key hands +-- over that name only. module Simplex.Chat.Wallet ( SeedId (..), WalletSeed (..), AccountIndex, NameIndex, AccountRef (..), - WalletAccount (..), + WalletAccount, newSeed, importRecoveryKey, recoveryKeyPhrase, deriveNameKey, - nameKeyPath, renderNameKeyPath, accountAddress, ) @@ -54,16 +35,12 @@ import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey) newtype SeedId = SeedId Int64 deriving (Eq, Ord, Show) --- | BIP-44 account index within a seed. One per chat profile. +-- | BIP-44 account index, one per chat profile. type AccountIndex = Word32 --- | BIP-44 address index within a profile account. One per name. +-- | BIP-44 address index, 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. --- --- 'Show' is redacting: this is the root secret behind every name it owns. data WalletSeed = WalletSeed { wsId :: SeedId, wsEntropy :: ByteString @@ -73,14 +50,12 @@ data WalletSeed = WalletSeed instance Show WalletSeed where show s = "WalletSeed " <> show (wsId s) <> " " --- | What a chat profile stores: which seed, and which account index within it. data AccountRef = AccountRef { arSeedId :: SeedId, arIndex :: AccountIndex } deriving (Eq, Show) --- | A derived account: the reference plus the key it resolves to. data WalletAccount = WalletAccount { waRef :: AccountRef, waKey :: S.PrivateKey @@ -90,35 +65,23 @@ data WalletAccount = WalletAccount instance Show WalletAccount where show a = "WalletAccount " <> show (waRef a) <> " " --- | Fresh seed entropy. The caller stores it; this module never persists. --- A 25th-word passphrase is deliberately not used: it would be a second secret --- to back up. +-- | No 25th-word passphrase: it would be a second secret to back up. newSeed :: B39.MnemonicStrength -> TVar ChaChaDRG -> STM ByteString newSeed strength g = B39.mnemonicToEntropy <$> B39.randomMnemonic strength g --- | Import from a recovery phrase, validating the wordlist and the BIP-39 --- checksum. Returns the entropy; the caller persists it. importRecoveryKey :: ByteString -> Either String ByteString importRecoveryKey phrase = B39.mnemonicToEntropy <$> B39.parseMnemonic phrase --- | The phrase to show under "recovery key". Anyone who knows these words --- controls every name this seed owns, so the risk to state is theft, not loss. recoveryKeyPhrase :: WalletSeed -> Either String ByteString recoveryKeyPhrase s = B39.mnemonicPhrase <$> B39.entropyToMnemonic (wsEntropy s) --- | @m\/44'\/60'\/i'\/0\/k@ is 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. +-- | Standard BIP-44, so the phrase reaches the same addresses in other wallets. 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) @@ -126,6 +89,5 @@ deriveNameKey s acc nm = do 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 the name this key was derived for. accountAddress :: WalletAccount -> Address accountAddress = addressFromPrivateKey . waKey diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 3566fe353d..644b3cea3e 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -14,22 +14,17 @@ import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, derive import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec --- | The BIP-39 test vector every wallet ships with, so the addresses below can --- be checked against MetaMask and Ledger Live. +-- | The standard BIP-39 test vector, so the addresses can be checked elsewhere. testPhrase :: ByteString testPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" testSeed :: WalletSeed testSeed = WalletSeed {wsId = SeedId 1, wsEntropy = either error id $ importRecoveryKey testPhrase} --- | Derivation is the part other wallets have to agree with, so it is pinned --- here rather than left to the end-to-end tests. walletDerivationTests :: Spec walletDerivationTests = do - -- Name keys are plain BIP-44: 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. + -- profile 0's names are MetaMask's account list, and each profile's first + -- name is the matching Ledger Live account Hspec.it "name keys line up with other wallets' derivation" $ do let addrOf i k = either error (show . accountAddress) (deriveNameKey testSeed i k) -- MetaMask accounts 1 and 2 for this phrase @@ -42,22 +37,20 @@ walletDerivationTests = do renderNameKeyPath 2 7 `shouldBe` "m/44'/60'/2'/0/7" Hspec.it "round-trips the phrase it was imported from" $ recoveryKeyPhrase testSeed `shouldBe` Right testPhrase - -- Without the checksum a mistyped word imports a key that owns nothing. Hspec.it "refuses a phrase with a bad checksum" $ importRecoveryKey (B.unwords $ replicate 12 "abandon") `shouldSatisfy` isLeft walletTests :: SpecWith TestParams walletTests = do - it "creates no key until asked, then shows the derived addresses" testWalletCreate + it "creates no key until asked, then derives addresses" testWalletCreate it "the key and the addresses come back after a restart" testWalletPersists it "a second profile gets its own account, on the same key" testWalletSecondProfile it "imports a phrase, exports it, and refuses a second import" testWalletImport it "deletes the key only with the last word of the phrase" testWalletDelete --- | The derivation path and address of each name shown for a profile's account. accountRows :: HasCallStack => TestCC -> String -> Int -> IO [(String, String)] accountRows cc profile acct = do - cc <## (" account " <> show acct <> " (" <> profile <> ")") + cc <## ("account " <> show acct <> " (" <> profile <> ")") mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int] where nameRow l = case words l of @@ -66,81 +59,66 @@ accountRows cc profile acct = do testWalletCreate :: HasCallStack => TestParams -> IO () testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/wallet" - alice <## "no wallet key on this device - create one with /wallet create" - -- asked again: still nothing, and nothing was created by asking - alice ##> "/wallet" - alice <## "no wallet key on this device - create one with /wallet create" - alice ##> "/wallet export" - alice <## "bad chat command: no wallet key for this profile - create one with /wallet create" - alice ##> "/wallet create" - alice <## "key 1" + alice ##> "/_wallet" + alice <## "no wallet key" + -- asking creates nothing + alice ##> "/_wallet" + alice <## "no wallet key" + alice ##> "/_wallet export" + alice <## "bad chat command: no wallet key for this profile" + alice ##> "/_wallet create" rows <- accountRows alice "alice, active" 0 - -- one key per name: the two addresses differ and sit at consecutive indices map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] length (nub $ map snd rows) `shouldBe` 2 testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/wallet create" - alice <## "key 1" + alice ##> "/_wallet create" accountRows alice "alice, active" 0 - -- same database, new session: the seed has to come back from the DB, or the - -- name bought at that address is unreachable + -- same database, new session: a name bought at that address must stay reachable withTestChat ps "alice" $ \alice -> do - alice ##> "/wallet" - alice <## "key 1" + alice ##> "/_wallet" rows' <- accountRows alice "alice, active" 0 rows' `shouldBe` rows testWalletSecondProfile :: HasCallStack => TestParams -> IO () testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/wallet create" - alice <## "key 1" + alice ##> "/_wallet create" rows <- accountRows alice "alice, active" 0 alice ##> "/create user alisa" showActiveUser alice "alisa" - alice ##> "/wallet" - alice <## "key 1" + alice ##> "/_wallet" _ <- accountRows alice "alice" 0 - alice <## "this profile has no key yet - add one with /wallet create" - -- the same key, a different account, so the two profiles do not share names - alice ##> "/wallet create" - alice <## "key 1" + alice <## "this profile has no wallet key" + alice ##> "/_wallet create" _ <- accountRows alice "alice" 0 rows' <- accountRows alice "alisa, active" 1 null (map snd rows `intersect` map snd rows') `shouldBe` True testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/wallet import " <> B.unpack testPhrase) - alice <## "key 1" - alice <## " account 0 (alice, active)" - alice <## " name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" - alice <## " name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" - alice ##> "/wallet export" - alice <## "write this down - anyone who knows these words controls the names this key owns:" - alice <## (" " <> B.unpack testPhrase) - -- one key per device: a second would make the addresses shown depend on which - -- key was picked - alice ##> ("/wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice <## "account 0 (alice, active)" + alice <## " name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + alice <## " name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" + alice ##> "/_wallet export" + alice <## B.unpack testPhrase + alice ##> ("/_wallet import " <> B.unpack testPhrase) alice <## "bad chat command: this device already has a wallet key" -- a mistyped phrase says nothing about which word was wrong - alice ##> ("/wallet import " <> B.unpack (B.unwords $ replicate 12 "abandon")) + alice ##> ("/_wallet import " <> B.unpack (B.unwords $ replicate 12 "abandon")) alice <## "bad chat command: bad recovery phrase" testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/wallet import " <> B.unpack testPhrase) - alice <## "key 1" + alice ##> ("/_wallet import " <> B.unpack testPhrase) _ <- accountRows alice "alice, active" 0 - alice ##> "/wallet delete abandon" + alice ##> "/_wallet delete abandon" alice <## "bad chat command: to confirm, pass the last word of the recovery phrase" - alice ##> "/wallet delete about" - alice <## "no wallet key on this device - create one with /wallet create" - -- deleting unbinds the profile, so a real phrase can now be imported - alice ##> ("/wallet import " <> B.unpack testPhrase) - alice <## "key 1" + alice ##> "/_wallet delete about" + alice <## "no wallet key" + -- deleting unbinds the profile, so a key can be imported again + alice ##> ("/_wallet import " <> B.unpack testPhrase) _ <- accountRows alice "alice, active" 0 pure ()