diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index f1122979a9..7b7920690c 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -219,6 +219,7 @@ This file is generated automatically. - [UserProfileUpdateSummary](#userprofileupdatesummary) - [UserPwdHash](#userpwdhash) - [VersionRange](#versionrange) +- [WalletError](#walleterror) - [XFTPErrorType](#xftperrortype) - [XFTPRcvFile](#xftprcvfile) - [XFTPSndFile](#xftpsndfile) @@ -1231,6 +1232,10 @@ SimplexDomainNotReady: - simplexDomain: [SimplexDomain](#simplexdomain) - simplexDomainError: [SimplexDomainError](#simplexdomainerror) +Wallet: +- type: "wallet" +- walletError: [WalletError](#walleterror) + NotResolvedLocally: - type: "notResolvedLocally" @@ -4633,6 +4638,37 @@ Handshake: - maxVersion: int +--- + +## WalletError + +**Discriminated union type**: + +NoMaster: +- type: "noMaster" + +MasterExists: +- type: "masterExists" + +BadMnemonic: +- type: "badMnemonic" + +HiddenProfile: +- type: "hiddenProfile" + +AccountBound: +- type: "accountBound" + +AccountNotHeld: +- type: "accountNotHeld" + +CounterUnknown: +- type: "counterUnknown" + +AccountsExhausted: +- type: "accountsExhausted" + + --- ## XFTPErrorType diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 4ffba7bfbe..64ee8bf09a 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -347,6 +347,7 @@ undocumentedCommands = "APIAddGroupShortLink", "APIAddMyAddressShortLink", "APIArchiveReceivedReports", + "APIBindWalletAccount", "APICallStatus", "APIChangeConnectionUser", "APIChangePreparedContactUser", @@ -366,16 +367,20 @@ undocumentedCommands = "APICreateMemberContact", "APISendMemberContactInvitation", "APIAcceptMemberContact", + "APICreateWallet", "APIDeleteChatTag", "APIDeleteMemberSupportChat", "APIDeleteReceivedReports", "APIDeleteStorage", "APIDeleteToken", + "APIDeleteWallet", "APIDownloadStandaloneFile", "APIEnableContact", "APIEnableGroupMember", "APIEndCall", "APIExportArchive", + "APIExportWalletAccount", + "APIExportWalletMnemonic", "APIForwardChatItems", "APIGetAppSettings", "APIGetBadgeLedger", @@ -397,6 +402,8 @@ undocumentedCommands = "APIGetServerOperators", "APIGetUsageConditions", "APIGetUserServers", + "APIGetWallet", + "APIGetWalletAddress", "APIGroupInfo", "APIGetUpdatedGroupLinkData", "APIGroupMemberInfo", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 18392ae1b5..e6b0024374 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -215,5 +215,9 @@ undocumentedResponses = "CRUserServers", "CRUserServersValidation", "CRVersionInfo", + "CRWallet", + "CRWalletAccountSecret", + "CRWalletAddress", + "CRWalletMnemonic", "CRWelcome" ] diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index ac5992770e..f07fab0f9b 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -37,6 +37,7 @@ import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) import Simplex.Chat.Badges import Simplex.Chat.Badges.Service import Simplex.Chat.Names +import Simplex.Chat.Wallet (WalletError (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -407,6 +408,7 @@ chatTypesDocsData = (sti @UserInfo, STRecord, "", [], "", ""), (sti @UserProfileUpdateSummary, STRecord, "", [], "", ""), (sti @UserPwdHash, STRecord, "", [], "", ""), + (sti @WalletError, STUnion, "WE", [], "", ""), (sti @XFTPErrorType, STUnion, "", [], "", ""), (sti @XFTPRcvFile, STRecord, "", [], "", ""), (sti @XFTPSndFile, STRecord, "", [], "", ""), @@ -645,6 +647,7 @@ deriving instance Generic UserContactRequest deriving instance Generic UserInfo deriving instance Generic UserProfileUpdateSummary deriving instance Generic UserPwdHash +deriving instance Generic WalletError deriving instance Generic XFTPErrorType deriving instance Generic XFTPRcvFile deriving instance Generic XFTPSndFile diff --git a/cabal.project b/cabal.project index 0648c8b3e5..65e1edeccb 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 5eecd99bc2450705dfdc4982bb3604cc4942a51a + tag: 26e70edfbf1d23be5a2ea514d4950b419a49fd5c source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md new file mode 100644 index 0000000000..ddd41f314e --- /dev/null +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -0,0 +1,190 @@ +# Wallet keys + +## Problem + +The client has no static keys of its own for blockchain use cases. It requires keys at addresses it can derive again after a restart, after a database restore, or on a new device; otherwise whatever those addresses hold is lost with the device. + +The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain that contains the owner's address, and every name is owned by an account of its own, so one chat profile requires many accounts. The wallet is not specific to names. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will use accounts in the same way. A device also contains several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover the keys of all of them. + +Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the chain scan and the command that records its results, importing an account key that the master does not derive, more than one master, and stealth keys. Buying is not implemented yet either, so key derivation is merged first and can be reviewed on its own. + +## Security objectives + +1. Every key the device uses is derivable again from the master phrase alone. +2. Giving away one account key gives away that account and nothing else, whatever else the recipient has. +3. No extended public key links two accounts. +4. Key material is generated or imported only by an explicit command, never at startup; reading the wallet row re-derives the master key to check it, and account keys are derived only by the command that uses them. +5. A hidden profile owns nothing on chain, so nothing on chain is linked to it. + +The master phrase derives every account and an account secret controls one account; no export covers anything in between, such as "this profile's accounts", because the profile is not an input to the derivation. + +## Design + +### Accounts + +A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) entropy, the **master**, generated as 24 words. Nothing signs with it. Every key is a [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) account under it, at a hardened account index, which is how Ledger Live lays out an Ethereum wallet. + +Each thing the device owns on chain is assigned an **account index**, and the key at that index is the **account key**. Names are the first use: one name, one account, and that account's address is the owner address in the name record. Which chat profile an account belongs to is a mapping in the database. No path contains a profile, so no profile data is an input to the derivation, and the mapping can be changed without changing any key. + +``` +master seed the only key material to back up +└── m/44'/60'/n'/0/0 account n, n >= 0 +``` + +An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. So the master phrase imported into another wallet derives the same addresses, and the two tools use the word for the same thing. That wallet discovers account 0 and stops, because BIP-44 specifies that discovery stops at the first account with no transaction history, and this app leaves account 0 empty (known limit 10) and an account that only owns a name has none; above it the user has to enter the path in a wallet that accepts one, such as MEW, Rabby or Frame, since Ledger Live, MetaMask and Trezor Suite do not, which leaves the exported account key for those. + +The tests pin the derivation against the standard `abandon ... about` test mnemonic, which is 12 words, with an empty BIP-39 passphrase. Addresses are in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) mixed case. + +``` +account 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 +account 1 m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 +``` + +### Why an account for each name + +The alternative is one account owning several names. A name's owner address is public, so that address would link them, and that is as true inside one profile as across two. An account for each name creates no such link, and the same holds for anything else an account comes to own. + +### Why the account level is hardened + +The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a known weakness there: the extended public key of a parent, together with one non-hardened child's private key, yields the parent private key and from it every sibling. An exported account key is one half, and any wallet that enumerates accounts produces the other. The two levels below an account are not hardened, so the two halves together reveal the extended private key at the account level, m/44'/60'/n'; nothing else is derived under an account by this app and the account level itself is hardened, so they reveal no other account's key; known limit 10 covers account 0, under which other wallets do derive. This satisfies objectives 2 and 3, and it is worth the loss of MetaMask's default path. + +### Why 24 words + +Entropy cannot be added to a seed afterwards and the uses of a seed can grow, so a generated master is 24 words, 256 bits. An imported phrase is accepted at any BIP-39 length, 12 to 24 words, because its strength was decided by the wallet that generated it and other wallets default to 12. + +## Profiles and accounts + +An account is bound to at most one chat profile, and a profile to any number of accounts, because a profile can own any number of names. Only one of those names points at the profile as its SimpleX domain name, but which one is recorded by the names layer, not by the wallet: the wallet records only which profile an account belongs to. + +Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds. An account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user neither buys anything nor requests an address has never derived an account key. + +An account cannot be bound to a hidden profile, so no name can be bought for a hidden profile. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; the random profile of an incognito connection is not a chat profile, so it cannot hold an account. + +## Commands + +These commands are an internal API, called by the names commands and by any later feature that binds accounts, rather than typed by users. Nothing here runs at startup. + +``` +/_wallet whether the device has a master, the next + account, and the accounts bound to the profile +/_wallet create new generate the master, 24 words +/_wallet create mnemonic= import the master entropy from a phrase, 12 to 24 words +/_wallet bind account= bind an account to the profile and return its + address; without account=, the next free one +/_wallet address account= one address, with the account index it is + derived at; without account=, the next free one +/_wallet export master the master phrase +/_wallet export account the secret of an account the profile holds, + 0x and 64 hex digits +/_wallet delete delete the master entropy and its accounts +``` + +A command that acts on a profile's accounts names the profile and is rejected when that profile is not the active one, so switching profiles during a flow cannot bind an account to the wrong profile. + +`create` always names its source, so no key material is generated by typing a prefix. An imported phrase must be 12, 15, 18, 21 or 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with a passphrase elsewhere derives different keys here. + +`bind` without `account=` binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed. + +BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. For the one key in 2^128 that BIP-32 declares invalid, simplexmq recomputes it as SLIP-0010 specifies and Trezor implements, instead of skipping the index, up to three times; a third failure is reported as an internal error, not a wallet condition, since no input is expected to reach it. An account index is a simplexmq type that only holds values below 2^31, so the command parser rejects 2^31 and above as a bad command, the columns have CHECK constraints for that bound, and reading a row that violates it is an error. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened, which `bind` without an index reports. + +`address` reads the counter without changing it, so two calls return the same address, and it derives an address for an account the database has no row for, which a device that lost its database requires. One address per call is sufficient: a caller that scans the tree calls it in a loop. + +`export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index does not return, because the counter is past it or unknown; the exception is a database restored from a backup (known limit 9). An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves whatever the accounts own on chain, recoverable only from the phrase. + +```haskell +data WalletAddress = WalletAddress {accountIndex :: AccountIndex, keyPath :: Text, address :: Address} +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 + | WEHiddenProfile -- bind, on a hidden profile + | 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 +``` + +## Recovery + +Three cases, by how much of the database is restored. + +**Only the phrase is left.** A phrase encodes the entropy and nothing else. Which accounts were used is not encoded in it, so the counter starts out unknown and only a scan of the chain sets it; which profile held which account is not encoded in it either, and nothing recovers that. The scan is a sequence of `address` calls, one for each candidate: account 0, then 1, and so on, querying what each address owns and stopping after a run of addresses that own nothing. Querying what an address owns and choosing the length of that run are the caller's responsibility, because `address` returns only an address. Nothing outside this API adds rows to these tables, so a command added together with the scan writes a row for each account found, unbound, and moves the counter past every account row, including accounts bound by index that the scan does not find. + +What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own identify them, which is what makes the choice possible at all: the user chooses between names they recognise, not between numbers. Binding changes no key and signs nothing, because ownership does not change, only the profile under which the app shows the account. Pointing a name at that profile's SimpleX address is a separate signed edit of the name's record. + +**The database is restored from a backup.** It contains the accounts as of the backup and nothing written after it, so its counter is behind if an account was bound after the backup. A counter that is behind is worse than one that is unknown, because it appears valid, so `bind` can return an account that is already in use. Nothing here distinguishes a restored database from a current one, so clearing the counter is the responsibility of whatever restores a database, along with the scan that sets it again. + +**The database is current.** It records which profile holds which account, so the user is asked nothing. + +## Schema + +```sql +CREATE TABLE wallet_seeds ( + wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, + entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), + master BLOB NOT NULL CHECK (length(master) = 64), + next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), -- null means not known yet + 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), -- null when the key was imported + user_id INTEGER REFERENCES users ON DELETE SET NULL +) STRICT; + +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); +``` + +The master entropy, 32 bytes when generated and 16 to 32 bytes when imported, is stored with the BIP-32 master key it derives, the 32-byte private key followed by the 32-byte chain code. Reading the row recomputes the master key from the entropy and rejects a row where the two do not match, so a wallet read from the database always derives accounts. An account key is never stored, because the master key and an account index derive it whenever it is required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes. + +`users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile can hold any number of accounts. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The migration has no down migration, because a down migration would delete the master entropy, which may have no other copy. A down migration runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. Without a down migration the older app reports that the database is newer than the app, and changes nothing. + +A null `account_index` marks an account whose key was imported rather than derived. The master phrase does not recover such an account, and the schema must not suggest that it does. Importing one is not implemented here; the column is nullable now so that a row written later is read correctly. That feature also requires storage for the imported secret and an optional link to a seed, because such an account belongs to no seed and must not be deleted with one; on SQLite, making `wallet_seed_id` nullable rebuilds the table. + +## Threat model + +- **Someone with the database file.** Gets everything, now and later: the stored entropy is the master phrase in another encoding, so an archive exported to move devices contains every key on the device. No export granularity protects against a file copy. On SQLite the connection sets `secure_delete`, so a deleted row's pages are zeroed; the journal and any copy already made are not. +- **Someone with one account key.** Can act as that account's owner permanently, because an export is a copy and the device keeps deriving the same key. Cannot derive another account's key. +- **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which gives that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. +- **Whoever answers the recovery scan.** Receives every address the scan derives from the phrase, in one sequence of requests, so it can link every account on the device, across profiles, and recognise addresses that own nothing yet, which is where future accounts will be. `address` derives an address for any index directly from the master, so a caller can enumerate hidden profiles' addresses too. This is the largest privacy cost of the design. +- **A paired device.** Wallet commands are allowed from a paired device like other chat commands: `export master` returns the whole wallet, `create mnemonic=` on a device that has no seed imports a phrase that the paired device sends, and `delete` deletes the master entropy, which may have no other copy. +- **Someone reading the logs.** The core logs no command and no response, so a phrase passed to `create` is not written to any log. On Postgres the client inlines parameters into the statement text, so server-side statement logging records the inserted entropy and master key. +- **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs`, which runs only with `--chat-server-port` and listens on 127.0.0.1, accepts any local connection, requires no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the response. It also prints every command it receives, that phrase included. Both are properties of that server, which this change makes a more valuable target, and closing them requires changes to that server rather than to the wallet. + +## Known limits + +1. **There is no per-profile transfer.** The master phrase derives every account on the device and an account secret controls one account, with nothing in between, so a user who wants to move one profile's names to another wallet has to export one key for each name. +2. **The phrase alone does not restore a device.** Until the scan is implemented, an imported phrase gives a device its keys and no way to find what they own. +3. **The scan's design is not decided.** Finding the names an address owns is not a query a registry answers on chain, so it requires the registrar or an indexer, and that choice determines which party observes the scan. The length of the run of empty accounts that ends the scan is not yet decided, and the user must be able to extend the scan. +4. **The same phrase on two devices collides.** The counter is stored in one database, so both devices bind the same account and each treats it as free. Sharing the counter requires a backup both devices can read. +5. **A profile hidden after an account was bound to it keeps its accounts.** The check runs at bind time only. +6. **Account indexes are not dense.** An account can be bound and never used, and a run of empty accounts ends the scan, so an account after a gap can be missed. +7. **Other wallets do not discover the accounts.** A generated wallet leaves account 0 empty, so BIP-44 discovery in Ledger Live or Trezor Suite stops before the first account this app uses, whether or not later accounts hold funds or paid gas; they are reached only by entering the path or with the exported key. If an account ever pays for anything, whatever funds it links accounts on chain. +8. **Nothing records which layout a seed was used with.** Another wallet may have derived accounts from the phrase at paths this doc does not describe. +9. **`bind` on a restored database can return an account that is already in use.** Its counter can be below the highest index used, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. +10. **Account 0 is the primary account of other wallets.** MetaMask, Ledger Live and Trezor Suite present `m/44'/60'/0'/0/0` first, and their hardware integrations hand a browser the extended public key of its address level, so with a phrase used in one of them the export of account 0 exposes that wallet's accounts. A generated wallet therefore starts its counter at 1, and account 0 is bound or exported only when asked for with `account=0`. + +## Main files + +- `src/Simplex/Chat/Wallet.hs`, derivation. +- `src/Simplex/Chat/Store/Wallets.hs`, the two tables. +- `src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs` and the corresponding Postgres migration. +- `tests/WalletTests.hs`. +- `tests/SchemaDump.hs` and `tests/PostgresSchemaDump.hs`, which selected the migrations to test as every migration after the last one without a down migration, and now select every migration from the first one that has a down migration, applying any that has none. +- Derivation uses the `BIP32`, `BIP39`, `Secp256k1` and `Eth.Address` modules from simplexmq (simplex-chat/simplexmq#1843) and adds no dependency to this package. + +## What is verified + +**File:** `tests/WalletTests.hs`. Each of these is a test, not a claim. + +1. **Vectors.** The two addresses above are derived from `abandon ... about`, as is account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command derives a pinned address end to end, so a change of path fails here rather than in a release, and the account a command names is the account whose key is returned. +2. **Isolation.** Ten accounts' addresses are all different, and the paths of accounts 0 and 7 have a hardened account component. +3. **Rejections.** A second generate; a phrase with a bad checksum; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; `bind` without an index once the counter has passed 2^31 - 1; an index of 2^31 or more is rejected as a bad command on `address`, `bind` and `export account` alike. +4. **Binding and reads.** Several accounts are bound to one profile, each `bind` returns the account it bound, and that profile's accounts can be exported; binding an account by index moves the counter past it so the next one does not collide, and never moves it back; `bind account=` binds an account after an import; an account left unbound by deleting its profile is bound to another profile; and two consecutive `address` calls return the same address without changing the counter, and derive an address for an account with no row. +5. **Encoding and persistence.** An account secret whose first byte is zero is rendered with 64 hex digits; the wallet, its accounts, the counter and the phrase persist across a restart; and deleting the wallet deletes its accounts and resets the counter. diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index d7db7f259f..11bc46d7be 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -1180,6 +1180,7 @@ export type ChatErrorType = | ChatErrorType.ChatStoreChanged | ChatErrorType.InvalidConnReq | ChatErrorType.SimplexDomainNotReady + | ChatErrorType.Wallet | ChatErrorType.NotResolvedLocally | ChatErrorType.UnsupportedConnReq | ChatErrorType.ConnReqMessageProhibited @@ -1260,6 +1261,7 @@ export namespace ChatErrorType { | "chatStoreChanged" | "invalidConnReq" | "simplexDomainNotReady" + | "wallet" | "notResolvedLocally" | "unsupportedConnReq" | "connReqMessageProhibited" @@ -1422,6 +1424,11 @@ export namespace ChatErrorType { simplexDomainError: SimplexDomainError } + export interface Wallet extends Interface { + type: "wallet" + walletError: WalletError + } + export interface NotResolvedLocally extends Interface { type: "notResolvedLocally" } @@ -5300,6 +5307,64 @@ export interface VersionRange { maxVersion: number // int } +export type WalletError = + | WalletError.NoMaster + | WalletError.MasterExists + | WalletError.BadMnemonic + | WalletError.HiddenProfile + | WalletError.AccountBound + | WalletError.AccountNotHeld + | WalletError.CounterUnknown + | WalletError.AccountsExhausted + +export namespace WalletError { + export type Tag = + | "noMaster" + | "masterExists" + | "badMnemonic" + | "hiddenProfile" + | "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 HiddenProfile extends Interface { + type: "hiddenProfile" + } + + export interface AccountBound extends Interface { + type: "accountBound" + } + + export interface AccountNotHeld extends Interface { + type: "accountNotHeld" + } + + export interface CounterUnknown extends Interface { + type: "counterUnknown" + } + + export interface AccountsExhausted extends Interface { + type: "accountsExhausted" + } +} + export type XFTPErrorType = | XFTPErrorType.BLOCK | XFTPErrorType.SESSION 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 ab06a24f53..00983026fe 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -883,6 +883,10 @@ class ChatErrorType_simplexDomainNotReady(TypedDict): simplexDomain: "SimplexDomain" simplexDomainError: "SimplexDomainError" +class ChatErrorType_wallet(TypedDict): + type: Literal["wallet"] + walletError: "WalletError" + class ChatErrorType_notResolvedLocally(TypedDict): type: Literal["notResolvedLocally"] @@ -1121,6 +1125,7 @@ ChatErrorType = ( | ChatErrorType_chatStoreChanged | ChatErrorType_invalidConnReq | ChatErrorType_simplexDomainNotReady + | ChatErrorType_wallet | ChatErrorType_notResolvedLocally | ChatErrorType_unsupportedConnReq | ChatErrorType_connReqMessageProhibited @@ -1179,7 +1184,7 @@ ChatErrorType = ( | ChatErrorType_exception ) -ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "badgeRedeemError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] +ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "wallet", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "badgeRedeemError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"] @@ -3717,6 +3722,43 @@ 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_hiddenProfile(TypedDict): + type: Literal["hiddenProfile"] + +class WalletError_accountBound(TypedDict): + type: Literal["accountBound"] + +class WalletError_accountNotHeld(TypedDict): + type: Literal["accountNotHeld"] + +class WalletError_counterUnknown(TypedDict): + type: Literal["counterUnknown"] + +class WalletError_accountsExhausted(TypedDict): + type: Literal["accountsExhausted"] + +WalletError = ( + WalletError_noMaster + | WalletError_masterExists + | WalletError_badMnemonic + | WalletError_hiddenProfile + | WalletError_accountBound + | WalletError_accountNotHeld + | WalletError_counterUnknown + | WalletError_accountsExhausted +) + +WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "accountNotHeld", "counterUnknown", "accountsExhausted"] + class XFTPErrorType_BLOCK(TypedDict): type: Literal["BLOCK"] diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 074cf7123b..a669b4401c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."5eecd99bc2450705dfdc4982bb3604cc4942a51a" = "08jwx1inj96dgbq9vwry9fcprx9sjf9w2a7d4cdfbrfp2xwv52kg"; + "https://github.com/simplex-chat/simplexmq.git"."26e70edfbf1d23be5a2ea514d4950b419a49fd5c" = "1kj700rpnl0797mg7nn5a9kmqxlnpqdy41ffnwpdn30mcdlahfzx"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 38e02cfd18..126e5812c6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -94,6 +94,7 @@ library Simplex.Chat.Store.RelayRequests Simplex.Chat.Store.Remote Simplex.Chat.Store.Shared + Simplex.Chat.Store.Wallets Simplex.Chat.Styled Simplex.Chat.Types Simplex.Chat.Types.MemberRelations @@ -101,6 +102,7 @@ library Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme Simplex.Chat.Util + Simplex.Chat.Wallet Simplex.Chat.Web if !flag(client_library) exposed-modules: @@ -168,6 +170,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json + Simplex.Chat.Store.Postgres.Migrations.M20260924_wallet_seeds else exposed-modules: Simplex.Chat.Archive @@ -345,6 +348,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json + Simplex.Chat.Store.SQLite.Migrations.M20260924_wallet_seeds other-modules: Paths_simplex_chat hs-source-dirs: @@ -706,6 +710,7 @@ test-suite simplex-chat-test RemoteTests ValidNames ViewTests + WalletTests API.Docs.Commands API.Docs.Events API.Docs.Generate diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 9cc80fc54b..82b7308ea0 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -69,7 +69,9 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Chat.Util (liftIOEither) +import Simplex.Chat.Wallet (WalletAddress, WalletError, WalletInfo) import Simplex.FileTransfer.Description (FileDescriptionURI) +import Simplex.Messaging.Crypto.BIP44 (AccountIndex) import Simplex.Messaging.Server.Information (ServerPublicInfo) import Simplex.Messaging.Agent (AgentClient, DatabaseDiff, SubscriptionsInfo) import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo) @@ -436,6 +438,13 @@ data ChatCommand | APIRejectContact {contactReqId :: Int64, notify :: Bool} | APISendServiceRequest {userId :: UserId, sendTarget :: ConnectTarget 'CMContact, requestTimeout :: Maybe NominalDiffTime, signKey :: Maybe (C.StoredPrivateKey 'C.Ed25519), request :: J.Object} | APISendServiceResponse {userId :: UserId, requestId :: AgentInvId, responseData :: J.Object} + | APIGetWallet {userId :: UserId} + | APICreateWallet {mnemonic :: Maybe Text} + | APIBindWalletAccount {userId :: UserId, accountIndex_ :: Maybe AccountIndex} + | APIGetWalletAddress {accountIndex_ :: Maybe AccountIndex} + | APIExportWalletMnemonic + | APIExportWalletAccount {userId :: UserId, accountIndex :: AccountIndex} + | APIDeleteWallet | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType | APIRejectCall ContactId @@ -871,6 +880,10 @@ data ChatResponse | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState} | CRBadgeState {user :: User, badgeState :: Maybe BadgeState} | CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]} + | CRWallet {user :: User, walletInfo_ :: Maybe WalletInfo} + | CRWalletMnemonic {user :: User, mnemonic :: Text} + | CRWalletAddress {user :: User, walletAddress :: WalletAddress} + | 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} | CRGroupsList {user :: User, groups :: [GroupInfo]} @@ -1511,6 +1524,7 @@ data ChatErrorType | CEChatStoreChanged | CEInvalidConnReq | CESimplexDomainNotReady {simplexDomain :: SimplexDomain, simplexDomainError :: SimplexDomainError} + | CEWallet {walletError :: WalletError} | CENotResolvedLocally -- a name or link is not a known chat in the local store and online resolution is off (PRMNever) | CEUnsupportedConnReq | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d02a189b1d..32f7821fc3 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -65,6 +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, newWalletMaster) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -95,6 +96,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared +import Simplex.Chat.Store.Wallets (Wallet (..), bindAccount, createWallet, deleteWallet, getUserAccounts, getWallet, heldAccount, resolveAccount) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -114,6 +116,8 @@ 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.BIP32 (WalletMaster, mkWalletMaster) +import Simplex.Messaging.Crypto.BIP44 (AccountIndex, mkAccountIndex) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (E2ERatchetParamsUri (..), InitialKeys (..), PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQSupportOff, pattern PQSupportOn) @@ -1497,6 +1501,36 @@ processChatCommand cxt nm = \case let AgentInvId invId = requestId connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData) pure $ CRServiceReplyAccepted user (AgentConnId connId) + APIGetWallet userId -> withUserId userId $ \user -> + 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 + -- the counter starts at 1 for a generated seed, leaving account 0 to other wallets, and is unknown for an imported one + (master, nextAccount) <- case mnemonic_ of + Nothing -> (,Just 1) <$> (liftDerivation . newWalletMaster =<< asks random) + Just phrase -> (,Nothing) <$> (liftDerivation . pure . mkWalletMaster =<< liftWallet (entropyFromMnemonic phrase)) + created <- withFastStore' $ \db -> createWallet db master nextAccount + unless created $ throwWalletError WEMasterExists + pure $ CRWallet user (Just $ WalletInfo [] nextAccount) + APIBindWalletAccount userId accountIdx_ -> withUserId userId $ \user@User {viewPwdHash} -> do + when (isJust viewPwdHash) $ throwWalletError WEHiddenProfile + (master, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_ + CRWalletAddress user . snd <$> walletAccount master n + APIGetWalletAddress accountIdx_ -> withUser $ \user -> do + (master, n) <- withWalletStore (`resolveAccount` accountIdx_) + CRWalletAddress user . snd <$> walletAccount master n + APIExportWalletMnemonic -> withUser $ \user -> do + Wallet {walletMaster} <- withFastStore getWallet >>= maybe (throwWalletError WENoMaster) pure + pure $ CRWalletMnemonic user (masterMnemonic walletMaster) + APIExportWalletAccount userId n -> withUserId userId $ \user -> do + master <- withWalletStore $ \db -> heldAccount db userId n + (k, a) <- walletAccount master n + pure $ CRWalletAccountSecret user a (accountSecret k) + APIDeleteWallet -> withUser_ $ do + deleted <- withFastStore' deleteWallet + unless deleted $ throwWalletError WENoMaster + ok_ APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withFastStore $ \db -> getContact db cxt user contactId @@ -5991,6 +6025,23 @@ 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 + +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 :: WalletMaster -> AccountIndex -> CM (AccountKey, WalletAddress) +walletAccount master n = do + g <- asks random + liftDerivation $ deriveAccount g master n + +liftDerivation :: IO (Either String a) -> CM a +liftDerivation = liftError' (ChatError . CEInternalError) + chatCommandP :: Parser ChatCommand chatCommandP = choice @@ -6113,6 +6164,14 @@ chatCommandP = "/_badge ledger " *> (APIGetBadgeLedger <$> A.decimal <* A.space <*> A.decimal), "/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP), "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), + "/_wallet create new" $> APICreateWallet Nothing, + "/_wallet create mnemonic=" *> (APICreateWallet . Just <$> textP), + "/_wallet bind " *> (APIBindWalletAccount <$> A.decimal <*> optional (" account=" *> accountIndexP)), + "/_wallet address" *> (APIGetWalletAddress <$> optional (" account=" *> accountIndexP)), + "/_wallet export master" $> APIExportWalletMnemonic, + "/_wallet export account " *> (APIExportWalletAccount <$> A.decimal <* A.space <*> accountIndexP), + "/_wallet delete" $> APIDeleteWallet, + "/_wallet " *> (APIGetWallet <$> A.decimal), "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), "/_call reject @" *> (APIRejectCall <$> A.decimal), @@ -6656,6 +6715,13 @@ chatCommandP = quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"') text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char + accountIndexP = do + ds <- A.lookAhead $ A.takeWhile1 isDigit + when (B.length (B.dropWhile (== '0') ds) > 10) tooLarge + i <- A.decimal + if i <= toInteger (maxBound :: Word32) then either fail pure $ mkAccountIndex (fromInteger i) else tooLarge + where + tooLarge = fail "account index too large" displayNameP :: Parser Text displayNameP = safeDecodeUtf8 <$> displayNameP_ diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 4de4f4f6b7..6dd34a8d2f 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -552,7 +552,6 @@ liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do - logDebug $ "Send: " <> tshow command -- execCC is execChatCommand CSRemoteCtrl, which checks allowRemoteCommand -- convert errors thrown in execCC into error responses to prevent aborting the protocol wrapper RRChatResponse . eitherToResult <$> execCC (encodeUtf8 command) retryNum diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 24ed45c9a9..60ae8f999e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -53,6 +53,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json +import Simplex.Chat.Store.Postgres.Migrations.M20260924_wallet_seeds import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -105,7 +106,8 @@ schemaMigrations = ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), - ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json) + ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), + ("20260924_wallet_seeds", m20260924_wallet_seeds, Nothing) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs new file mode 100644 index 0000000000..9cff8f3d9c --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260924_wallet_seeds where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260924_wallet_seeds :: Text +m20260924_wallet_seeds = + [r| +CREATE TABLE wallet_seeds ( + wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + entropy BYTEA NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), + master BYTEA NOT NULL CHECK (length(master) = 64), + next_account_index BIGINT CHECK (next_account_index BETWEEN 0 AND 2147483648), + single_seed SMALLINT NOT NULL DEFAULT 1 +); + +CREATE TABLE wallet_accounts ( + wallet_account_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + wallet_seed_id BIGINT NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, + account_index BIGINT CHECK (account_index BETWEEN 0 AND 2147483647), + user_id BIGINT REFERENCES users ON DELETE SET NULL +); + +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); +|] + +-- no down migration, see the SQLite migration diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index ad975874db..b31b19b1af 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1730,6 +1730,51 @@ 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, + master bytea NOT NULL, + next_account_index bigint, + single_seed smallint DEFAULT 1 NOT NULL, + CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = ANY (ARRAY[16, 20, 24, 28, 32]))), + CONSTRAINT wallet_seeds_master_check CHECK ((length(master) = 64)), + 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, @@ -2143,6 +2188,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); @@ -2944,6 +2999,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); @@ -3669,6 +3736,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.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 4dcefa1598..9e6ff903a6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -176,6 +176,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json +import Simplex.Chat.Store.SQLite.Migrations.M20260924_wallet_seeds import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -351,7 +352,8 @@ schemaMigrations = ("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges), ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), - ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json) + ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), + ("20260924_wallet_seeds", m20260924_wallet_seeds, Nothing) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs new file mode 100644 index 0000000000..bb389633bd --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs @@ -0,0 +1,31 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260924_wallet_seeds where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260924_wallet_seeds :: Query +m20260924_wallet_seeds = + [sql| +CREATE TABLE wallet_seeds ( + wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, + entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), + master BLOB NOT NULL CHECK (length(master) = 64), + 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 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); +|] + +-- No down migration: it would delete the master entropy, which may have no other copy. diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 152d6c0c8d..a2a70276d7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -2048,6 +2048,14 @@ Query: Plan: +Query: + INSERT INTO wallet_seeds (entropy, master, next_account_index) VALUES (?, ?, ?) + ON CONFLICT (single_seed) DO NOTHING + RETURNING wallet_seed_id + +Plan: +SEARCH wallet_accounts USING COVERING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=?) + Query: SELECT (SELECT prev.balance_start_ts FROM badge_ledger prev @@ -3502,6 +3510,14 @@ SCAN CONSTANT ROW SCALAR SUBQUERY 1 SEARCH groups USING INDEX idx_groups_relay_request_group_link (user_id=? AND relay_request_group_link=?) +Query: + SELECT account_index FROM wallet_accounts + WHERE wallet_seed_id = ? AND user_id = ? AND account_index IS NOT NULL + ORDER BY account_index + +Plan: +SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=? AND account_index>?) + Query: SELECT agent_conn_id FROM connections @@ -4400,6 +4416,14 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE wallet_accounts SET user_id = ? + WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL + RETURNING wallet_account_id + +Plan: +SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=? AND account_index=?) + Query: WITH user_groups AS MATERIALIZED ( SELECT g.group_id @@ -5707,6 +5731,13 @@ Query: Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE wallet_seeds SET next_account_index = ? + WHERE wallet_seed_id = ? AND next_account_index IS NOT NULL AND next_account_index <= ? + +Plan: +SEARCH wallet_seeds USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE xftp_file_descriptions SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ?, updated_at = ? @@ -7109,6 +7140,7 @@ SEARCH connections USING COVERING INDEX idx_connections_user_contact_link_id (us Query: DELETE FROM users WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH wallet_accounts USING COVERING INDEX idx_wallet_accounts_user_id (user_id=?) SEARCH badge_code_redemptions USING COVERING INDEX idx_badge_code_redemptions_user (user_id=?) SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_user (user_id=?) SEARCH chat_relays USING COVERING INDEX idx_chat_relays_user_id (user_id=?) @@ -7136,6 +7168,11 @@ SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_2 (user_id=?) SEARCH display_names USING COVERING INDEX sqlite_autoindex_display_names_2 (user_id=?) SEARCH contact_profiles USING COVERING INDEX idx_contact_profiles_user_id (user_id=?) +Query: DELETE FROM wallet_seeds RETURNING wallet_seed_id +Plan: +SCAN wallet_seeds +SEARCH wallet_accounts USING COVERING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=?) + Query: DROP TABLE IF EXISTS temp_delete_members Plan: @@ -7259,6 +7296,9 @@ Plan: Query: INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, auto_accept_group_invitations, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?) Plan: +Query: INSERT INTO wallet_accounts (wallet_seed_id, account_index, user_id) VALUES (?, ?, ?) +Plan: + Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?) Plan: @@ -7768,6 +7808,10 @@ Query: SELECT user_id FROM users WHERE local_display_name = ? Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) +Query: SELECT user_id FROM wallet_accounts WHERE wallet_seed_id = ? AND account_index = ? +Plan: +SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=? AND account_index=?) + Query: SELECT via_contact_uri FROM connections WHERE connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) @@ -7776,6 +7820,10 @@ Query: SELECT via_contact_uri, via_contact_uri_hash FROM connections WHERE conne Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT wallet_seed_id, entropy, master, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1 +Plan: +SCAN wallet_seeds + Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 995497d5ee..b3f1c1a4f8 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -981,6 +981,19 @@ 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) IN(16, 20, 24, 28, 32)), + master BLOB NOT NULL CHECK(length(master) = 64), + 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 @@ -1544,6 +1557,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 new file mode 100644 index 0000000000..074cfc0e8d --- /dev/null +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -0,0 +1,152 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TupleSections #-} + +module Simplex.Chat.Store.Wallets + ( SeedId, + Wallet (..), + getWallet, + createWallet, + deleteWallet, + resolveAccount, + getUserAccounts, + heldAccount, + bindAccount, + ) +where + +import Control.Monad.Except +import Data.Bifunctor (first) +import Control.Monad.IO.Class (liftIO) +import qualified Data.ByteArray as BA +import Data.ByteString (ByteString) +import Data.Int (Int64) +import Data.Word (Word32) +import Simplex.Chat.Store.Shared (StoreError (..)) +import Simplex.Chat.Wallet (WalletError (..)) +import Simplex.Messaging.Agent.Protocol (UserId) +import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Crypto.BIP32 (WalletMaster, masterBytes, masterEntropy, parseWalletMaster) +import Simplex.Messaging.Crypto.BIP39 (unEntropy) +import Simplex.Messaging.Crypto.BIP44 (AccountIndex, mkAccountIndex, unAccountIndex) +import Simplex.Messaging.Util (liftEitherWith) + +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +type SeedId = Int64 + +data Wallet = Wallet + { walletId :: SeedId, + walletMaster :: WalletMaster, + nextAccountIndex :: Maybe Word32 + } + +getWallet :: DB.Connection -> ExceptT StoreError IO (Maybe Wallet) +getWallet db = + liftIO (maybeFirstRow id $ DB.query_ db "SELECT wallet_seed_id, entropy, master, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1") + >>= mapM toWallet + where + toWallet :: (SeedId, ByteString, ByteString, Maybe Word32) -> ExceptT StoreError IO Wallet + toWallet (walletId, entropy, master, nextAccountIndex) = + liftEitherWith SEInternalError $ (\walletMaster -> Wallet {walletId, walletMaster, nextAccountIndex}) <$> parseWalletMaster (BA.convert entropy) (BA.convert master) + +createWallet :: DB.Connection -> WalletMaster -> Maybe Word32 -> IO Bool +createWallet db master nextAccount = + rowReturned $ + DB.query + db + [sql| + INSERT INTO wallet_seeds (entropy, master, next_account_index) VALUES (?, ?, ?) + ON CONFLICT (single_seed) DO NOTHING + RETURNING wallet_seed_id + |] + (DB.Binary (BA.convert (unEntropy $ masterEntropy master) :: ByteString), DB.Binary (BA.convert (masterBytes master) :: ByteString), nextAccount) + +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 (WalletMaster, AccountIndex)) +resolveAccount db accountIdx_ = fmap (first walletMaster) <$> resolveAccount_ db accountIdx_ + +resolveAccount_ :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (Wallet, AccountIndex)) +resolveAccount_ db accountIdx_ = + getWallet db >>= \case + Nothing -> pure $ Left WENoMaster + Just w@Wallet {nextAccountIndex} -> pure $ (w,) <$> maybe next Right accountIdx_ + where + next = maybe (Left WECounterUnknown) (first (const WEAccountsExhausted) . mkAccountIndex) nextAccountIndex + +getUserAccounts :: DB.Connection -> UserId -> SeedId -> IO [AccountIndex] +getUserAccounts db userId sId = + map fromOnly + <$> DB.query + db + [sql| + SELECT account_index FROM wallet_accounts + WHERE wallet_seed_id = ? AND user_id = ? AND account_index IS NOT NULL + ORDER BY account_index + |] + (sId, userId) + +-- | Which profile holds an account: 'Nothing' when there is no row for it, @Just Nothing@ when no profile holds it. +accountUser :: DB.Connection -> SeedId -> AccountIndex -> IO (Maybe (Maybe UserId)) +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 (WalletMaster, AccountIndex)) +bindAccount db userId accountIdx_ = resolveAccount_ db accountIdx_ >>= either (pure . Left) (liftIO . bind) + where + bind (Wallet {walletId, walletMaster}, 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 (walletMaster, n) <$ raiseNextAccount db walletId n else pure $ Left WEAccountBound + +setAccountUser :: DB.Connection -> UserId -> SeedId -> AccountIndex -> IO Bool +setAccountUser db userId sId n = + rowReturned $ + DB.query + db + [sql| + UPDATE wallet_accounts SET user_id = ? + WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL + RETURNING wallet_account_id + |] + (userId, sId, n) + +heldAccount :: DB.Connection -> UserId -> AccountIndex -> ExceptT StoreError IO (Either WalletError WalletMaster) +heldAccount db userId n = + getWallet db >>= \case + Nothing -> pure $ Left WENoMaster + Just Wallet {walletId, walletMaster} -> liftIO $ (\held -> if held == Just (Just userId) then Right walletMaster else Left WEAccountNotHeld) <$> accountUser db walletId n + +raiseNextAccount :: DB.Connection -> SeedId -> AccountIndex -> IO () +raiseNextAccount db sId n = + DB.execute + db + [sql| + UPDATE wallet_seeds SET next_account_index = ? + WHERE wallet_seed_id = ? AND next_account_index IS NOT NULL AND next_account_index <= ? + |] + (unAccountIndex n + 1, sId, n) + +insertAccount :: DB.Connection -> UserId -> SeedId -> AccountIndex -> IO () +insertAccount db userId sId n = + DB.execute db "INSERT INTO wallet_accounts (wallet_seed_id, account_index, user_id) VALUES (?, ?, ?)" (sId, n, userId) + +rowReturned :: IO [Only Int64] -> IO Bool +rowReturned = fmap (not . null) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 1c62bc267b..d0b11d6cbf 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -60,6 +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 (..), WalletInfo (..)) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.Messaging.Agent (DatabaseDiff (..)) import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) @@ -68,6 +69,7 @@ import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BIP44 (unAccountIndex) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding @@ -195,6 +197,15 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] CRBadgeState u st -> ttyUser u $ viewUserBadgeState st CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries + CRWallet u info_ -> ttyUser u $ case info_ of + Nothing -> ["no wallet on this device"] + Just WalletInfo {accountIndexes, nextAccountIndex} -> + let next = maybe "unknown" tshow nextAccountIndex + accounts = if null accountIndexes then "no accounts for this profile" else "accounts: " <> T.intercalate ", " (map (tshow . unAccountIndex) accountIndexes) + in [plain $ "wallet, next account " <> next <> ", " <> accounts] + CRWalletMnemonic u mnemonic -> ttyUser u [plain mnemonic] + CRWalletAddress u a -> ttyUser u [walletAddressRow a] + 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 CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results @@ -1102,6 +1113,10 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of ContactConnection _ -> [] CInfoInvalidJSON {} -> [] +walletAddressRow :: WalletAddress -> StyledString +walletAddressRow WalletAddress {accountIndex, keyPath, address} = + plain $ tshow (unAccountIndex accountIndex) <> " " <> keyPath <> " " <> safeDecodeUtf8 (strEncode address) + viewContactsList :: [Contact] -> [StyledString] viewContactsList = let getLDN :: Contact -> ContactName @@ -2799,6 +2814,18 @@ viewChatError isCmd logLevel testView = \case SDENoValidLink -> "has no valid connection link" SDEUnknownDomain -> "is not included in the connection link's profile" in [plain $ "SimpleX name " <> strEncode domain <> " " <> reason] + 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" + WEHiddenProfile -> "an account cannot be bound to a hidden profile" + WEAccountBound -> "another profile holds this account" + WEAccountNotHeld -> "this profile does not hold this account" + WECounterUnknown -> "the next account is unknown after an import" + WEAccountsExhausted -> "every account index is used" + in [plain $ "wallet: " <> reason] CENotResolvedLocally -> ["no matching chat found, name resolution is disabled"] CEUnsupportedConnReq -> [ "", "Connection link is not supported by the your app version, please ugrade it.", plain updateStr] CEInvalidChatMessage Connection {connId} msgMeta_ msg e -> diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs new file mode 100644 index 0000000000..4dfb37db97 --- /dev/null +++ b/src/Simplex/Chat/Wallet.hs @@ -0,0 +1,83 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Wallet + ( AccountKey, + WalletAddress (..), + WalletInfo (..), + WalletError (..), + newWalletMaster, + entropyFromMnemonic, + masterMnemonic, + deriveAccount, + accountSecret, + ) +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) +import Data.Word (Word32) +import qualified Simplex.Messaging.Crypto.BIP32 as B32 +import qualified Simplex.Messaging.Crypto.BIP39 as B39 +import Simplex.Messaging.Crypto.BIP44 (AccountIndex, CoinType (..), bip44Path) +import qualified Simplex.Messaging.Crypto.Secp256k1 as S +import Simplex.Messaging.Eth.Address (Address, deriveAddress) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) + +type AccountKey = S.Secp256k1PrivateKey + +data WalletAddress = WalletAddress + { accountIndex :: AccountIndex, + keyPath :: Text, + address :: Address + } + deriving (Show) + +data WalletInfo = WalletInfo + { accountIndexes :: [AccountIndex], + nextAccountIndex :: Maybe Word32 + } + deriving (Show) + +data WalletError + = WENoMaster + | WEMasterExists + | WEBadMnemonic + | WEHiddenProfile + | WEAccountBound + | WEAccountNotHeld + | WECounterUnknown + | WEAccountsExhausted + deriving (Eq, Show) + +masterStrength :: B39.EntropyStrength +masterStrength = B39.ES256 + +newWalletMaster :: TVar ChaChaDRG -> IO (Either String B32.WalletMaster) +newWalletMaster g = B32.mkWalletMaster <$> atomically (B39.randomEntropy masterStrength g) + +entropyFromMnemonic :: Text -> Either WalletError B39.WalletEntropy +entropyFromMnemonic = first (const WEBadMnemonic) . B39.parsePhrase + +masterMnemonic :: B32.WalletMaster -> Text +masterMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy + +deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (Either String (AccountKey, WalletAddress)) +deriveAccount g master n = fmap account <$> deriveAddress g (B32.walletMasterKey master) path + where + path = bip44Path Ethereum n + account (xk, a) = (B32.xkKey xk, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = a}) + +accountSecret :: AccountKey -> Text +accountSecret k = "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 $ S.unPrivateKey k) + +$(JQ.deriveJSON defaultJSON ''WalletAddress) + +$(JQ.deriveJSON defaultJSON ''WalletInfo) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "WE") ''WalletError) diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index b36b7faeaf..6809955f57 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -7,10 +7,9 @@ module PostgresSchemaDump (postgresSchemaDumpTest) where import ChatTests.Utils hiding (it) import Control.Concurrent (threadDelay) import Control.DeepSeq -import Control.Monad (unless, void) +import Control.Monad (forM_, unless, void) import qualified Data.ByteString.Char8 as B -import Data.List (dropWhileEnd) -import Data.Maybe (fromJust, isJust) +import Data.Maybe (isNothing) import Simplex.Messaging.Agent.Store.Postgres (closeDBStore, createDBStore) import Simplex.Messaging.Agent.Store.Postgres.Common (DBOpts (..)) import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations @@ -36,17 +35,17 @@ postgresSchemaDumpTest migrations testDBOpts@DBOpts {connstr, schema = testDBSch getSchema srcSchemaPath `shouldReturn` savedSchema testSchemaMigrations = do - let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) migrations + let noDownMigrations = takeWhile (\Migration {down} -> isNothing down) migrations st <- createDBStore testDBOpts noDownMigrations (MigrationConfig MCYesUpDown Nothing) >>= \case Right st -> pure st Left e -> error $ show e - mapM_ (testDownMigration st) $ drop (length noDownMigrations) migrations + forM_ (drop (length noDownMigrations) migrations) $ \m -> + maybe (Migrations.run st Nothing $ MTRUp [m]) (testDownMigration st m) (toDownMigration m) closeDBStore st whenM (doesFileExist testSchemaPath) $ removeFile testSchemaPath where - testDownMigration st m = do + testDownMigration st m downMigr = do putStrLn $ "down migration " <> name m - let downMigr = fromJust $ toDownMigration m schema <- getSchema testSchemaPath Migrations.run st Nothing $ MTRUp [m] schema' <- getSchema testSchemaPath diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 783f6587bf..d16a14af2c 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -10,10 +10,10 @@ import ChatTests.DBUtils import Control.Concurrent.STM import Control.DeepSeq import qualified Control.Exception as E -import Control.Monad (unless, void) -import Data.List (dropWhileEnd, sort) +import Control.Monad (forM_, unless, void) +import Data.List (sort) import qualified Data.Map.Strict as M -import Data.Maybe (fromJust, isJust) +import Data.Maybe (isNothing) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as T @@ -79,16 +79,16 @@ testVerifyLintFKeyIndexes = withTmpFiles $ do testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do - let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations + let noDownMigrations = takeWhile (\Migration {down} -> isNothing down) Store.migrations Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) - mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations + forM_ (drop (length noDownMigrations) Store.migrations) $ \m -> + maybe (Migrations.run st Nothing True $ MTRUp [m]) (testDownMigration st m) (toDownMigration m) closeDBStore st removeFile testDB whenM (doesFileExist testSchema) $ removeFile testSchema where - testDownMigration st m = do + testDownMigration st m downMigr = do putStrLn $ "down migration " <> name m - let downMigr = fromJust $ toDownMigration m schema <- getSchema testDB testSchema Migrations.run st Nothing True $ MTRUp [m] schema' <- getSchema testDB testSchema diff --git a/tests/Test.hs b/tests/Test.hs index dd2a7b8d59..5eb91325e6 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -32,6 +32,7 @@ import Test.Hspec hiding (it) import UnliftIO.Temporary (withTempDirectory) import ValidNames import ViewTests +import WalletTests #if defined(dbPostgres) import Control.Exception (bracket_) import PostgresSchemaDump @@ -81,6 +82,7 @@ main = do describe "JSON Tests" jsonTests describe "Member relations" memberRelationsTests describe "SimpleX chat view" viewTests + describe "Wallet derivation" walletDerivationTests describe "SimpleX chat protocol" protocolTests describe "Valid names" validNameTests describe "Message batching" batchingTests @@ -100,6 +102,7 @@ main = do describe "Mobile API Tests" mobileTests #endif describe "SimpleX chat client" chatTests + describe "Wallet" walletTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests xdescribe'' "SimpleX badge service e2e" badgeServiceTests diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs new file mode 100644 index 0000000000..6f3719fde8 --- /dev/null +++ b/tests/WalletTests.hs @@ -0,0 +1,323 @@ +{-# LANGUAGE OverloadedStrings #-} + +module WalletTests where + +import ChatClient +import ChatTests.DBUtils +import ChatTests.Utils +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 qualified Simplex.Messaging.Crypto as C +import qualified Simplex.Messaging.Crypto.BIP32 as B32 +import qualified Simplex.Messaging.Crypto.BIP39 as B39 +import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex) +import qualified Simplex.Messaging.Crypto.Secp256k1 as S +import Simplex.Messaging.Encoding.String (strEncode) +import Simplex.Messaging.Eth.Address (addressFromPrivateKey) +import Test.Hspec hiding (it) +import qualified Test.Hspec as Hspec + +testPhrase12 :: Text +testPhrase12 = T.unwords $ replicate 11 "abandon" <> ["about"] + +testPhrase24 :: Text +testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"] + +walletMaster :: Text -> B32.WalletMaster +walletMaster phrase = either error id $ B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) + +walletAccount :: Text -> Word32 -> IO (AccountKey, WalletAddress) +walletAccount phrase n = do + g <- C.newRandom + either error id <$> deriveAccount g (walletMaster phrase) (either error id $ mkAccountIndex n) + +addressFromSecret :: String -> IO String +addressFromSecret secret = do + g <- C.newRandom + let k = either error id $ S.mkPrivateKey (either error id $ BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)) + B.unpack . strEncode <$> addressFromPrivateKey g k + +exportRow :: HasCallStack => String -> (String, String, String, String) +exportRow row = case words row of + [idx, path, addr, secret] -> (idx, path, addr, secret) + _ -> error $ "unexpected export row: " <> row + +accountBound :: HasCallStack => TestCC -> String -> Expectation +accountBound cc idx = (take 1 . words <$> getTermLine cc) `shouldReturn` [idx] + +walletDerivationTests :: Spec +walletDerivationTests = do + Hspec.it "derives the addresses another wallet derives for the same phrase" $ do + let addrOf n = strEncode . address . snd <$> walletAccount testPhrase12 n + addrOf 0 `shouldReturn` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + addrOf 1 `shouldReturn` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" + Hspec.it "the exported secret is the one another wallet shows for that account" $ do + (k, _) <- walletAccount testPhrase12 0 + accountSecret k `shouldBe` "0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" + Hspec.it "every account has its own address" $ do + addrs <- mapM (fmap (address . snd) . walletAccount testPhrase12) [0 .. 9] + length (nub addrs) `shouldBe` 10 + Hspec.it "renders a secret whose first byte is zero with 64 hex digits" $ do + let k = either error id $ S.mkPrivateKey (BA.convert $ B.pack ('\0' : replicate 31 '\1')) + let secret = T.unpack $ accountSecret k + take 4 secret `shouldBe` "0x00" + length secret `shouldBe` 66 + Hspec.it "renders the path an account is derived at" $ do + (keyPath . snd <$> walletAccount testPhrase12 0) `shouldReturn` "m/44'/60'/0'/0/0" + (keyPath . snd <$> walletAccount testPhrase12 7) `shouldReturn` "m/44'/60'/7'/0/0" + Hspec.it "round-trips the phrase it was imported from" $ + masterMnemonic (walletMaster 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 + it "creates a wallet only on the create command, and at most one" testWalletCreate + it "binds the next free account, and re-binding an account the profile holds changes nothing" testWalletBind + it "keeps each profile's accounts apart" testWalletAccountsPerProfile + it "binding the next account skips one already bound by index" testWalletBindByIndexThenNext + it "a deleted profile's account can be bound to another profile" testWalletDeletedProfileAccount + it "derives an address without binding it" testWalletAddress + it "exports the master phrase and one account's secret" testWalletExport + it "does not bind the next account after an import" testWalletImport + it "the wallet, the accounts and the counter persist across a restart" testWalletPersists + it "deletes the wallet, and a new one can be created" testWalletDelete + it "does not bind an account to a hidden profile" testWalletHiddenProfile + it "exports only an account the profile holds" testWalletExportNotHeld + it "rejects an account index at or above 2^31 on every command" testWalletIndexTooLarge + +testWalletCreate :: HasCallStack => TestParams -> IO () +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 ##> "/_wallet bind 1" + alice <## "wallet: this device has no wallet" + alice ##> "/_wallet delete" + alice <## "wallet: 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 ##> "/_wallet delete" + alice <## "ok" + alice ##> ("/_wallet create mnemonic=" <> unwords (replicate 24 "abandon")) + alice <## "wallet: 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" + alice <## T.unpack testPhrase24 + alice ##> "/_wallet delete" + alice <## "ok" + alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase12) + alice <## "wallet, next account unknown, no accounts for this profile" + alice ##> "/_wallet address account=0" + alice <## "0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + +testWalletBind :: HasCallStack => TestParams -> IO () +testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet bind 1" + alice `accountBound` "1" + alice ##> "/_wallet bind 1" + alice `accountBound` "2" + alice ##> "/_wallet bind 1 account=0" + alice `accountBound` "0" + alice ##> "/_wallet 1" + alice <## "wallet, next account 3, accounts: 0, 1, 2" + +testWalletAccountsPerProfile :: HasCallStack => TestParams -> IO () +testWalletAccountsPerProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet bind 1" + alice `accountBound` "1" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_wallet 2" + alice <## "wallet, next account 2, no accounts for this profile" + alice ##> "/_wallet bind 2" + alice `accountBound` "2" + alice ##> "/_wallet bind 2 account=1" + alice <## "wallet: another profile holds this account" + +testWalletBindByIndexThenNext :: HasCallStack => TestParams -> IO () +testWalletBindByIndexThenNext ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet bind 1 account=2" + alice `accountBound` "2" + alice ##> "/_wallet bind 1" + alice `accountBound` "3" + alice ##> "/_wallet bind 1 account=1" + alice `accountBound` "1" + alice ##> "/_wallet 1" + alice <## "wallet, next account 4, accounts: 1, 2, 3" + alice ##> "/_wallet bind 1" + alice `accountBound` "4" + +testWalletDeletedProfileAccount :: HasCallStack => TestParams -> IO () +testWalletDeletedProfileAccount ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet bind 1" + alice `accountBound` "1" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/delete user alice" + alice <### ["ok", "completed deleting user"] + alice ##> "/_wallet 2" + alice <## "wallet, next account 2, no accounts for this profile" + alice ##> "/_wallet bind 2 account=1" + alice `accountBound` "1" + +testWalletAddress :: HasCallStack => TestParams -> IO () +testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet address" + addr <- getTermLine alice + alice ##> "/_wallet address" + getTermLine alice `shouldReturn` addr + words addr !! 1 `shouldBe` "m/44'/60'/1'/0/0" + alice ##> "/_wallet address account=3" + at3 <- getTermLine alice + words at3 !! 1 `shouldBe` "m/44'/60'/3'/0/0" + alice ##> "/_wallet address account=00000000003" + getTermLine alice `shouldReturn` at3 + alice ##> "/_wallet bind 1 account=abc" + alice <## "bad chat command: Failed reading: empty" + alice ##> "/_wallet 1" + alice <## "wallet, next account 1, no accounts for this profile" + +testWalletExport :: HasCallStack => TestParams -> IO () +testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase24) + alice <## "wallet, next account unknown, no accounts for this profile" + alice ##> "/_wallet export master" + alice <## T.unpack testPhrase24 + alice ##> "/_wallet bind 1 account=0" + alice `accountBound` "0" + alice ##> "/_wallet bind 1 account=1" + alice `accountBound` "1" + alice ##> "/_wallet export account 1 0" + (idx, path, addr, secret) <- exportRow <$> getTermLine alice + idx `shouldBe` "0" + path `shouldBe` "m/44'/60'/0'/0/0" + addr `shouldBe` "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb" + addressFromSecret secret `shouldReturn` addr + alice ##> "/_wallet export account 1 1" + (idx', path', addr', secret') <- exportRow <$> getTermLine alice + idx' `shouldBe` "1" + path' `shouldBe` "m/44'/60'/1'/0/0" + (B.unpack . strEncode . address . snd <$> walletAccount testPhrase24 1) `shouldReturn` addr' + addressFromSecret secret' `shouldReturn` addr' + alice ##> "/_wallet address account=1" + (words <$> getTermLine alice) `shouldReturn` ["1", "m/44'/60'/1'/0/0", addr'] + +testWalletImport :: HasCallStack => TestParams -> IO () +testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase24) + alice <## "wallet, next account unknown, no accounts for this profile" + alice ##> "/_wallet bind 1" + alice <## "wallet: the next account is unknown after an import" + alice ##> "/_wallet address" + alice <## "wallet: the next account is unknown after an import" + alice ##> "/_wallet bind 1 account=4" + alice `accountBound` "4" + alice ##> "/_wallet bind 1" + alice <## "wallet: the next account is unknown after an import" + +testWalletPersists :: HasCallStack => TestParams -> IO () +testWalletPersists ps = do + phrase <- withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet bind 1 account=2" + alice `accountBound` "2" + alice ##> "/_wallet export master" + getTermLine alice + withTestChat ps "alice" $ \alice -> do + alice ##> "/_wallet 1" + alice <## "wallet, next account 3, accounts: 2" + alice ##> "/_wallet export master" + alice <## phrase + alice ##> "/_wallet bind 1" + alice `accountBound` "3" + +testWalletDelete :: HasCallStack => TestParams -> IO () +testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet bind 1 account=1" + alice `accountBound` "1" + alice ##> "/_wallet delete" + alice <## "ok" + 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 bind 1" + alice `accountBound` "1" + +testWalletHiddenProfile :: HasCallStack => TestParams -> IO () +testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/hide user my_password" + alice <## "current user alisa:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + alice ##> "/_wallet bind 2" + alice <## "wallet: an account cannot be bound to a hidden profile" + +testWalletExportNotHeld :: HasCallStack => TestParams -> IO () +testWalletExportNotHeld ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet bind 1" + alice `accountBound` "1" + alice ##> "/_wallet export account 1 1" + (_, path, _, _) <- exportRow <$> getTermLine alice + path `shouldBe` "m/44'/60'/1'/0/0" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_wallet export account 2 1" + alice <## "wallet: this profile does not hold this account" + alice ##> "/_wallet export account 2 7" + alice <## "wallet: this profile does not hold this account" + +testWalletIndexTooLarge :: HasCallStack => TestParams -> IO () +testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet address account=2147483648" + alice <## "bad chat command: Failed reading: empty" + alice ##> "/_wallet bind 1 account=2147483648" + alice <## "bad chat command: Failed reading: empty" + alice ##> "/_wallet export account 1 2147483648" + alice <## "bad chat command: Failed reading: empty" + alice ##> "/_wallet address account=4294967296" + alice <## "bad chat command: Failed reading: empty" + alice ##> "/_wallet bind 1 account=4294967296" + alice <## "bad chat command: Failed reading: empty" + alice ##> "/_wallet bind 1 account=2147483647" + alice `accountBound` "2147483647" + alice ##> "/_wallet bind 1" + alice <## "wallet: every account index is used"