diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 82dc113452..50ec31ffdb 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -169,6 +169,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges Simplex.Chat.Store.Postgres.Migrations.M20260921_wallet_seeds + Simplex.Chat.Store.Postgres.Migrations.M20260923_wallet_names else exposed-modules: Simplex.Chat.Archive @@ -345,6 +346,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges Simplex.Chat.Store.SQLite.Migrations.M20260921_wallet_seeds + Simplex.Chat.Store.SQLite.Migrations.M20260923_wallet_names other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 755378254a..edbd05f0d4 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -68,7 +68,7 @@ import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), cla import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldByOther, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, 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 (OwnedNames (..)) +import Simplex.Messaging.Names.Record (NameResponse, OwnedNames (..)) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -5971,17 +5971,17 @@ accountKey :: WalletSeed -> AccountIndex -> CM AccountKey accountKey seed n = liftWallet $ seedMaster (wsEntropy seed) >>= (`deriveAccountKey` n) -- | Walk the accounts until 'scanGapLimit' in a row are untouched, or 'scanMaxAccounts' is reached, asking each on a relay the scan has not used where the configured set allows. -scanAccounts :: NetworkRequestMode -> User -> WalletSeed -> CM [AccountIndex] +scanAccounts :: NetworkRequestMode -> User -> WalletSeed -> CM [(AccountIndex, [NameResponse])] scanAccounts nm user seed = do master <- liftWallet $ seedMaster (wsEntropy seed) - let go :: AccountIndex -> [SMPServer] -> [AccountIndex] -> Int -> CM [AccountIndex] + let go :: AccountIndex -> [SMPServer] -> [(AccountIndex, [NameResponse])] -> Int -> CM [(AccountIndex, [NameResponse])] go n used found gap | gap >= scanGapLimit || n >= scanMaxAccounts = pure (reverse found) | otherwise = do addr <- addressFromPrivateKey <$> liftWallet (deriveAccountKey master n) (srv, owned) <- withAgent $ \a -> ownedSimplexNames a nm (aUserId user) used addr 0 if ownInUse owned - then go (n + 1) (srv : used) (n : found) 0 + then go (n + 1) (srv : used) ((n, ownNames owned) : found) 0 else go (n + 1) (srv : used) found (gap + 1) go 0 [] [] 0 diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index f580298663..ddca9cde31 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -52,6 +52,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry import Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges import Simplex.Chat.Store.Postgres.Migrations.M20260921_wallet_seeds +import Simplex.Chat.Store.Postgres.Migrations.M20260923_wallet_names import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -103,7 +104,8 @@ schemaMigrations = ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), - ("20260921_wallet_seeds", m20260921_wallet_seeds, Nothing) + ("20260921_wallet_seeds", m20260921_wallet_seeds, Nothing), + ("20260923_wallet_names", m20260923_wallet_names, Nothing) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260923_wallet_names.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260923_wallet_names.hs new file mode 100644 index 0000000000..e4253ee667 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260923_wallet_names.hs @@ -0,0 +1,24 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260923_wallet_names where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260923_wallet_names :: Text +m20260923_wallet_names = + [r| +-- the columns are commented in the SQLite migration +CREATE TABLE wallet_names ( + wallet_name_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + wallet_seed_id BIGINT NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, + account_index BIGINT NOT NULL CHECK (account_index BETWEEN 0 AND 2147483647), + name TEXT NOT NULL, + name_response TEXT NOT NULL +); + +CREATE UNIQUE INDEX idx_wallet_names_wallet_seed_id_name ON wallet_names(wallet_seed_id, name); +|] + +-- No reverse step: wallet_seeds, which the rows hang off, has none either. diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index c37ae7ed7e..97b0000331 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1745,6 +1745,28 @@ ALTER TABLE test_chat_schema.wallet_accounts ALTER COLUMN wallet_account_id ADD +CREATE TABLE test_chat_schema.wallet_names ( + wallet_name_id bigint NOT NULL, + wallet_seed_id bigint NOT NULL, + account_index bigint NOT NULL, + name text NOT NULL, + name_response text NOT NULL, + CONSTRAINT wallet_names_account_index_check CHECK (((account_index >= 0) AND (account_index <= 2147483647))) +); + + + +ALTER TABLE test_chat_schema.wallet_names ALTER COLUMN wallet_name_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.wallet_names_wallet_name_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.wallet_seeds ( wallet_seed_id bigint NOT NULL, entropy bytea NOT NULL, @@ -2185,6 +2207,11 @@ ALTER TABLE ONLY test_chat_schema.wallet_accounts +ALTER TABLE ONLY test_chat_schema.wallet_names + ADD CONSTRAINT wallet_names_pkey PRIMARY KEY (wallet_name_id); + + + ALTER TABLE ONLY test_chat_schema.wallet_seeds ADD CONSTRAINT wallet_seeds_pkey PRIMARY KEY (wallet_seed_id); @@ -2999,6 +3026,10 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON test_cha +CREATE UNIQUE INDEX idx_wallet_names_wallet_seed_id_name ON test_chat_schema.wallet_names USING btree (wallet_seed_id, name); + + + CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON test_chat_schema.wallet_seeds USING btree (single_seed); @@ -3738,6 +3769,11 @@ ALTER TABLE ONLY test_chat_schema.wallet_accounts +ALTER TABLE ONLY test_chat_schema.wallet_names + ADD CONSTRAINT wallet_names_wallet_seed_id_fkey FOREIGN KEY (wallet_seed_id) REFERENCES test_chat_schema.wallet_seeds(wallet_seed_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index f6bea39306..a1dfa0c74e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -175,6 +175,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry import Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges import Simplex.Chat.Store.SQLite.Migrations.M20260921_wallet_seeds +import Simplex.Chat.Store.SQLite.Migrations.M20260923_wallet_names import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -349,7 +350,8 @@ schemaMigrations = ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), - ("20260921_wallet_seeds", m20260921_wallet_seeds, Nothing) + ("20260921_wallet_seeds", m20260921_wallet_seeds, Nothing), + ("20260923_wallet_names", m20260923_wallet_names, Nothing) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260923_wallet_names.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260923_wallet_names.hs new file mode 100644 index 0000000000..9d7db9f892 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260923_wallet_names.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260923_wallet_names where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260923_wallet_names :: Query +m20260923_wallet_names = + [sql| +CREATE TABLE wallet_names ( + wallet_name_id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, + account_index INTEGER NOT NULL CHECK (account_index BETWEEN 0 AND 2147483647), + name TEXT NOT NULL, + name_response TEXT NOT NULL -- the NameResponse a scan last read for this name +) STRICT; + +CREATE UNIQUE INDEX idx_wallet_names_wallet_seed_id_name ON wallet_names(wallet_seed_id, name); +|] + +-- No reverse step: wallet_seeds, which the rows hang off, has none either. diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index d98ebb1534..ef2fc5231e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -987,6 +987,13 @@ CREATE TABLE wallet_accounts( account_index INTEGER CHECK(account_index BETWEEN 0 AND 2147483647), user_id INTEGER REFERENCES users ON DELETE SET NULL ) STRICT; +CREATE TABLE wallet_names( + wallet_name_id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, + account_index INTEGER NOT NULL CHECK(account_index BETWEEN 0 AND 2147483647), + name TEXT NOT NULL, + name_response TEXT NOT NULL -- the NameResponse a scan last read for this name +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1556,6 +1563,10 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_a account_index ); CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); +CREATE UNIQUE INDEX idx_wallet_names_wallet_seed_id_name ON wallet_names( + wallet_seed_id, + name +); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index bd841b2178..0b225bd6e8 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -20,7 +20,7 @@ module Simplex.Chat.Store.Wallets ) where -import Control.Monad (join, unless) +import Control.Monad (forM_, join, unless) import Control.Monad.Except import Control.Monad.IO.Class (liftIO) import qualified Data.ByteArray as BA @@ -28,8 +28,10 @@ import Data.ByteString (ByteString) import Data.Int (Int64) import Simplex.Chat.Wallet (AccountIndex, WalletError (..), checkAccountIndex) import Simplex.Messaging.Agent.Protocol (UserId) +import Simplex.Messaging.Names.Record (NameRecord (..), NameRegistration (..), NameResponse (..)) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Util (encodeJSON) #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..)) @@ -145,8 +147,8 @@ setAccountUser db sId userId n = accountHeldBy :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool accountHeldBy db sId userId n = (== Just (Just userId)) <$> accountUser db sId n --- | The accounts a scan found, bound to the profile that ran it, and the counter raised past every account recorded, not just those found. -recordScan :: DB.Connection -> SeedId -> UserId -> [AccountIndex] -> IO () +-- | The accounts a scan found with the names each holds, bound to the profile that ran it, and the counter raised past every account recorded, not just those found. +recordScan :: DB.Connection -> SeedId -> UserId -> [(AccountIndex, [NameResponse])] -> IO () recordScan db sId userId inUse = do mapM_ insertScanned inUse DB.execute @@ -158,7 +160,7 @@ recordScan db sId userId inUse = do |] (sId, sId, sId) where - insertScanned n = + insertScanned (n, names) = do DB.execute db [sql| @@ -166,6 +168,20 @@ recordScan db sId userId inUse = do ON CONFLICT (wallet_seed_id, account_index) DO NOTHING |] (sId, accountIndexCol n, userId) + mapM_ (insertName n) names + -- a name the scan no longer sees is left alone: it reads one page per account, so unseen is not unowned + insertName n r = forM_ (registeredName r) $ \nm -> + DB.execute + db + [sql| + INSERT INTO wallet_names (wallet_seed_id, account_index, name, name_response) VALUES (?, ?, ?, ?) + ON CONFLICT (wallet_seed_id, name) + DO UPDATE SET account_index = EXCLUDED.account_index, name_response = EXCLUDED.name_response + |] + (sId, accountIndexCol n, nm, encodeJSON r) + registeredName NameResponse {registration} = case registration of + NRRegistered {nameRecord} -> Just $ nrName nameRecord + _ -> Nothing -- | Keep the counter a high-water mark. Never lowers it, never gives one to an imported phrase that has none. raiseNextAccount :: DB.Connection -> SeedId -> AccountIndex -> IO () diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index c19474af11..4a0fdcf088 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} @@ -12,6 +13,7 @@ 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 @@ -19,8 +21,11 @@ import NameResolver (ownedName) import Simplex.Chat.Wallet (AccountIndex, WalletError (..), accountSecret, deriveAccountKey, entropyFromMnemonic, renderAccountPath, seedMaster, seedMnemonic) import qualified Simplex.Messaging.Crypto.BIP39 as B39 import qualified Simplex.Messaging.Crypto.Secp256k1 as S +import Simplex.Messaging.Agent.Store.AgentStore (withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Eth.Address (addressFromPrivateKey) -import Simplex.Messaging.Util (safeDecodeUtf8) +import Simplex.Messaging.Names.Record (NameRecord (..), NameRegistration (..), NameResponse (..)) +import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec @@ -115,6 +120,10 @@ testWalletScan ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "alice" a ownedName reg "alice.simplex" (accountAddress 1) 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_names" + map (\(n, nm, _) -> (n, nm)) names `shouldBe` [(1 :: Int64, "alice.simplex" :: Text)] + map (\(_, _, 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" @@ -299,6 +308,12 @@ testWalletScanHiddenProfile ps = withSmpServerAndNames $ \_reg -> withNewTestCha alice ##> "/_wallet scan" alice <## "wallet: a hidden profile cannot own an account" +-- | The name a stored NameResponse carries, which is what makes the row findable again. +registeredName :: NameResponse -> Maybe Text +registeredName NameResponse {registration} = case registration of + NRRegistered {nameRecord} -> Just $ nrName nameRecord + _ -> Nothing + testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do phrase <- withNewTestChat ps "alice" aliceProfile $ \alice -> do