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 ()