From e5d18bd59e31459e18c05b5aaabf4669ec7c1a0c Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 9 Sep 2026 16:06:34 +0000 Subject: [PATCH] core: wallet export and delete act on the device key The key is one per device, so both commands read it directly instead of going through the active profile's account. A profile without an account of its own can now export, and the delete error says that the key goes for every profile on the device. The confirmation word is lowercased, as the phrase is when imported. Two known limitations are written down: account indexes restart at 0 after an import, so profiles are bound in the order they ask for a key, and the gap left by a hidden profile in the printed indexes shows that it exists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- src/Simplex/Chat/Library/Commands.hs | 13 +++++++------ .../SQLite/Migrations/M20260908_wallet_seeds.hs | 3 ++- .../Chat/Store/SQLite/Migrations/chat_schema.sql | 3 ++- src/Simplex/Chat/Store/Wallets.hs | 1 - tests/WalletTests.hs | 11 ++++++++--- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f80b1a23ca..08aca5dbf3 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -58,7 +58,7 @@ import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) -import Simplex.Chat.Store.Wallets (deleteSeed, getBoundAccount, getDeviceSeed, getOrCreateAccountRef, getSeedAccounts, importSeed) +import Simplex.Chat.Store.Wallets (deleteSeed, getDeviceSeed, getOrCreateAccountRef, getSeedAccounts, importSeed) import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -1496,7 +1496,8 @@ processChatCommand cxt nm = \case accs <- case seed_ of Nothing -> pure [] Just seed -> do - -- hidden profiles are left out, as they are by /users + -- hidden profiles are left out, as they are by /users, but the gap in + -- account indexes still shows that one exists 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 @@ -1515,17 +1516,17 @@ processChatCommand cxt nm = \case when (isNothing r) $ throwCmdError "this device already has a wallet key" processChatCommand cxt nm APIWallet APIWalletExport -> withUser $ \user -> do - (seed, _) <- withFastStore' (\db -> getBoundAccount db user) >>= maybe (throwCmdError noKeyError) pure + seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed pure $ CRWalletPhrase user (safeDecodeUtf8 phrase) APIWalletDelete confirmWord -> withUser $ \user -> do seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed case reverse . T.words $ safeDecodeUtf8 phrase of - w : _ | w == confirmWord -> do + w : _ | w == T.toLower confirmWord -> do withFastStore' $ \db -> deleteSeed db (wsId seed) processChatCommand cxt nm APIWallet - _ -> throwCmdError "to confirm, pass the last word of the recovery phrase" + _ -> throwCmdError "this deletes the wallet key for all profiles on this device, to confirm pass the last word of the recovery phrase" APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withFastStore $ \db -> getContact db cxt user contactId @@ -5467,7 +5468,7 @@ walletNamesShown :: NameIndex walletNamesShown = 2 noKeyError :: String -noKeyError = "no wallet key for this profile" +noKeyError = "no wallet key on this device" chatCommandP :: Parser ChatCommand chatCommandP = 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 fcd14ee593..4e7235ff40 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -12,7 +12,8 @@ CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes -- known issue: after import this starts at 0, so a recovered device can - -- re-issue an account that already owns names + -- re-issue an account that already owns names, and profiles are bound in + -- the order they ask for a key, not the order they had next_account_index INTEGER NOT NULL DEFAULT 0, -- one key per device for now single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index aa8adc9714..7a3b18af4d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -858,7 +858,8 @@ CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes -- known issue: after import this starts at 0, so a recovered device can - -- re-issue an account that already owns names + -- re-issue an account that already owns names, and profiles are bound in + -- the order they ask for a key, not the order they had next_account_index INTEGER NOT NULL DEFAULT 0, -- one key per device for now single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 90ddf96653..b7a41a1e79 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -6,7 +6,6 @@ module Simplex.Chat.Store.Wallets ( getDeviceSeed, - getBoundAccount, getSeedAccounts, getOrCreateAccountRef, importSeed, diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 644b3cea3e..3f90ea8852 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -65,7 +65,7 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet" alice <## "no wallet key" alice ##> "/_wallet export" - alice <## "bad chat command: no wallet key for this profile" + alice <## "bad chat command: no wallet key on this device" alice ##> "/_wallet create" rows <- accountRows alice "alice, active" 0 map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] @@ -86,11 +86,16 @@ testWalletSecondProfile :: HasCallStack => TestParams -> IO () testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create" rows <- accountRows alice "alice, active" 0 + alice ##> "/_wallet export" + phrase <- getTermLine alice alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> "/_wallet" _ <- accountRows alice "alice" 0 alice <## "this profile has no wallet key" + -- the key belongs to the device, so a profile without an account exports it too + alice ##> "/_wallet export" + alice <## phrase alice ##> "/_wallet create" _ <- accountRows alice "alice" 0 rows' <- accountRows alice "alisa, active" 1 @@ -115,8 +120,8 @@ testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) _ <- accountRows alice "alice, active" 0 alice ##> "/_wallet delete abandon" - alice <## "bad chat command: to confirm, pass the last word of the recovery phrase" - alice ##> "/_wallet delete about" + alice <## "bad chat command: this deletes the wallet key for all profiles on this device, to confirm pass the last word of the recovery phrase" + 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)