diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index c6c8d34b77..6974cb2339 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -4424,6 +4424,12 @@ DeliveryJobNotFound: - type: "deliveryJobNotFound" - jobId: int64 +WalletNotFound: +- type: "walletNotFound" + +DuplicateWallet: +- type: "duplicateWallet" + WorkItemError: - type: "workItemError" - errContext: string diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 5804b0e945..059d1a426e 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -375,16 +375,20 @@ undocumentedCommands = "APICreateMemberContact", "APISendMemberContactInvitation", "APIAcceptMemberContact", + "APICreateWallet", + "APICreateWalletAccount", "APIDeleteChatTag", "APIDeleteMemberSupportChat", "APIDeleteReceivedReports", "APIDeleteStorage", "APIDeleteToken", + "APIDeleteWallet", "APIDownloadStandaloneFile", "APIEnableContact", "APIEnableGroupMember", "APIEndCall", "APIExportArchive", + "APIExportWalletMnemonic", "APIForwardChatItems", "APIGetAppSettings", "APIGetBadgeLedger", @@ -406,6 +410,7 @@ undocumentedCommands = "APIGetServerOperators", "APIGetUsageConditions", "APIGetUserServers", + "APIGetWallet", "APIGroupInfo", "APIGetUpdatedGroupLinkData", "APIGroupMemberInfo", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index c97e96e302..0766ec63be 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -216,5 +216,8 @@ undocumentedResponses = "CRUserServers", "CRUserServersValidation", "CRVersionInfo", + "CRWallet", + "CRWalletAddress", + "CRWalletMnemonic", "CRWelcome" ] diff --git a/cabal.project b/cabal.project index fea595d685..2eeacae9ab 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: cba13823567d7cd564d8a1f354046174fba375d8 + tag: d49e3a303b2276d615ec339b2fd12fc7f76ca2ee 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..9356c9a7ad --- /dev/null +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -0,0 +1,157 @@ +# 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, recovery from the phrase, 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 only by an explicit command, never at startup, and keys are derived only by the command that uses them. + +The master phrase is the only key material exported, and it derives every account. An export of one profile's accounts is impossible, 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, which [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) also varies and calls an account, so the master phrase imported into another wallet derives the same addresses. Other wallets reach an account above 0 only by entering the path, in a wallet that accepts one such as MEW, Rabby or Frame (known limit 4). + +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 account key that is given away 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, except under account 0 where other wallets derive (known limit 4). 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. + +## Profiles and accounts + +An account belongs to at most one chat profile, and a profile has 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 created in order and never reused, because an account the device no longer tracks still owns whatever it holds. An account remains without a profile when its profile is deleted. No account is created with a profile; an account is created on first use, when the user buys a name for a profile, so a device on which the user buys nothing has never derived an account key. + +An account is created for a hidden profile as for any other profile. A hidden profile is a filter on what is shown, not encryption, and every account is derived from the master, so whoever can use any profile on the device can derive a hidden profile's account keys. Incognito is a property of a connection in this app rather than of a profile; 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 creates accounts, rather than typed by users. Nothing here runs at startup. + +``` +/_wallet whether the device has a master, the next + account, and the profile's accounts +/_wallet create new generate the master, 24 words +/_wallet create account create the next account for the profile and + return its index, path and address +/_wallet export master the master phrase +/_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 create an account for the wrong profile. + +The BIP-39 passphrase is empty, because a passphrase would be a second secret to back up, and losing it would look exactly like losing the phrase. Another wallet derives the same keys from the exported phrase only with an empty passphrase. + +`create account` takes the index from a counter on the master, which is a high-water mark and not a count of accounts. The counter is incremented by `UPDATE … RETURNING`, and the account row is inserted in the same transaction. On Postgres the update locks the counter row, so concurrent creations are serialised, as SQLite transactions are. Every account index in the database is below the counter, so the insert cannot conflict with an existing account. + +BIP-32 marks an index as hardened by setting its top bit, so account 2^31 would derive account 0's key: a collision, not a loss of hardening. An account index is therefore a simplexmq type holding values below 2^31; the columns have CHECK constraints for the bound, and a row outside it is an error. The counter's bound is one higher, because it holds the next index; an increment past 2^31 violates its constraint and is reported as a database error. For the one key in 2^128 that BIP-32 declares invalid, simplexmq recomputes it as SLIP-0010 specifies, up to two times; a third invalid key is an internal error, since no input is expected to reach it. + +Signing is not in this change; when it is added, it is a command here that returns a signature. `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 :: Word32} +``` + +A device without a wallet is the store error `SEWalletNotFound`, and a second wallet is `SEDuplicateWallet`. A counter past 2^31 is a database error, and a third invalid key is an internal error. + +## Recovery + +Recovery from the phrase is planned separately. It is one core command, allowed only on a device without a wallet: the core imports the phrase, sends each account to a service in a separate request, records the accounts in use and sets the counter past them. + +A database restored from a backup contains the counter as of the backup. The counter is behind if an account was created after the backup, and `create account` can then return an account that is already in use (known limit 5). + +## 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)), + next_account_index INTEGER NOT NULL 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 NOT NULL 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); +``` + +The master entropy, generated as 32 bytes, is the only key material stored. Neither the master key nor an account key is stored, because the entropy and an account index derive them whenever they are 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. + +`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 down migration drops both tables, which deletes the master entropy; it runs only when the user confirms "Downgrade and open chat" in an older app. + +## 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. +- **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. +- **A paired device.** `create new`, `export master` and `delete` are rejected from a paired device, as the archive and database encryption commands are, because they return, replace or delete key material. `create account` is allowed. +- **Someone reading the logs.** No wallet command contains key material. On Postgres the client inlines parameters into the statement text, so server-side statement logging records the inserted entropy. +- **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. This is a property of that server, which this change makes a more valuable target, and closing it requires changes to that server rather than to the wallet. + +## Known limits + +1. **Only the whole wallet can be moved.** The master phrase is the only export, and it derives every account on the device, so a wallet given one profile's names also receives every other profile's names. +2. **The phrase cannot be imported yet.** Until recovery is implemented, the phrase restores the keys only in another wallet. +3. **The same master on two devices collides.** The counter is stored in one database, so two devices with copies of that database create the same account, and each treats it as free. +4. **Account 0 is left to other wallets.** MetaMask, Ledger Live and Trezor Suite present `m/44'/60'/0'/0/0` first, so a phrase exported to one of them is used at account 0 there. A wallet therefore starts its counter at 1. In turn, BIP-44 discovery in those wallets stops at the empty account 0, so they reach this app's accounts only by path. If an account ever pays for anything, whatever funds it links accounts on chain. +5. **`create account` 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. + +## Main files + +- `src/Simplex/Chat/Wallet.hs`, derivation. +- `src/Simplex/Chat/Store/Wallets.hs`, the two tables. +- `src/Simplex/Chat/Store/SQLite/Migrations/M20261030_wallet_seeds.hs` and the corresponding Postgres migration. +- `tests/WalletTests.hs`. +- 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`. The address of an account created through the command is the address derived from the exported phrase at the same index, so a change of path fails here rather than in a release. +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 wallet; `create account`, `delete` and `export master` on a device with no wallet. +4. **Accounts.** Each `create account` returns the next index; each profile's accounts are listed apart; a deleted profile's account index is not reused. +5. **Persistence.** The wallet, its accounts, the counter and the phrase persist across a restart; after the wallet is deleted, a new wallet starts its counter at 1. diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 49c81128f6..c5b0bab36a 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -4624,6 +4624,8 @@ export type StoreError = | StoreError.DeliveryTaskNotFound | StoreError.InvalidDeliveryJob | StoreError.DeliveryJobNotFound + | StoreError.WalletNotFound + | StoreError.DuplicateWallet | StoreError.WorkItemError export namespace StoreError { @@ -4716,6 +4718,8 @@ export namespace StoreError { | "deliveryTaskNotFound" | "invalidDeliveryJob" | "deliveryJobNotFound" + | "walletNotFound" + | "duplicateWallet" | "workItemError" interface Interface { @@ -5148,6 +5152,14 @@ export namespace StoreError { jobId: number // int64 } + export interface WalletNotFound extends Interface { + type: "walletNotFound" + } + + export interface DuplicateWallet extends Interface { + type: "duplicateWallet" + } + export interface WorkItemError extends Interface { type: "workItemError" errContext: string 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 573731b5ad..6b83e95398 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -3496,6 +3496,12 @@ class StoreError_deliveryJobNotFound(TypedDict): type: Literal["deliveryJobNotFound"] jobId: int # int64 +class StoreError_walletNotFound(TypedDict): + type: Literal["walletNotFound"] + +class StoreError_duplicateWallet(TypedDict): + type: Literal["duplicateWallet"] + class StoreError_workItemError(TypedDict): type: Literal["workItemError"] errContext: str @@ -3589,10 +3595,12 @@ StoreError = ( | StoreError_deliveryTaskNotFound | StoreError_invalidDeliveryJob | StoreError_deliveryJobNotFound + | StoreError_walletNotFound + | StoreError_duplicateWallet | StoreError_workItemError ) -StoreError_Tag = Literal["duplicateName", "userNotFound", "relayUserNotFound", "userNotFoundByName", "userNotFoundByContactId", "userNotFoundByGroupId", "userNotFoundByFileId", "userNotFoundByContactRequestId", "contactNotFound", "contactNotFoundByName", "contactNotFoundByMemberId", "contactNotReady", "duplicateContactLink", "userContactLinkNotFound", "contactRequestNotFound", "contactRequestNotFoundByName", "invalidContactRequestEntity", "invalidBusinessChatContactRequest", "groupNotFound", "groupNotFoundByName", "groupMemberNameNotFound", "groupMemberNotFound", "groupMemberNotFoundByIndex", "memberRelationsVectorNotFound", "groupHostMemberNotFound", "groupMemberNotFoundByMemberId", "memberContactGroupMemberNotFound", "invalidMemberRelationUpdate", "groupWithoutUser", "duplicateGroupMember", "duplicateMemberId", "groupAlreadyJoined", "groupInvitationNotFound", "noteFolderAlreadyExists", "noteFolderNotFound", "userNoteFolderNotFound", "sndFileNotFound", "sndFileInvalid", "rcvFileNotFound", "rcvFileDescrNotFound", "fileNotFound", "rcvFileInvalid", "rcvFileInvalidDescrPart", "localFileNoTransfer", "sharedMsgIdNotFoundByFileId", "fileIdNotFoundBySharedMsgId", "sndFileNotFoundXFTP", "rcvFileNotFoundXFTP", "connectionNotFound", "connectionNotFoundById", "connectionNotFoundByMemberId", "pendingConnectionNotFound", "uniqueID", "largeMsg", "internalError", "dBException", "dBBusyError", "badChatItem", "chatItemNotFound", "chatItemNotFoundByText", "chatItemSharedMsgIdNotFound", "chatItemNotFoundByFileId", "chatItemNotFoundByContactId", "chatItemNotFoundByGroupId", "profileNotFound", "duplicateGroupLink", "groupLinkNotFound", "hostMemberIdNotFound", "contactNotFoundByFileId", "noGroupSndStatus", "duplicateGroupMessage", "remoteHostNotFound", "remoteHostUnknown", "remoteHostDuplicateCA", "remoteCtrlNotFound", "remoteCtrlDuplicateCA", "prohibitedDeleteUser", "operatorNotFound", "usageConditionsNotFound", "userChatRelayNotFound", "groupRelayNotFound", "groupRelayNotFoundByMemberId", "invalidQuote", "invalidMention", "invalidDeliveryTask", "deliveryTaskNotFound", "invalidDeliveryJob", "deliveryJobNotFound", "workItemError"] +StoreError_Tag = Literal["duplicateName", "userNotFound", "relayUserNotFound", "userNotFoundByName", "userNotFoundByContactId", "userNotFoundByGroupId", "userNotFoundByFileId", "userNotFoundByContactRequestId", "contactNotFound", "contactNotFoundByName", "contactNotFoundByMemberId", "contactNotReady", "duplicateContactLink", "userContactLinkNotFound", "contactRequestNotFound", "contactRequestNotFoundByName", "invalidContactRequestEntity", "invalidBusinessChatContactRequest", "groupNotFound", "groupNotFoundByName", "groupMemberNameNotFound", "groupMemberNotFound", "groupMemberNotFoundByIndex", "memberRelationsVectorNotFound", "groupHostMemberNotFound", "groupMemberNotFoundByMemberId", "memberContactGroupMemberNotFound", "invalidMemberRelationUpdate", "groupWithoutUser", "duplicateGroupMember", "duplicateMemberId", "groupAlreadyJoined", "groupInvitationNotFound", "noteFolderAlreadyExists", "noteFolderNotFound", "userNoteFolderNotFound", "sndFileNotFound", "sndFileInvalid", "rcvFileNotFound", "rcvFileDescrNotFound", "fileNotFound", "rcvFileInvalid", "rcvFileInvalidDescrPart", "localFileNoTransfer", "sharedMsgIdNotFoundByFileId", "fileIdNotFoundBySharedMsgId", "sndFileNotFoundXFTP", "rcvFileNotFoundXFTP", "connectionNotFound", "connectionNotFoundById", "connectionNotFoundByMemberId", "pendingConnectionNotFound", "uniqueID", "largeMsg", "internalError", "dBException", "dBBusyError", "badChatItem", "chatItemNotFound", "chatItemNotFoundByText", "chatItemSharedMsgIdNotFound", "chatItemNotFoundByFileId", "chatItemNotFoundByContactId", "chatItemNotFoundByGroupId", "profileNotFound", "duplicateGroupLink", "groupLinkNotFound", "hostMemberIdNotFound", "contactNotFoundByFileId", "noGroupSndStatus", "duplicateGroupMessage", "remoteHostNotFound", "remoteHostUnknown", "remoteHostDuplicateCA", "remoteCtrlNotFound", "remoteCtrlDuplicateCA", "prohibitedDeleteUser", "operatorNotFound", "usageConditionsNotFound", "userChatRelayNotFound", "groupRelayNotFound", "groupRelayNotFoundByMemberId", "invalidQuote", "invalidMention", "invalidDeliveryTask", "deliveryTaskNotFound", "invalidDeliveryJob", "deliveryJobNotFound", "walletNotFound", "duplicateWallet", "workItemError"] class SubscriptionStatus_active(TypedDict): type: Literal["active"] diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 49ae96bb8f..d0d50843bc 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."cba13823567d7cd564d8a1f354046174fba375d8" = "0nhd6zsmqs55b7shh945b0v9pn0qqkpx9djq9dldair82hsmw1if"; + "https://github.com/simplex-chat/simplexmq.git"."d49e3a303b2276d615ec339b2fd12fc7f76ca2ee" = "0rjj2iizrnsia2712plfvbq71wyrhqcgl23ra4l2r2592l685cfm"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."2330df3dc8c4b660674d3c02d76bb86f66c2abee" = "11pfj1vqsjdl26nplkr2l6z1xj7cn153qijj5gknmlzq3krn7x1i"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index c9a29b3ac8..df8e3d26e1 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: @@ -170,6 +172,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group + Simplex.Chat.Store.Postgres.Migrations.M20261030_wallet_seeds else exposed-modules: Simplex.Chat.Archive @@ -349,6 +352,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group + Simplex.Chat.Store.SQLite.Migrations.M20261030_wallet_seeds other-modules: Paths_simplex_chat hs-source-dirs: @@ -713,6 +717,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 08b939baeb..c984748142 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -69,6 +69,7 @@ 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, WalletInfo) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Server.Information (ServerPublicInfo) import Simplex.Messaging.Agent (AgentClient, DatabaseDiff, SubscriptionsInfo) @@ -438,6 +439,11 @@ 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 + | APICreateWalletAccount {userId :: UserId} + | APIExportWalletMnemonic + | APIDeleteWallet | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType | APIRejectCall ContactId @@ -743,6 +749,9 @@ allowRemoteCommand = \case SetFilesFolder _ -> False SetRemoteHostsFolder _ -> False APISetEncryptLocalFiles _ -> False + APICreateWallet -> False + APIExportWalletMnemonic -> False + APIDeleteWallet -> False #if !defined(dbPostgres) APIExportArchive _ -> False APIImportArchive _ -> False @@ -874,6 +883,9 @@ 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 :: WalletInfo} + | CRWalletMnemonic {user :: User, mnemonic :: Text} + | CRWalletAddress {user :: User, walletAddress :: WalletAddress} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 74af268ede..a56000d80d 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 (deriveAccount, masterMnemonic, newEntropy) 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 import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -1502,6 +1504,18 @@ 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 (`getWalletInfo` user) + APICreateWallet -> withUser $ \user -> do + entropy <- liftIO . newEntropy =<< asks random + CRWallet user <$> withFastStore (`createWallet` entropy) + APICreateWalletAccount userId -> withUserId userId $ \user -> do + (entropy, n) <- withFastStore (`createAccount` user) + g <- asks random + CRWalletAddress user <$> liftError' (ChatError . CEInternalError) (deriveAccount g entropy n) + APIExportWalletMnemonic -> withUser $ \user -> + CRWalletMnemonic user . masterMnemonic <$> withFastStore getWalletEntropy + APIDeleteWallet -> withUser_ $ withFastStore deleteWallet >> ok_ APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withFastStore $ \db -> getContact db cxt user contactId @@ -6312,6 +6326,11 @@ 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, + "/_wallet create account " *> (APICreateWalletAccount <$> A.decimal), + "/_wallet export master" $> APIExportWalletMnemonic, + "/_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), diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index dc2de08739..93447d4a39 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -55,6 +55,7 @@ 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.M20260925_badge_bindings import Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group +import Simplex.Chat.Store.Postgres.Migrations.M20261030_wallet_seeds import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -109,7 +110,8 @@ schemaMigrations = ("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), ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), - ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), + ("20261030_wallet_seeds", m20261030_wallet_seeds, Just down_m20261030_wallet_seeds) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261030_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261030_wallet_seeds.hs new file mode 100644 index 0000000000..e7729c6e85 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261030_wallet_seeds.hs @@ -0,0 +1,40 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20261030_wallet_seeds where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20261030_wallet_seeds :: Text +m20261030_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)), + next_account_index BIGINT NOT NULL 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 NOT NULL 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); +|] + +down_m20261030_wallet_seeds :: Text +down_m20261030_wallet_seeds = + [r| +DROP INDEX idx_wallet_accounts_user_id; +DROP INDEX idx_wallet_accounts_wallet_seed_id_account_index; +DROP INDEX idx_wallet_seeds_single_seed; + +DROP TABLE wallet_accounts; +DROP TABLE wallet_seeds; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 835181199c..5646305474 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1747,6 +1747,49 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS +CREATE TABLE test_chat_schema.wallet_accounts ( + wallet_account_id bigint NOT NULL, + wallet_seed_id bigint NOT NULL, + account_index bigint NOT NULL, + user_id bigint, + CONSTRAINT wallet_accounts_account_index_check CHECK (((account_index >= 0) AND (account_index <= 2147483647))) +); + + + +ALTER TABLE test_chat_schema.wallet_accounts ALTER COLUMN wallet_account_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.wallet_accounts_wallet_account_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE test_chat_schema.wallet_seeds ( + wallet_seed_id bigint NOT NULL, + entropy bytea NOT NULL, + next_account_index bigint NOT NULL, + 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_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, @@ -2165,6 +2208,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); @@ -2970,6 +3023,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); @@ -3705,6 +3770,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 3e6d6694c6..cb50730862 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -178,6 +178,7 @@ 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.M20260925_badge_bindings import Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group +import Simplex.Chat.Store.SQLite.Migrations.M20261030_wallet_seeds import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -355,7 +356,8 @@ schemaMigrations = ("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), ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), - ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), + ("20261030_wallet_seeds", m20261030_wallet_seeds, Just down_m20261030_wallet_seeds) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261030_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261030_wallet_seeds.hs new file mode 100644 index 0000000000..f824d1f419 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261030_wallet_seeds.hs @@ -0,0 +1,39 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20261030_wallet_seeds where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20261030_wallet_seeds :: Query +m20261030_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)), + next_account_index INTEGER NOT NULL 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 NOT NULL 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); +|] + +down_m20261030_wallet_seeds :: Query +down_m20261030_wallet_seeds = + [sql| +DROP INDEX idx_wallet_accounts_user_id; +DROP INDEX idx_wallet_accounts_wallet_seed_id_account_index; +DROP INDEX idx_wallet_seeds_single_seed; + +DROP TABLE wallet_accounts; +DROP TABLE wallet_seeds; +|] 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 a7dab6fa7e..d3f1edabfe 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -930,6 +930,14 @@ UNION ALL SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=? AND created_at=? AND rowid>?) SCAN ci +Query: + SELECT account_index FROM wallet_accounts + WHERE wallet_seed_id = ? AND user_id = ? + ORDER BY account_index + +Plan: +SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=?) + Query: SELECT author_group_member_id, forwarded_by_group_member_id FROM messages @@ -1932,6 +1940,13 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE wallet_seeds SET next_account_index = next_account_index + 1 + RETURNING wallet_seed_id, entropy, next_account_index - 1 + +Plan: +SCAN wallet_seeds + Query: INSERT INTO connections ( user_id, agent_conn_id, conn_level, conn_status, conn_type, @@ -2091,6 +2106,14 @@ Query: Plan: +Query: + INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?,1) + ON CONFLICT (single_seed) DO NOTHING + RETURNING next_account_index + +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 @@ -7275,6 +7298,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=?) @@ -7302,6 +7326,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: @@ -7425,6 +7454,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: @@ -7684,6 +7716,10 @@ Query: SELECT count(1) FROM pending_group_messages Plan: SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id +Query: SELECT entropy FROM wallet_seeds +Plan: +SCAN wallet_seeds + Query: SELECT entry_id FROM badge_ledger WHERE badge_purchase_id = ? AND entry_uuid = ? Plan: SEARCH badge_ledger USING INDEX idx_badge_ledger_uuid (entry_uuid=?) @@ -7982,6 +8018,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, next_account_index FROM wallet_seeds +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 d60a41047f..fa6bb3cf33 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -995,6 +995,18 @@ CREATE TABLE group_member_badge_proofs( created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) 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)), + next_account_index INTEGER NOT NULL 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 NOT NULL 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 @@ -1561,6 +1573,12 @@ CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases( CREATE INDEX idx_group_members_invited_via_group_member_id ON group_members( invited_via_group_member_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/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 10a1c28396..237c65641e 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -167,6 +167,8 @@ data StoreError | SEDeliveryTaskNotFound {taskId :: Int64} | SEInvalidDeliveryJob {jobId :: Int64} | SEDeliveryJobNotFound {jobId :: Int64} + | SEWalletNotFound + | SEDuplicateWallet | -- | Error when reading work item that suspends worker - do not use! SEWorkItemError {errContext :: String} deriving (Show, Exception) diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs new file mode 100644 index 0000000000..3a3f561d4d --- /dev/null +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -0,0 +1,88 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Simplex.Chat.Store.Wallets + ( getWalletEntropy, + createWallet, + deleteWallet, + getWalletInfo, + createAccount, + ) +where + +import Control.Monad (when) +import Control.Monad.Except +import Control.Monad.IO.Class (liftIO) +import Data.Int (Int64) +import Simplex.Chat.Store.Shared (StoreError (..)) +import Simplex.Chat.Types (User (..)) +import Simplex.Chat.Wallet (WalletInfo (..)) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Crypto.BIP39 (WalletEntropy) +import Simplex.Messaging.Crypto.BIP44 (AccountIndex) + +#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 + +getWalletEntropy :: DB.Connection -> ExceptT StoreError IO WalletEntropy +getWalletEntropy db = + ExceptT . firstRow fromOnly SEWalletNotFound $ + DB.query_ db "SELECT entropy FROM wallet_seeds" + +createWallet :: DB.Connection -> WalletEntropy -> ExceptT StoreError IO WalletInfo +createWallet db entropy = + ExceptT . firstRow (WalletInfo [] . fromOnly) SEDuplicateWallet $ + DB.query + db + [sql| + INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?,1) + ON CONFLICT (single_seed) DO NOTHING + RETURNING next_account_index + |] + (Only entropy) + +deleteWallet :: DB.Connection -> ExceptT StoreError IO () +deleteWallet db = do + deleted :: [Only Int64] <- liftIO $ DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id" + when (null deleted) $ throwError SEWalletNotFound + +getWalletInfo :: DB.Connection -> User -> ExceptT StoreError IO WalletInfo +getWalletInfo db User {userId} = do + (walletSeedId :: Int64, nextAccountIndex) <- + ExceptT . firstRow id SEWalletNotFound $ + DB.query_ db "SELECT wallet_seed_id, next_account_index FROM wallet_seeds" + accountIndexes <- + liftIO $ + map fromOnly + <$> DB.query + db + [sql| + SELECT account_index FROM wallet_accounts + WHERE wallet_seed_id = ? AND user_id = ? + ORDER BY account_index + |] + (walletSeedId, userId) + pure WalletInfo {accountIndexes, nextAccountIndex} + +createAccount :: DB.Connection -> User -> ExceptT StoreError IO (WalletEntropy, AccountIndex) +createAccount db User {userId} = do + (walletSeedId :: Int64, entropy, n) <- + ExceptT . firstRow id SEWalletNotFound $ + DB.query_ + db + [sql| + UPDATE wallet_seeds SET next_account_index = next_account_index + 1 + RETURNING wallet_seed_id, entropy, next_account_index - 1 + |] + liftIO $ DB.execute db "INSERT INTO wallet_accounts (wallet_seed_id, account_index, user_id) VALUES (?,?,?)" (walletSeedId, n, userId) + pure (entropy, n) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5b16bde0d3..59c056bdf4 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 (..), 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,9 @@ 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 $ viewWalletInfo info + CRWalletMnemonic u mnemonic -> ttyUser u [plain mnemonic] + CRWalletAddress u a -> ttyUser u [viewWalletAddress a] 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 @@ -1882,6 +1887,17 @@ day = T.pack . formatTime defaultTimeLocale "%Y-%m-%d" dayTime :: UTCTime -> Text dayTime = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M" +viewWalletInfo :: WalletInfo -> [StyledString] +viewWalletInfo WalletInfo {accountIndexes, nextAccountIndex} = + [plain $ "wallet, next account " <> tshow nextAccountIndex <> ", " <> viewAccounts accountIndexes] + where + viewAccounts [] = "no accounts for this profile" + viewAccounts ns = "accounts: " <> T.intercalate ", " (map (tshow . unAccountIndex) ns) + +viewWalletAddress :: WalletAddress -> StyledString +viewWalletAddress WalletAddress {accountIndex, keyPath, address} = + plain $ tshow (unAccountIndex accountIndex) <> " " <> keyPath <> " " <> safeDecodeUtf8 (strEncode address) + viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] @@ -2945,6 +2961,8 @@ viewChatError isCmd logLevel testView = \case | testView -> ["duplicate group message, group id: " <> sShow groupId <> ", message id: " <> sShow sharedMsgId] | otherwise -> [] SEUserNoteFolderNotFound -> ["no notes folder"] + SEWalletNotFound -> ["no wallet on this device"] + SEDuplicateWallet -> ["this device already has a wallet"] SEInternalError {message} | testView && message == "referenced group member not found" -> [] e -> ["chat db error: " <> sShow e] diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs new file mode 100644 index 0000000000..d154c3d82b --- /dev/null +++ b/src/Simplex/Chat/Wallet.hs @@ -0,0 +1,56 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module Simplex.Chat.Wallet + ( WalletAddress (..), + WalletInfo (..), + newEntropy, + masterMnemonic, + deriveAccount, + ) +where + +import Control.Concurrent.STM +import Control.Monad.Except +import Crypto.Random (ChaChaDRG) +import qualified Data.Aeson.TH as JQ +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 Simplex.Messaging.Eth.Address (Address, deriveAddress) +import Simplex.Messaging.Parsers (defaultJSON) + +data WalletAddress = WalletAddress + { accountIndex :: AccountIndex, + keyPath :: Text, + address :: Address + } + deriving (Show) + +data WalletInfo = WalletInfo + { accountIndexes :: [AccountIndex], + nextAccountIndex :: Word32 + } + deriving (Show) + +newEntropy :: TVar ChaChaDRG -> IO B39.WalletEntropy +newEntropy = atomically . B39.randomEntropy B39.ES256 + +masterMnemonic :: B39.WalletEntropy -> Text +masterMnemonic = decodeLatin1 . B39.entropyPhrase + +deriveAccount :: TVar ChaChaDRG -> B39.WalletEntropy -> AccountIndex -> IO (Either String WalletAddress) +deriveAccount g entropy n = runExceptT $ do + master <- liftEither $ B32.masterKey $ B39.entropySeed entropy "" + (_, address) <- ExceptT $ deriveAddress g master path + pure WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address} + where + path = bip44Path Ethereum n + +$(JQ.deriveJSON defaultJSON ''WalletAddress) + +$(JQ.deriveJSON defaultJSON ''WalletInfo) diff --git a/tests/Test.hs b/tests/Test.hs index 4517a8edb2..ffae79059a 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -37,6 +37,7 @@ import Test.Hspec.Core.Spec (sequential) import UnliftIO.Temporary (withTempDirectory) import ValidNames import ViewTests +import WalletTests #if defined(dbPostgres) import Control.Exception (bracket_, finally) import PostgresSchemaDump @@ -87,6 +88,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 @@ -106,6 +108,7 @@ main = do describe "Mobile API Tests" mobileTests #endif describe "SimpleX chat client" chatTests + describe "Wallet" walletTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests describe "SimpleX Directory service bot" directoryServiceTests describe "SimpleX badge service e2e" $ do diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs new file mode 100644 index 0000000000..16920be33c --- /dev/null +++ b/tests/WalletTests.hs @@ -0,0 +1,162 @@ +{-# LANGUAGE OverloadedStrings #-} + +module WalletTests where + +import ChatClient +import ChatTests.DBUtils +import ChatTests.Utils +import qualified Data.ByteString.Char8 as B +import Data.List (nub) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Word (Word32) +import Simplex.Chat.Wallet (WalletAddress (..), deriveAccount, masterMnemonic) +import qualified Simplex.Messaging.Crypto as C +import qualified Simplex.Messaging.Crypto.BIP39 as B39 +import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex) +import Simplex.Messaging.Encoding.String (strEncode) +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"] + +walletEntropy :: Text -> B39.WalletEntropy +walletEntropy = either error id . B39.parsePhrase + +walletAddress :: Text -> Word32 -> IO WalletAddress +walletAddress phrase n = do + g <- C.newRandom + either error id <$> deriveAccount g (walletEntropy phrase) (either error id $ mkAccountIndex n) + +accountCreated :: HasCallStack => TestCC -> String -> Expectation +accountCreated 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 <$> walletAddress testPhrase12 n + addrOf 0 `shouldReturn` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + addrOf 1 `shouldReturn` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" + Hspec.it "every account has its own address" $ do + addrs <- mapM (fmap address . walletAddress testPhrase12) [0 .. 9] + length (nub addrs) `shouldBe` 10 + Hspec.it "renders the path an account is derived at" $ do + (keyPath <$> walletAddress testPhrase12 0) `shouldReturn` "m/44'/60'/0'/0/0" + (keyPath <$> walletAddress testPhrase12 7) `shouldReturn` "m/44'/60'/7'/0/0" + Hspec.it "round-trips the phrase it was parsed from" $ + masterMnemonic (walletEntropy testPhrase24) `shouldBe` testPhrase24 + +walletTests :: SpecWith TestParams +walletTests = do + it "creates a wallet only on the create command, and at most one" testWalletCreate + it "creates each account at the next index" testWalletCreateAccount + it "keeps each profile's accounts apart" testWalletAccountsPerProfile + it "does not reuse a deleted profile's account" testWalletDeletedProfileAccount + it "derives a created account from the exported phrase" testWalletExport + 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 + +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 <## "no wallet on this device" + alice ##> "/_wallet create account 1" + alice <## "no wallet on this device" + alice ##> "/_wallet delete" + alice <## "no wallet on this device" + 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 <## "this device already has a wallet" + +testWalletCreateAccount :: HasCallStack => TestParams -> IO () +testWalletCreateAccount ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet create account 1" + alice `accountCreated` "1" + alice ##> "/_wallet create account 1" + alice `accountCreated` "2" + alice ##> "/_wallet 1" + alice <## "wallet, next account 3, accounts: 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 create account 1" + alice `accountCreated` "1" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_wallet 2" + alice <## "wallet, next account 2, no accounts for this profile" + alice ##> "/_wallet create account 2" + alice `accountCreated` "2" + alice ##> "/_wallet 2" + alice <## "wallet, next account 3, accounts: 2" + +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 create account 1" + alice `accountCreated` "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 create account 2" + alice `accountCreated` "2" + +testWalletExport :: HasCallStack => TestParams -> IO () +testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, next account 1, no accounts for this profile" + alice ##> "/_wallet export master" + phrase <- getTermLine alice + length (words phrase) `shouldBe` 24 + addr <- B.unpack . strEncode . address <$> walletAddress (T.pack phrase) 1 + alice ##> "/_wallet create account 1" + (words <$> getTermLine alice) `shouldReturn` ["1", "m/44'/60'/1'/0/0", addr] + +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 create account 1" + alice `accountCreated` "1" + alice ##> "/_wallet export master" + getTermLine alice + withTestChat ps "alice" $ \alice -> do + alice ##> "/_wallet 1" + alice <## "wallet, next account 2, accounts: 1" + alice ##> "/_wallet export master" + alice <## phrase + alice ##> "/_wallet create account 1" + alice `accountCreated` "2" + +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 create account 1" + alice `accountCreated` "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 create account 1" + alice `accountCreated` "1"