diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 3deb592a8e..dceee7b794 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -66,7 +66,7 @@ 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, resolveAccount) -import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, checkAccountIndex, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, seedMaster, seedMnemonic) +import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, seedMaster, seedMnemonic) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -1520,8 +1520,7 @@ processChatCommand cxt nm = \case APIExportWalletMnemonic -> withUser $ \user -> CRWalletMnemonic user <$> (liftWallet . seedMnemonic . wsEntropy =<< walletSeed) APIExportWalletAccount n -> withUser $ \user@User {userId} -> do - seed <- walletSeed - liftWallet $ checkAccountIndex n + (seed, _) <- liftWallet =<< withFastStore' (`resolveAccount` Just n) -- a key another profile holds is not this profile's to hand out heldByOther <- withFastStore' $ \db -> accountHeldByOther db (wsId seed) userId n when heldByOther $ throwWalletError WEAccountBound @@ -6643,11 +6642,11 @@ chatCommandP = quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"') text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char - -- ten digits fit an Int, and the hardening bound is a typed error when the command runs + -- a long digit run is not free to convert; the hardening bound is checked when the command runs accountIndexP = do ds <- A.takeWhile1 isDigit - case if B.length ds <= 10 then B.readInt ds else Nothing of - Just (i, _) | i <= fromIntegral (maxBound :: AccountIndex) -> pure $ fromIntegral i + case if B.length ds <= 10 then B.readInteger ds else Nothing of + Just (i, _) | i <= toInteger (maxBound :: AccountIndex) -> pure (fromInteger i) _ -> fail "account index too large" displayNameP :: Parser Text diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 1e2a26d74e..c37ae7ed7e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1724,6 +1724,49 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS +CREATE TABLE test_chat_schema.wallet_accounts ( + wallet_account_id bigint NOT NULL, + wallet_seed_id bigint NOT NULL, + account_index bigint, + user_id bigint, + CONSTRAINT wallet_accounts_account_index_check CHECK (((account_index >= 0) AND (account_index <= 2147483647))) +); + + + +ALTER TABLE test_chat_schema.wallet_accounts ALTER COLUMN wallet_account_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.wallet_accounts_wallet_account_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, + next_account_index bigint, + single_seed smallint DEFAULT 1 NOT NULL, + CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = 32)), + CONSTRAINT wallet_seeds_next_account_index_check CHECK (((next_account_index >= 0) AND (next_account_index <= '2147483648'::bigint))) +); + + + +ALTER TABLE test_chat_schema.wallet_seeds ALTER COLUMN wallet_seed_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.wallet_seeds_wallet_seed_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.xftp_file_descriptions ( file_descr_id bigint NOT NULL, user_id bigint NOT NULL, @@ -2137,6 +2180,16 @@ ALTER TABLE ONLY test_chat_schema.users +ALTER TABLE ONLY test_chat_schema.wallet_accounts + ADD CONSTRAINT wallet_accounts_pkey PRIMARY KEY (wallet_account_id); + + + +ALTER TABLE ONLY test_chat_schema.wallet_seeds + ADD CONSTRAINT wallet_seeds_pkey PRIMARY KEY (wallet_seed_id); + + + ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_pkey PRIMARY KEY (file_descr_id); @@ -2938,6 +2991,18 @@ CREATE INDEX idx_users_shown_badge ON test_chat_schema.users USING btree (shown_ +CREATE INDEX idx_wallet_accounts_user_id ON test_chat_schema.wallet_accounts USING btree (user_id); + + + +CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON test_chat_schema.wallet_accounts USING btree (wallet_seed_id, account_index); + + + +CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON test_chat_schema.wallet_seeds USING btree (single_seed); + + + CREATE INDEX idx_xftp_file_descriptions_user_id ON test_chat_schema.xftp_file_descriptions USING btree (user_id); @@ -3663,6 +3728,16 @@ ALTER TABLE ONLY test_chat_schema.users +ALTER TABLE ONLY test_chat_schema.wallet_accounts + ADD CONSTRAINT wallet_accounts_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE SET NULL; + + + +ALTER TABLE ONLY test_chat_schema.wallet_accounts + ADD CONSTRAINT wallet_accounts_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/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 090b6332ab..d98ebb1534 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -975,6 +975,18 @@ CREATE TABLE badge_code_redemptions( created_at TEXT NOT NULL, UNIQUE(user_id, code) ) STRICT; +CREATE TABLE wallet_seeds( + wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, + entropy BLOB NOT NULL CHECK(length(entropy) = 32), -- BIP-39 entropy, 24 words + next_account_index INTEGER CHECK(next_account_index BETWEEN 0 AND 2147483648), + single_seed INTEGER NOT NULL DEFAULT 1 +) STRICT; +CREATE TABLE wallet_accounts( + wallet_account_id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, + account_index INTEGER CHECK(account_index BETWEEN 0 AND 2147483647), + user_id INTEGER REFERENCES users ON DELETE SET NULL +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1538,6 +1550,12 @@ CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions( CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases( badge_code_redemption_id ); +CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed); +CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts( + wallet_seed_id, + account_index +); +CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); 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 b804cf9f5c..d6589b464c 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -19,7 +19,7 @@ module Simplex.Chat.Store.Wallets ) where -import Control.Monad (join, unless, when) +import Control.Monad (join, unless) import Control.Monad.Except import Control.Monad.IO.Class (liftIO) import qualified Data.ByteArray as BA @@ -123,10 +123,8 @@ accountHeldByOther db sId userId n = heldByOther userId <$> accountUser db sId n bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError ()) bindAccount db userId accountIdx_ = runExceptT $ do (WalletSeed {wsId = sId}, n) <- ExceptT $ resolveAccount db accountIdx_ - held <- liftIO $ accountUser db sId n - when (heldByOther userId held) $ throwError WEAccountBound - taken <- liftIO $ case held of - Just (Just _) -> pure True -- already this profile's + taken <- liftIO $ accountUser db sId n >>= \case + Just (Just heldBy) -> pure $ heldBy == userId -- the update takes the account only while no profile holds it, the read after says whether this one got it Just Nothing -> setAccountUser db sId userId n >> accountHeldBy db sId userId n Nothing -> True <$ insertAccount db sId userId n diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 4f609591af..c134f6bb80 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -198,6 +198,9 @@ testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet address account=3" at3 <- getTermLine alice words at3 !! 1 `shouldBe` "m/44'/60'/3'/0/0" + -- a zero-padded index is the same index + alice ##> "/_wallet address account=0000000003" + getTermLine alice `shouldReturn` at3 -- a malformed index is a parse error, never a silent bind of the next account alice ##> "/_wallet bind account=abc" alice <## "bad chat command: Failed reading: empty"