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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
This commit is contained in:
Alain Brenzikofer
2026-09-09 14:00:25 +00:00
co-authored by Claude Opus 5
parent ee11b368b6
commit 70d4c128bd
11 changed files with 69 additions and 178 deletions
-9
View File
@@ -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'/<profile>'/0/<name>`
(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
+1 -1
View File
@@ -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
+1 -26
View File
@@ -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 <topic> " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db", "wallet"],
indent <> highlight "/help <topic> " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db"],
indent <> highlight "/profile " <> " - show / update user profile",
indent <> highlight "/delete <contact>" <> " - 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 <phrase>" <> " - use a key you already have",
indent <> highlight "/wallet export " <> " - show your recovery phrase",
indent <> highlight "/wallet delete <word> " <> " - 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'/<profile>'/0/<name>" <> ". 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
+8 -9
View File
@@ -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),
@@ -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
);
@@ -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;
@@ -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(
+3 -10
View File
@@ -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)
+6 -11
View File
@@ -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
+8 -46
View File
@@ -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) <> " <redacted>"
-- | 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) <> " <redacted>"
-- | 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
+34 -56
View File
@@ -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 ()