diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index c301847806..2d0623fd0e 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -4644,15 +4644,6 @@ Handshake: **Discriminated union type**: -NoMaster: -- type: "noMaster" - -MasterExists: -- type: "masterExists" - -BadMnemonic: -- type: "badMnemonic" - AccountBound: - type: "accountBound" diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 7408275102..cacd768dbc 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -95,10 +95,7 @@ data WalletAddress = WalletAddress {accountIndex :: AccountIndex, keyPath :: Tex data WalletInfo = WalletInfo {accountIndexes :: [AccountIndex], nextAccountIndex :: Maybe Word32} -- Nothing: unknown after an import data WalletError - = WENoMaster -- the device has no master entropy - | WEMasterExists -- create, when the device already has one - | WEBadMnemonic -- wrong word count, wrong word, or bad checksum - | WEAccountBound -- bind, on an account another profile holds + = WEAccountBound -- bind, on an account another profile holds | WEAccountNotHeld -- export account, on an account the profile does not hold | WECounterUnknown -- the counter is not set yet, after an import | WEAccountsExhausted -- bind without an index, when the counter has passed every index diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 0d11598ff3..17d6594bdb 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -5308,40 +5308,18 @@ export interface VersionRange { } export type WalletError = - | WalletError.NoMaster - | WalletError.MasterExists - | WalletError.BadMnemonic | WalletError.AccountBound | WalletError.AccountNotHeld | WalletError.CounterUnknown | WalletError.AccountsExhausted export namespace WalletError { - export type Tag = - | "noMaster" - | "masterExists" - | "badMnemonic" - | "accountBound" - | "accountNotHeld" - | "counterUnknown" - | "accountsExhausted" + export type Tag = "accountBound" | "accountNotHeld" | "counterUnknown" | "accountsExhausted" interface Interface { type: Tag } - export interface NoMaster extends Interface { - type: "noMaster" - } - - export interface MasterExists extends Interface { - type: "masterExists" - } - - export interface BadMnemonic extends Interface { - type: "badMnemonic" - } - export interface AccountBound extends Interface { type: "accountBound" } diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 86243cb18b..eabba7fa41 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -3722,15 +3722,6 @@ class VersionRange(TypedDict): minVersion: int # int maxVersion: int # int -class WalletError_noMaster(TypedDict): - type: Literal["noMaster"] - -class WalletError_masterExists(TypedDict): - type: Literal["masterExists"] - -class WalletError_badMnemonic(TypedDict): - type: Literal["badMnemonic"] - class WalletError_accountBound(TypedDict): type: Literal["accountBound"] @@ -3744,16 +3735,13 @@ class WalletError_accountsExhausted(TypedDict): type: Literal["accountsExhausted"] WalletError = ( - WalletError_noMaster - | WalletError_masterExists - | WalletError_badMnemonic - | WalletError_accountBound + WalletError_accountBound | WalletError_accountNotHeld | WalletError_counterUnknown | WalletError_accountsExhausted ) -WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "accountBound", "accountNotHeld", "counterUnknown", "accountsExhausted"] +WalletError_Tag = Literal["accountBound", "accountNotHeld", "counterUnknown", "accountsExhausted"] class XFTPErrorType_BLOCK(TypedDict): type: Literal["BLOCK"] diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4fbcfcadb3..b43cc0a496 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -65,7 +65,7 @@ import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIss import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) -import Simplex.Chat.Wallet (AccountKey, WalletAddress, WalletError (..), WalletInfo (..), accountSecret, deriveAccount, entropyFromMnemonic, masterMnemonic, newEntropy) +import Simplex.Chat.Wallet (AccountKey, WalletAddress, WalletError (..), WalletInfo (..), accountSecret, deriveAccount, masterMnemonic, newEntropy) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -116,7 +116,7 @@ import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), pattern NRMInteractive, textToHostMode) import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.ShortLink as SL -import Simplex.Messaging.Crypto.BIP39 (WalletEntropy) +import Simplex.Messaging.Crypto.BIP39 (WalletEntropy, parsePhrase) import Simplex.Messaging.Crypto.BIP44 (AccountIndex, mkAccountIndex) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -1505,32 +1505,36 @@ processChatCommand cxt nm = \case CRWallet user <$> withFastStore (\db -> getWallet db >>= mapM (\Wallet {walletId, nextAccountIndex} -> liftIO $ (`WalletInfo` nextAccountIndex) <$> getUserAccounts db userId walletId)) APICreateWallet mnemonic_ -> withUser $ \user -> do wallet_ <- withFastStore getWallet - when (isJust wallet_) $ throwWalletError WEMasterExists + when (isJust wallet_) $ throwCmdError "this device already has a wallet" -- the counter starts at 1 for a generated seed, leaving account 0 to other wallets, and is unknown for an imported one (entropy, nextAccount) <- case mnemonic_ of Nothing -> (,Just 1) <$> (liftIO . newEntropy =<< asks random) - Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic phrase) + Just phrase -> (,Nothing) <$> either (const $ throwCmdError "not a valid recovery phrase") pure (parsePhrase phrase) -- derive the first account before storing, so stored entropy is valid for derivation void $ walletAccount entropy =<< liftDerivation (pure $ mkAccountIndex $ fromMaybe 0 nextAccount) created <- withFastStore' $ \db -> createWallet db entropy nextAccount - unless created $ throwWalletError WEMasterExists + unless created $ throwCmdError "this device already has a wallet" pure $ CRWallet user (Just $ WalletInfo [] nextAccount) APIBindWalletAccount userId accountIdx_ -> withUserId userId $ \user -> do - (entropy, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_ + w@Wallet {entropy} <- deviceWallet + n <- liftWallet =<< withFastStore' (\db -> bindAccount db userId w accountIdx_) CRWalletAddress user . snd <$> walletAccount entropy n APIGetWalletAddress accountIdx_ -> withUser $ \user -> do - (entropy, n) <- withWalletStore (`resolveAccount` accountIdx_) + w@Wallet {entropy} <- deviceWallet + n <- liftWallet $ resolveAccount w accountIdx_ CRWalletAddress user . snd <$> walletAccount entropy n APIExportWalletMnemonic -> withUser $ \user -> do - Wallet {entropy} <- withFastStore getWallet >>= maybe (throwWalletError WENoMaster) pure + Wallet {entropy} <- deviceWallet pure $ CRWalletMnemonic user (masterMnemonic entropy) APIExportWalletAccount userId n -> withUserId userId $ \user -> do - entropy <- withWalletStore $ \db -> heldAccount db userId n + Wallet {walletId, entropy} <- deviceWallet + held <- withFastStore' $ \db -> heldAccount db userId walletId n + unless held $ throwChatError $ CEWallet WEAccountNotHeld (k, a) <- walletAccount entropy n pure $ CRWalletAccountSecret user a (accountSecret k) APIDeleteWallet -> withUser_ $ do deleted <- withFastStore' deleteWallet - unless deleted $ throwWalletError WENoMaster + unless deleted $ throwCmdError "this device has no wallet" ok_ APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call @@ -6026,15 +6030,12 @@ withExpirationDate globalTTL chatItemTTL action = do let ttl = fromMaybe globalTTL chatItemTTL when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs -throwWalletError :: WalletError -> CM a -throwWalletError = throwChatError . CEWallet +deviceWallet :: CM Wallet +deviceWallet = withFastStore getWallet >>= maybe (throwCmdError "this device has no wallet") pure liftWallet :: Either WalletError a -> CM a liftWallet = liftEitherWith (ChatError . CEWallet) -withWalletStore :: (DB.Connection -> ExceptT StoreError IO (Either WalletError a)) -> CM a -withWalletStore action = liftWallet =<< withFastStore action - walletAccount :: WalletEntropy -> AccountIndex -> CM (AccountKey, WalletAddress) walletAccount entropy n = do g <- asks random diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 79979de505..2ce6df036d 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -3,7 +3,6 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -{-# LANGUAGE TupleSections #-} module Simplex.Chat.Store.Wallets ( Wallet (..), @@ -75,13 +74,10 @@ deleteWallet :: DB.Connection -> IO Bool deleteWallet db = rowReturned $ DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id" -resolveAccount :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (WalletEntropy, AccountIndex)) -resolveAccount db accountIdx_ = - getWallet db >>= \case - Nothing -> pure $ Left WENoMaster - Just Wallet {entropy, nextAccountIndex} -> pure $ (entropy,) <$> maybe next Right accountIdx_ - where - next = maybe (Left WECounterUnknown) (first (const WEAccountsExhausted) . mkAccountIndex) nextAccountIndex +resolveAccount :: Wallet -> Maybe AccountIndex -> Either WalletError AccountIndex +resolveAccount Wallet {nextAccountIndex} = maybe next Right + where + next = maybe (Left WECounterUnknown) (first (const WEAccountsExhausted) . mkAccountIndex) nextAccountIndex getUserAccounts :: DB.Connection -> UserId -> SeedId -> IO [AccountIndex] getUserAccounts db userId sId = @@ -101,21 +97,18 @@ accountUser db sId n = maybeFirstRow fromOnly $ DB.query db "SELECT user_id FROM wallet_accounts WHERE wallet_seed_id = ? AND account_index = ?" (sId, n) -bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (WalletEntropy, AccountIndex)) -bindAccount db userId accountIdx_ = - getWallet db >>= \case - Nothing -> pure $ Left WENoMaster - Just Wallet {walletId, entropy, nextAccountIndex} -> liftIO $ case accountIdx_ of - Nothing - | isNothing nextAccountIndex -> pure $ Left WECounterUnknown - | otherwise -> takeNextAccount db walletId >>= maybe (pure $ Left WEAccountsExhausted) (\n -> Right (entropy, n) <$ insertAccount db userId walletId n) - Just n -> do - held <- - accountUser db walletId n >>= \case - Just (Just heldBy) -> pure $ heldBy == userId - Just Nothing -> setAccountUser db userId walletId n - Nothing -> True <$ insertAccount db userId walletId n - if held then Right (entropy, n) <$ raiseNextAccount db walletId n else pure $ Left WEAccountBound +bindAccount :: DB.Connection -> UserId -> Wallet -> Maybe AccountIndex -> IO (Either WalletError AccountIndex) +bindAccount db userId Wallet {walletId, nextAccountIndex} accountIdx_ = case accountIdx_ of + Nothing + | isNothing nextAccountIndex -> pure $ Left WECounterUnknown + | otherwise -> takeNextAccount db walletId >>= maybe (pure $ Left WEAccountsExhausted) (\n -> Right n <$ insertAccount db userId walletId n) + Just n -> do + held <- + accountUser db walletId n >>= \case + Just (Just heldBy) -> pure $ heldBy == userId + Just Nothing -> setAccountUser db userId walletId n + Nothing -> True <$ insertAccount db userId walletId n + if held then Right n <$ raiseNextAccount db walletId n else pure $ Left WEAccountBound takeNextAccount :: DB.Connection -> SeedId -> IO (Maybe AccountIndex) takeNextAccount db sId = @@ -141,11 +134,8 @@ setAccountUser db userId sId n = |] (userId, sId, n) -heldAccount :: DB.Connection -> UserId -> AccountIndex -> ExceptT StoreError IO (Either WalletError WalletEntropy) -heldAccount db userId n = - getWallet db >>= \case - Nothing -> pure $ Left WENoMaster - Just Wallet {walletId, entropy} -> liftIO $ (\held -> if held == Just (Just userId) then Right entropy else Left WEAccountNotHeld) <$> accountUser db walletId n +heldAccount :: DB.Connection -> UserId -> SeedId -> AccountIndex -> IO Bool +heldAccount db userId sId n = (== Just (Just userId)) <$> accountUser db sId n raiseNextAccount :: DB.Connection -> SeedId -> AccountIndex -> IO () raiseNextAccount db sId n = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d0b650abca..b754536613 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -2817,9 +2817,6 @@ viewChatError isCmd logLevel testView = \case CEWallet walletErr -> let reason :: Text reason = case walletErr of - WENoMaster -> "this device has no wallet" - WEMasterExists -> "this device already has a wallet" - WEBadMnemonic -> "not a valid recovery phrase" WEAccountBound -> "another profile holds this account" WEAccountNotHeld -> "this profile does not hold this account" WECounterUnknown -> "the next account is unknown after an import" diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 59dff4d0d6..c9a9f8df11 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -7,7 +7,6 @@ module Simplex.Chat.Wallet WalletInfo (..), WalletError (..), newEntropy, - entropyFromMnemonic, masterMnemonic, deriveAccount, accountSecret, @@ -17,7 +16,6 @@ where import Control.Concurrent.STM import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as JQ -import Data.Bifunctor (first) import qualified Data.ByteArray.Encoding as BAE import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) @@ -45,10 +43,7 @@ data WalletInfo = WalletInfo deriving (Show) data WalletError - = WENoMaster - | WEMasterExists - | WEBadMnemonic - | WEAccountBound + = WEAccountBound | WEAccountNotHeld | WECounterUnknown | WEAccountsExhausted @@ -57,9 +52,6 @@ data WalletError newEntropy :: TVar ChaChaDRG -> IO B39.WalletEntropy newEntropy = atomically . B39.randomEntropy B39.ES256 -entropyFromMnemonic :: Text -> Either WalletError B39.WalletEntropy -entropyFromMnemonic = first (const WEBadMnemonic) . B39.parsePhrase - masterMnemonic :: B39.WalletEntropy -> Text masterMnemonic = decodeLatin1 . B39.entropyPhrase diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 5d3462ba18..6003c4e894 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -9,12 +9,11 @@ import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE import qualified Data.ByteString.Char8 as B import Data.Char (toUpper) -import Data.Either (isRight) import Data.List (nub) import Data.Text (Text) import qualified Data.Text as T import Data.Word (Word32) -import Simplex.Chat.Wallet (AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, masterMnemonic) +import Simplex.Chat.Wallet (AccountKey, WalletAddress (..), accountSecret, deriveAccount, masterMnemonic) import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.BIP39 as B39 import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex) @@ -74,10 +73,6 @@ walletDerivationTests = do (keyPath . snd <$> walletAccount testPhrase12 7) `shouldReturn` "m/44'/60'/7'/0/0" Hspec.it "round-trips the phrase it was imported from" $ masterMnemonic (walletEntropy testPhrase24) `shouldBe` testPhrase24 - Hspec.it "accepts a phrase of any BIP-39 length with a valid checksum" $ do - entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight - entropyFromMnemonic testPhrase12 `shouldSatisfy` isRight - entropyFromMnemonic (T.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic walletTests :: SpecWith TestParams walletTests = do @@ -99,21 +94,21 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet 1" alice <## "no wallet on this device" alice ##> "/_wallet export master" - alice <## "wallet: this device has no wallet" + alice <## "bad chat command: this device has no wallet" alice ##> "/_wallet bind 1" - alice <## "wallet: this device has no wallet" + alice <## "bad chat command: this device has no wallet" alice ##> "/_wallet delete" - alice <## "wallet: this device has no wallet" + alice <## "bad chat command: this device has no wallet" alice ##> "/_wallet 1" alice <## "no wallet on this device" alice ##> "/_wallet create new" alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/_wallet create new" - alice <## "wallet: this device already has a wallet" + alice <## "bad chat command: this device already has a wallet" alice ##> "/_wallet delete" alice <## "ok" alice ##> ("/_wallet create mnemonic=" <> unwords (replicate 24 "abandon")) - alice <## "wallet: not a valid recovery phrase" + alice <## "bad chat command: not a valid recovery phrase" alice ##> ("/_wallet create mnemonic=" <> map toUpper (T.unpack testPhrase24)) alice <## "wallet, next account unknown, no accounts for this profile" alice ##> "/_wallet export master"