diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 3e1dd00908..d30288146b 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -370,6 +370,7 @@ undocumentedCommands = "APICreateWallet", "APIDeleteChatTag", "APIDeleteWallet", + "APIGetWalletNames", "APIScanWallet", "APIDeleteMemberSupportChat", "APIDeleteReceivedReports", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index e6b0024374..8c6af54e87 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -219,5 +219,6 @@ undocumentedResponses = "CRWalletAccountSecret", "CRWalletAddress", "CRWalletMnemonic", + "CRWalletNames", "CRWelcome" ] diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index b252cfd0aa..2177ca8fbd 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -68,7 +68,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Chat.Wallet (AccountIndex, WalletAddress, WalletError) +import Simplex.Chat.Wallet (AccountIndex, WalletAddress, WalletError, WalletName) import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Server.Information (ServerPublicInfo) @@ -444,6 +444,7 @@ data ChatCommand | APIExportWalletMnemonic | APIExportWalletAccount {accountIndex :: AccountIndex} | APIScanWallet + | APIGetWalletNames | APIDeleteWallet | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType @@ -883,6 +884,7 @@ data ChatResponse | CRWallet {user :: User, accountIndexes_ :: Maybe [AccountIndex]} | CRWalletMnemonic {user :: User, mnemonic :: Text} | CRWalletAddress {user :: User, walletAddress :: WalletAddress} + | CRWalletNames {user :: User, walletNames :: [WalletName]} | CRWalletAccountSecret {user :: User, walletAddress :: WalletAddress, secret :: Text} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index edbd05f0d4..b34ac72c74 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 (..), BadgeSta 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.Store.Wallets (WalletSeed (..), accountHeldByOther, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, recordScan, resolveAccount) +import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldByOther, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getUserNames, getWalletSeed, recordScan, resolveAccount) import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, scanGapLimit, scanMaxAccounts, seedMaster, seedMnemonic) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Messaging.Names.Record (NameResponse, OwnedNames (..)) @@ -1527,6 +1527,8 @@ processChatCommand cxt nm = \case when heldByOther $ throwWalletError WEAccountBound k <- accountKey seed n pure $ CRWalletAccountSecret user (accountAddress n k) (accountSecret k) + APIGetWalletNames -> withUser $ \user@User {userId} -> + CRWalletNames user <$> withFastStore' (\db -> getWalletSeed db >>= maybe (pure []) (\WalletSeed {wsId} -> getUserNames db wsId userId)) APIScanWallet -> withUser $ \user@User {userId, viewPwdHash} -> do when (isJust viewPwdHash) $ throwWalletError WEHiddenProfile seed <- walletSeed @@ -6119,6 +6121,7 @@ chatCommandP = "/_wallet address" $> APIGetWalletAddress Nothing, "/_wallet export master" $> APIExportWalletMnemonic, "/_wallet export account " *> (APIExportWalletAccount <$> accountIndexP), + "/_wallet names" $> APIGetWalletNames, "/_wallet scan" $> APIScanWallet, "/_wallet delete" $> APIDeleteWallet, "/_wallet" $> APIGetWallet, diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 5a600a19f4..99be7fb2ca 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -14,6 +14,7 @@ module Simplex.Chat.Store.Wallets deleteWalletSeed, resolveAccount, getUserAccounts, + getUserNames, accountHeldByOther, bindAccount, recordScan, @@ -26,7 +27,7 @@ import Control.Monad.IO.Class (liftIO) import qualified Data.ByteArray as BA import Data.ByteString (ByteString) import Data.Int (Int64) -import Simplex.Chat.Wallet (AccountIndex, WalletError (..), checkAccountIndex) +import Simplex.Chat.Wallet (AccountIndex, WalletError (..), WalletName (..), checkAccountIndex) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Names.Record (NameRecord (..), NameRegistration (..), NameResponse (..)) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) @@ -107,6 +108,22 @@ getUserAccounts db sId userId = |] (sId, userId) +-- | The names the profile's accounts hold, as the last scan saw them. +getUserNames :: DB.Connection -> SeedId -> UserId -> IO [WalletName] +getUserNames db sId userId = + map toName + <$> DB.query + db + [sql| + SELECT n.account_index, n.name FROM wallet_owned_names n + JOIN wallet_accounts a ON a.wallet_seed_id = n.wallet_seed_id AND a.account_index = n.account_index + WHERE n.wallet_seed_id = ? AND a.user_id = ? + ORDER BY n.name + |] + (sId, userId) + where + toName (n, name) = WalletName {accountIndex = fromIntegral @Int64 n, name} + -- | Which profile holds an account: 'Nothing' when it is unknown, @Just Nothing@ when no profile holds it. accountUser :: DB.Connection -> SeedId -> AccountIndex -> IO (Maybe (Maybe Int64)) accountUser db sId n = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 051848e40f..be394c109e 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -60,7 +60,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Chat.Wallet (WalletAddress (..), WalletError (..)) +import Simplex.Chat.Wallet (WalletAddress (..), WalletError (..), WalletName (..)) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.Messaging.Agent (DatabaseDiff (..)) import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) @@ -202,6 +202,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te Just accounts -> [plain $ "accounts: " <> T.intercalate ", " (map tshow accounts)] CRWalletMnemonic u mnemonic -> ttyUser u [plain mnemonic] CRWalletAddress u a -> ttyUser u [walletAddressRow a] + CRWalletNames u names -> ttyUser u $ if null names then ["wallet, no names for this profile"] else map walletNameRow names CRWalletAccountSecret u a secret -> ttyUser u [walletAddressRow a <> " " <> plain secret] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView @@ -1114,6 +1115,9 @@ walletAddressRow :: WalletAddress -> StyledString walletAddressRow WalletAddress {accountIndex, keyPath, address} = plain $ tshow accountIndex <> " " <> keyPath <> " " <> address +walletNameRow :: WalletName -> StyledString +walletNameRow WalletName {accountIndex, name} = plain $ tshow accountIndex <> " " <> name + walletErrorText :: WalletError -> Text walletErrorText = \case WENoMaster -> "this device has no wallet" diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 47fa81c873..b36e01e5d9 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} @@ -6,6 +7,7 @@ module Simplex.Chat.Wallet ( AccountIndex, AccountKey, WalletAddress (..), + WalletName (..), WalletError (..), newSeedEntropy, entropyFromMnemonic, @@ -48,6 +50,13 @@ data WalletAddress = WalletAddress } deriving (Show) +-- | A name one of the seed's accounts holds, as the last scan saw it. +data WalletName = WalletName + { accountIndex :: AccountIndex, + name :: Text + } + deriving (Show) + data WalletError = WENoMaster -- the device has no master entropy | WEMasterExists -- create, when it already has one @@ -117,4 +126,6 @@ bipError = either (Left . WEDerivation) Right $(JQ.deriveJSON defaultJSON ''WalletAddress) +$(JQ.deriveJSON defaultJSON ''WalletName) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "WE") ''WalletError) diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 7350932c61..8501bcc79b 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -7,13 +7,13 @@ module WalletTests where import ChatClient import ChatTests.DBUtils import ChatTests.Utils +import Database.SQLite.Simple (Only (..)) import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (toUpper) import Data.Either (isRight) -import Data.Int (Int64) import Data.List (nub) import Data.Text (Text) import qualified Data.Text as T @@ -111,6 +111,7 @@ walletScanTests = do it "a scan of a recovered phrase finds the accounts in use" testWalletScan it "the counter a scan sets clears the accounts already bound" testWalletScanCounterPastBound it "a hidden profile cannot scan" testWalletScanHiddenProfile + it "a profile with no names says so" testWalletNamesEmpty testWalletScan :: HasCallStack => TestParams -> IO () testWalletScan ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -121,9 +122,10 @@ testWalletScan ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "alice" a alice ##> "/_wallet scan" alice <## "accounts: 1" -- the names the scan saw are recorded against the account that holds them - names <- withCCTransaction alice $ \db -> DB.query_ db "SELECT account_index, name, name_response FROM wallet_owned_names" - map (\(n, nm, _) -> (n, nm)) names `shouldBe` [(1 :: Int64, "alice.simplex" :: Text)] - map (\(_, _, r) -> registeredName <$> decodeJSON r) names `shouldBe` [Just (Just "alice.simplex")] + alice ##> "/_wallet names" + alice <## "1 alice.simplex" + names <- withCCTransaction alice $ \db -> DB.query_ db "SELECT name_response FROM wallet_owned_names" + map (\(Only r) -> registeredName <$> decodeJSON r) names `shouldBe` [Just (Just "alice.simplex")] -- the scan gives the imported phrase the counter it had none of alice ##> "/_wallet bind" alice <## "accounts: 1, 2" @@ -294,6 +296,14 @@ testWalletScanCounterPastBound ps = withSmpServerAndNames $ \_reg -> withNewTest alice ##> "/_wallet bind" alice <## "accounts: 5, 6" +-- | Names come from a scan, so a profile that has not scanned has none to list. +testWalletNamesEmpty :: HasCallStack => TestParams -> IO () +testWalletNamesEmpty ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile" + alice ##> "/_wallet names" + alice <## "wallet, no names for this profile" + -- | A scan binds what it finds, so it is refused where a bind is. testWalletScanHiddenProfile :: HasCallStack => TestParams -> IO () testWalletScanHiddenProfile ps = withSmpServerAndNames $ \_reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> do