mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 06:32:59 +00:00
core: wallet for names (#7684)
* core: wallet key for name ownership (CLI only) A name has to be owned by an address the client can still derive after a restart or on a new device. This adds that key and nothing else. /wallet create makes one BIP-39 key per device and one BIP-44 account per chat profile under it, /wallet shows the address that would own the next name that profile buys, /wallet import and /wallet export move the key with its recovery phrase. A name key sits at m/44'/60'/<profile>'/0/<name>, which is ordinary BIP-44, so the phrase reaches the same addresses in other wallets. No signing, so nothing can be bought or edited yet. Split out of #7390 / #7425. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: pin simplexmq with eth crypto merged into master ab/eth-crypto forked before Crypto.Entitlement, which Chat.Badges imports, so the branch had to be merged with simplexmq master before it could be pinned here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: wallet review fixes, and show the derived addresses Concurrency: the account index is incremented in SQL and read back in the same transaction, so two profiles cannot be handed the same key. Importing a phrase is one transaction and single_seed is UNIQUE, so a phrase cannot be discarded in favour of a key created meanwhile, and a device cannot end up with two keys. Wallet commands are no longer forwarded to a remote host: the recovery phrase must not leave the device, and the raw command is logged there. /wallet delete removes the key, confirmed by the last word of the phrase, so creating a key before importing your own is no longer a dead end. /wallet now shows every profile on the key with the first two name addresses each, to check derivation against other wallets. Hidden profiles are left out, as they are by /users. A bad phrase no longer says which word was wrong. /wallet export uses the profile's own key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: make the wallet an API, not a user-facing feature Names commands will use the wallet; users do not. The commands move to /_wallet, and the help section, its topic and the changelog entry are removed. The tests drive the API end to end. Comments cut to what the code does not say, dead exports removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: wallet export and delete act on the device key The key is one per device, so both commands read it directly instead of going through the active profile's account. A profile without an account of its own can now export, and the delete error says that the key goes for every profile on the device. The confirmation word is lowercased, as the phrase is when imported. Two known limitations are written down: account indexes restart at 0 after an import, so profiles are bound in the order they ask for a key, and the gap left by a hidden profile in the printed indexes shows that it exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: /_wallet delete takes no confirmation word The wallet is an API, and no other API command takes a typed confirmation: /_delete user takes the profile password. The word was also no barrier to a caller, which can read it from /_wallet export. Confirming belongs in the command that will wrap this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: name the unique index on wallet_seeds The one key per device rule is meant to be lifted later. SQLite cannot drop a column with an inline UNIQUE, or its automatic index, so lifting it would have meant rebuilding the table. As a named index it is a DROP INDEX and a DROP COLUMN. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: export the secret of a single name key /_wallet export gives the seed mnemonic, /_wallet export <account> <name> gives the secret of one derived key, so a single name can be handed over without the seed. Both are what a wallet takes on import: the mnemonic as a recovery phrase, the secret as hex. The commands are named for what they return, APIWalletExportSeedMnemonic and APIWalletExportDerivedSecret, as the difference is which secret leaves the device. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: reject a key index BIP-32 cannot harden A.decimal wraps into Word32 and hardened leaves anything at or above 2^31 alone, so /_wallet export 4294967296 0 returned account 0's secret under the path m/44'/60'/0'/0/0. Handing out a different account's key for a mistyped index is the wrong failure for this command. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: /_wallet bind, and name the other profiles instead of numbering them Restoring a chat database older than the key loses which account each profile had, and the keys are still there, so /_wallet bind <account> sets it by hand. The counter moves past an account bound this way, and an account another profile holds is refused. /_wallet now shows the paths of the active profile only, and names the other profiles on the key. Their account indexes were the gap that showed a hidden profile exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: say same seed, not same key, for the other profiles Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: create makes the seed, bind claims a path The two were one command: on a device that already had a seed, /_wallet create only bound a profile. Now create makes the seed and refuses when one exists, and binds every profile that exists, as a new seed has no account that already owns a name. /_wallet import binds nothing. Which account a profile had is what an import is recovering, and the seed does not say, so the profile says it with /_wallet bind <account>. /_wallet bind with no account takes the next free one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: the known issue is now about bind, not import Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: create binds no profile either Create and import differed only in whether they bound a profile, which made the convenient one inconsistent. Neither binds now, so the store keeps one function for both, and a profile reaches an account only through bind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: test the restore cases the key has to survive A database backed up after the key carries the key and the binding, so the restore is all of it and an import is refused. A database backed up before the key comes back with the profiles and no binding, so the phrase is imported and each profile says which account was its. A key imported before the database is restored is replaced by what the backup held. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: drop the types nothing reads, and say account where it means account AccountRef and WalletAccount were built and never read: deriveNameKey now returns the key, and the address comes from addressFromPrivateKey, which already exists. SeedId is a plain Int64 like every other row id here. The master key is derived once per command rather than once per name key, which is where PBKDF2 runs. A device has a key and a profile has an account, so /_wallet says "no account for this profile" rather than repeating "no wallet key", which is what it says when the device has none. Also: no "wallet: " in front of "bad chat command: ", strEncode for the address, the repeated seed lookup in one place, comments cut to what the code does not say, and the export test pins its addresses and secrets, including one whose first byte is zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: store the seed as bytes, and bind one account per profile On Postgres a bare ByteString binds as a text literal, so entropy with a backslash or a high bit was rejected and entropy with a zero byte was truncated: the BIP-39 test vector stored as an empty seed. Blob columns go through DB.Binary here, as every other one does. /_wallet bind with no account moved a profile that already had one to a fresh account, abandoning the old one without saying so. It refuses now; moving is asked for by number. The counter could walk past 2^31, where BIP-32 hardening folds every index back onto a low one, so a profile handed 2147483648 derived account 0's keys and printed account 0's path. Binding refuses once the counter is there, and a very long index is now rejected on its digit count rather than after reading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * docs: rfc for the keys that own names Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: pin simplexmq for the BIP-44 path, and use it ethereumPath now takes the address index, so the path a name key sits at is built once in the library rather than again here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: refuse an account index BIP-32 cannot harden, whichever way it arrives The counter path checked it, the explicit one did not, and the parser is not the only caller: processChatCommand takes APIWalletBind from library callers too. hardened leaves an index at or above 2^31 alone, so account i and i + 2^31 are the same key while the duplicate check compares the stored integers, and two profiles could hold one account's names. The rfc now says what hiding a profile from /_wallet does and does not protect: the seed is one per device, so whoever unlocks any profile can derive every account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: pin simplexmq with the wordlist embedded from the upstream file Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * docs: cut the rfc to what stands on its own Drops the references to other documents and to what is or is not in a pull request, so the design reads without them, and the list of tests, which restated their names and would drift. 164 lines to 134, against a median of 117 for the rfcs here that define schema. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * docs: what the stealth meta address is for Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * docs: the wallet is not only for names The seed, the accounts and the derivation are a wallet. Owning a name is the first thing it is used for, not what it is, and the title and the problem it solves now say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * docs: the reason for a key per name is that owners are public One address owning several names links them: whoever knows one name can read its owner and find the others. That is the reason, not the narrower export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * docs: give the reason for the counter before what it costs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: names are the device's, so drop the per-profile account A name's profile is the record it resolves to, not the key that owns it, so the account level was carrying a mapping nothing needs. It was also the only source of the profile to account ambiguity after a restore, of /_wallet bind, and of the index gap and the refusal that told a visible profile an account was held by one it cannot see. All of it goes. Names sit in account 0 from index 1. Index 0 is left unused so neither the names nor the profile accounts, which start at 1, claim the origin of both dimensions. users gains no columns at all now, so the migration is one CREATE TABLE. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * docs: rfc for names counted per device Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: one command for the seed, whichever way it arrives create and import differed only in where the entropy came from and already shared one store function, so they are one command with the source named: /_wallet create new, or /_wallet create seed=<phrase>. Naming it means no seed is generated by typing a prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: the argument is a mnemonic, so name it one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: the column holds entropy, so name it entropy The mnemonic comes back from the entropy exactly, which is what export returns, and the seed PBKDF2 derives from the mnemonic is computed when a key is needed and never stored. The column was the one thing called seed that is not the seed, which is why it needed a comment saying so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * docs: 24 words is a choice, not a default Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: deleting the seed reports ok, not the empty wallet Delete re-ran /_wallet, so a successful delete printed "no wallet key", which is the same words the error uses and reads as a failure. There is nothing to show after a delete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * docs: the export argument is an index, not a name Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * core: export says which kind of secret, so other kinds can follow /_wallet export name <k> rather than an index straight after export. The bare form stays the seed mnemonic, and a profile's key can be added beside the name one without the argument having to be guessed from its shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX * disencourage hidden profiles using names * let remote use wallet, but filter logs to avoid leaking secrets * change RFC wallet architecture to BIP-85 per profile and hardened paths for names * introduce keyring as the thing a profile binds to * restructure RFC * shorten * drop BIP85 and flattenhierarche. no keyrings * /goal iteration 4 - unconfirmed * /goal iteration 5 - unconfirmed * repin * rename to adapt * cut comments * next review round * minimize diff and align style even more * missing type mappings * fix review comments * self-review iteration 2 * self-review iteration 3 * self-review iteration 5 * iteration 6 * self-review iteration 9 * self-review iteration 10 * repin * rng preserve * refactor WalletSeed type, see mq wip * repin * infallibel wallet and account derivation * review iterations * repin to mq, derivation fallible again * repin * use deriveAddress, update simplexmq * simplexmq * add down migration for db and drop redundant masterkey field in db * repin to simplexmq PR * fable review fixes * iterations on /goal * treat hidden profiles same as normal ones. they can hold accounts now * reduce explicit types * repin mq * doc cosmetics * simplify * query plans * query plan * simplify * query plans * simplify more * fixes for comment ^^ --------- Co-authored-by: Alain Brenzikofer <alain@encointer.org> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
24 files changed
+777
-5
No files matched your search
@@ -4424,6 +4424,12 @@ DeliveryJobNotFound:
|
||||
- type: "deliveryJobNotFound"
|
||||
- jobId: int64
|
||||
|
||||
WalletNotFound:
|
||||
- type: "walletNotFound"
|
||||
|
||||
DuplicateWallet:
|
||||
- type: "duplicateWallet"
|
||||
|
||||
WorkItemError:
|
||||
- type: "workItemError"
|
||||
- errContext: string
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -216,5 +216,8 @@ undocumentedResponses =
|
||||
"CRUserServers",
|
||||
"CRUserServersValidation",
|
||||
"CRVersionInfo",
|
||||
"CRWallet",
|
||||
"CRWalletAddress",
|
||||
"CRWalletMnemonic",
|
||||
"CRWelcome"
|
||||
]
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 <userId> 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 <userId> 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|]
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|]
|
||||
@@ -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=?)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user