review iterations

This commit is contained in:
Alain Brenzikofer
2026-09-26 14:29:40 +02:00
parent 143d073980
commit 7b4ac1bb8b
13 changed files with 161 additions and 134 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 31360b40c4d3ede6baac78b00dec51df5812ba84
tag: 360a82cf7d461a34b9a1709f2b2c60c5cf7188ce
source-repository-package
type: git
+19 -17
View File
@@ -13,7 +13,7 @@ Out of scope of this doc: buying a name, the names protocol, the registrar, sign
1. Every key the device uses is derivable again from the master phrase alone.
2. Giving away one account key gives away that account and nothing else, whatever else the recipient has.
3. No extended public key links two accounts.
4. Key material is generated or derived only by an explicit command, never at startup and never as a side effect of a read.
4. Key material is generated or imported only by an explicit command, never at startup; reading the wallet row re-derives the master key to check it, and account keys are derived only by the command that uses them.
5. A hidden profile owns nothing on chain, so nothing on chain is linked to it.
The master phrase derives every account and an account secret controls one account; no export covers anything in between, such as "this profile's accounts", because the profile is not an input to the derivation.
@@ -27,11 +27,11 @@ A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/b
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, 24 words the only key material to back up
master seed the only key material to back up
└── m/44'/60'/n'/0/0 account n, n >= 0
```
An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. So the master phrase imported into another wallet derives the same addresses, and the two tools use the word for the same thing. That wallet discovers account 0 and stops, because BIP-44 specifies that discovery stops at the first account with no transaction history and an account that only owns a name has none; above it the user has to enter the path.
An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. So the master phrase imported into another wallet derives the same addresses, and the two tools use the word for the same thing. That wallet discovers account 0 and stops, because BIP-44 specifies that discovery stops at the first account with no transaction history, and this app leaves account 0 empty (known limit 10) and an account that only owns a name has none; above it the user has to enter the path in a wallet that accepts one, such as MEW, Rabby or Frame, since Ledger Live, MetaMask and Trezor Suite do not, which leaves the exported account key for those.
The tests pin the derivation against the standard `abandon ... about` test mnemonic, which is 12 words, with an empty BIP-39 passphrase. Addresses are in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) mixed case.
@@ -46,29 +46,29 @@ The alternative is one account owning several names. A name's owner address is p
### Why the account level is hardened
The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a known weakness there: the extended public key of a parent, together with one non-hardened child's private key, yields the parent private key and from it every sibling. An exported account key is one half, and any wallet that enumerates accounts produces the other. The two levels below an account are not hardened, so the two halves together reveal the extended private key at the account level, m/44'/60'/n'; nothing else is derived under an account and the account level itself is hardened, so they reveal no other account's key. This satisfies objectives 2 and 3, and it is worth the loss of MetaMask's default path.
The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a known weakness there: the extended public key of a parent, together with one non-hardened child's private key, yields the parent private key and from it every sibling. An exported account key is one half, and any wallet that enumerates accounts produces the other. The two levels below an account are not hardened, so the two halves together reveal the extended private key at the account level, m/44'/60'/n'; nothing else is derived under an account by this app and the account level itself is hardened, so they reveal no other account's key; known limit 10 covers account 0, under which other wallets do derive. This satisfies objectives 2 and 3, and it is worth the loss of MetaMask's default path.
### Why 24 words
Entropy cannot be added to a seed afterwards and the uses of a seed can grow, so the master is 24 words, 256 bits.
Entropy cannot be added to a seed afterwards and the uses of a seed can grow, so a generated master is 24 words, 256 bits. An imported phrase is accepted at any BIP-39 length, 12 to 24 words, because its strength was decided by the wallet that generated it and other wallets default to 12.
## Profiles and accounts
An account is bound to at most one chat profile, and a profile to any number of accounts, because a profile can own any number of names. Only one of those names points at the profile as its SimpleX domain name, but which one is recorded by the names layer, not by the wallet: the wallet records only which profile an account belongs to.
Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds. An account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user neither buys anything nor requests an address has never derived a key.
Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds. An account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user neither buys anything nor requests an address has never derived an account key.
An account cannot be bound to a hidden profile, so no name can be bought for a hidden profile. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; the random profile of an incognito connection is not a chat profile, so it cannot hold an account.
## Commands
These commands are an internal API, called by the names commands and by any later feature that binds accounts, rather than typed by users. Nothing here runs at startup or as a side effect of a read.
These commands are an internal API, called by the names commands and by any later feature that binds accounts, rather than typed by users. Nothing here runs at startup.
```
/_wallet <userId> whether the device has a master, and the
accounts bound to the profile
/_wallet <userId> whether the device has a master, the next
account, and the accounts bound to the profile
/_wallet create new generate the master, 24 words
/_wallet create mnemonic=<phrase> import the master entropy from a phrase, 24 words
/_wallet create mnemonic=<phrase> import the master entropy from a phrase, 12 to 24 words
/_wallet bind <userId> account=<n> bind an account to the profile and return its
address; without account=, the next free one
/_wallet address account=<n> one address, with the account index it is
@@ -81,7 +81,7 @@ These commands are an internal API, called by the names commands and by any late
A command that acts on a profile's accounts names the profile and is rejected when that profile is not the active one, so switching profiles during a flow cannot bind an account to the wrong profile.
`create` always names its source, so no key material is generated by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with a passphrase elsewhere derives different keys here.
`create` always names its source, so no key material is generated by typing a prefix. An imported phrase must be 12, 15, 18, 21 or 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with a passphrase elsewhere derives different keys here.
`bind` without `account=` binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=<n>` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed.
@@ -92,7 +92,8 @@ BIP-32 marks an index as hardened by setting its top bit, so an index at or abov
`export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index does not return, because the counter is past it or unknown; the exception is a database restored from a backup (known limit 9). An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves whatever the accounts own on chain, recoverable only from the phrase.
```haskell
data WalletAddress = WalletAddress {accountIndex :: Word32, keyPath :: Text, address :: Text}
data WalletAddress = WalletAddress {accountIndex :: AccountIndex, keyPath :: Text, address :: Address}
data WalletInfo = WalletInfo {accountIndexes :: [AccountIndex], nextAccountIndex :: Maybe Word32} -- Nothing: unknown after an import
data WalletError
= WENoMaster -- the device has no master entropy
@@ -122,7 +123,7 @@ What the scan finds is unbound, and the user attaches each account to a profile
```sql
CREATE TABLE wallet_seeds (
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
entropy BLOB NOT NULL CHECK (length(entropy) = 32),
entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)),
master BLOB NOT NULL CHECK (length(master) = 64),
next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), -- null means not known yet
single_seed INTEGER NOT NULL DEFAULT 1
@@ -140,7 +141,7 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_a
CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id);
```
The master entropy, always 32 bytes since it is generated and imported as 24 words, is stored with the BIP-32 master key it derives, the 32-byte private key followed by the 32-byte chain code. Reading the row recomputes the master key from the entropy and rejects a row where the two do not match, so a wallet read from the database always derives accounts. An account key is never stored, because the master key and an account index derive it whenever it is required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes.
The master entropy, 32 bytes when generated and 16 to 32 bytes when imported, is stored with the BIP-32 master key it derives, the 32-byte private key followed by the 32-byte chain code. Reading the row recomputes the master key from the entropy and rejects a row where the two do not match, so a wallet read from the database always derives accounts. An account key is never stored, because the master key and an account index derive it whenever it is required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes.
`users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile can hold any number of accounts. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The migration has no down migration, because a down migration would delete the master entropy, which may have no other copy. A down migration runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. Without a down migration the older app reports that the database is newer than the app, and changes nothing.
@@ -153,7 +154,7 @@ A null `account_index` marks an account whose key was imported rather than deriv
- **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which gives that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents.
- **Whoever answers the recovery scan.** Receives every address the scan derives from the phrase, in one sequence of requests, so it can link every account on the device, across profiles, and recognise addresses that own nothing yet, which is where future accounts will be. `address` derives an address for any index directly from the master, so a caller can enumerate hidden profiles' addresses too. This is the largest privacy cost of the design.
- **A paired device.** Wallet commands are allowed from a paired device like other chat commands: `export master` returns the whole wallet, `create mnemonic=` on a device that has no seed imports a phrase that the paired device sends, and `delete` deletes the master entropy, which may have no other copy.
- **Someone reading the logs.** The core logs no command and no response, so a phrase passed to `create` is not written to any log.
- **Someone reading the logs.** The core logs no command and no response, so a phrase passed to `create` is not written to any log. On Postgres the client inlines parameters into the statement text, so server-side statement logging records the inserted entropy and master key.
- **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs`, which runs only with `--chat-server-port` and listens on 127.0.0.1, accepts any local connection, requires no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the response. It also prints every command it receives, that phrase included. Both are properties of that server, which this change makes a more valuable target, and closing them requires changes to that server rather than to the wallet.
## Known limits
@@ -164,9 +165,10 @@ A null `account_index` marks an account whose key was imported rather than deriv
4. **The same phrase on two devices collides.** The counter is stored in one database, so both devices bind the same account and each treats it as free. Sharing the counter requires a backup both devices can read.
5. **A profile hidden after an account was bound to it keeps its accounts.** The check runs at bind time only.
6. **Account indexes are not dense.** An account can be bound and never used, and a run of empty accounts ends the scan, so an account after a gap can be missed.
7. **Gas payments and discovery conflict.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet discovers it past account 0.
7. **Other wallets do not discover the accounts.** A generated wallet leaves account 0 empty, so BIP-44 discovery in Ledger Live or Trezor Suite stops before the first account this app uses, whether or not later accounts hold funds or paid gas; they are reached only by entering the path or with the exported key. If an account ever pays for anything, whatever funds it links accounts on chain.
8. **Nothing records which layout a seed was used with.** Another wallet may have derived accounts from the phrase at paths this doc does not describe.
9. **`bind` on a restored database can return an account that is already in use.** Its counter can be below the highest index used, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter.
10. **Account 0 is the primary account of other wallets.** MetaMask, Ledger Live and Trezor Suite present `m/44'/60'/0'/0/0` first, and their hardware integrations hand a browser the extended public key of its address level, so with a phrase used in one of them the export of account 0 exposes that wallet's accounts. A generated wallet therefore starts its counter at 1, and account 0 is bound or exported only when asked for with `account=0`.
## Main files
@@ -183,6 +185,6 @@ A null `account_index` marks an account whose key was imported rather than deriv
1. **Vectors.** The two addresses above are derived from `abandon ... about`, as is account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command derives a pinned address end to end, so a change of path fails here rather than in a release, and the account a command names is the account whose key is returned.
2. **Isolation.** Ten accounts' addresses are all different, and the paths of accounts 0 and 7 have a hardened account component.
3. **Rejections.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; `bind` without an index once the counter has passed 2^31 - 1; an index of 2^31 or more is rejected as a bad command on `address`, `bind` and `export account` alike.
3. **Rejections.** A second generate; a phrase with a bad checksum; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; `bind` without an index once the counter has passed 2^31 - 1; an index of 2^31 or more is rejected as a bad command on `address`, `bind` and `export account` alike.
4. **Binding and reads.** Several accounts are bound to one profile, each `bind` returns the account it bound, and that profile's accounts can be exported; binding an account by index moves the counter past it so the next one does not collide, and never moves it back; `bind account=<n>` binds an account after an import; an account left unbound by deleting its profile is bound to another profile; and two consecutive `address` calls return the same address without changing the counter, and derive an address for an account with no row.
5. **Encoding and persistence.** An account secret whose first byte is zero is rendered with 64 hex digits; the wallet, its accounts, the counter and the phrase persist across a restart; and deleting the wallet deletes its accounts and resets the counter.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."31360b40c4d3ede6baac78b00dec51df5812ba84" = "0srnp3lsc09r3y5jsy6f6snb5d678l6bxjpxk77dl0lqx2gx7d2j";
"https://github.com/simplex-chat/simplexmq.git"."360a82cf7d461a34b9a1709f2b2c60c5cf7188ce" = "1hq5j8yx3kj1iwxj1pmjixxac40rwkh9mizkfpcf1iyprjsmbi6y";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
+3 -2
View File
@@ -69,8 +69,9 @@ import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme
import Simplex.Chat.Util (liftIOEither)
import Simplex.Chat.Wallet (AccountIndex, WalletAddress, WalletError)
import Simplex.Chat.Wallet (WalletAddress, WalletError, WalletInfo)
import Simplex.FileTransfer.Description (FileDescriptionURI)
import Simplex.Messaging.Crypto.BIP44 (AccountIndex)
import Simplex.Messaging.Server.Information (ServerPublicInfo)
import Simplex.Messaging.Agent (AgentClient, DatabaseDiff, SubscriptionsInfo)
import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo)
@@ -879,7 +880,7 @@ 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, accountIndexes_ :: Maybe [AccountIndex]}
| CRWallet {user :: User, walletInfo_ :: Maybe WalletInfo}
| CRWalletMnemonic {user :: User, mnemonic :: Text}
| CRWalletAddress {user :: User, walletAddress :: WalletAddress}
| CRWalletAccountSecret {user :: User, walletAddress :: WalletAddress, secret :: Text}
+23 -20
View File
@@ -65,7 +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 (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, importWalletMaster, newWalletMaster, seedMnemonic)
import Simplex.Chat.Wallet (AccountKey, WalletAddress, WalletError (..), WalletInfo (..), accountSecret, deriveAccount, importWalletMaster, masterMnemonic, newWalletMaster)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..))
@@ -96,7 +96,7 @@ import Simplex.Chat.Store.Messages
import Simplex.Chat.Store.NoteFolders
import Simplex.Chat.Store.Profiles
import Simplex.Chat.Store.Shared
import Simplex.Chat.Store.Wallets (Wallet (..), accountHeldBy, bindAccount, createWallet, deleteWallet, getUserAccounts, getWallet, resolveAccount)
import Simplex.Chat.Store.Wallets (Wallet (..), bindAccount, createWallet, deleteWallet, getUserAccounts, getWallet, heldAccount, resolveAccount)
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
@@ -116,7 +116,8 @@ import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations)
import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), pattern NRMInteractive, textToHostMode)
import qualified Simplex.Messaging.Crypto as C
import qualified Simplex.Messaging.Crypto.ShortLink as SL
import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex)
import Simplex.Messaging.Crypto.BIP32 (WalletMaster)
import Simplex.Messaging.Crypto.BIP44 (AccountIndex, mkAccountIndex)
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
import qualified Simplex.Messaging.Crypto.File as CF
import Simplex.Messaging.Crypto.Ratchet (E2ERatchetParamsUri (..), InitialKeys (..), PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQSupportOff, pattern PQSupportOn)
@@ -1501,32 +1502,30 @@ processChatCommand cxt nm = \case
connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData)
pure $ CRServiceReplyAccepted user (AgentConnId connId)
APIGetWallet userId -> withUserId userId $ \user ->
CRWallet user <$> withFastStore (\db -> getWallet db >>= mapM (\Wallet {walletId} -> liftIO $ getUserAccounts db walletId userId))
CRWallet user <$> withFastStore (\db -> getWallet db >>= mapM (\Wallet {walletId, nextAccountIndex} -> liftIO $ (`WalletInfo` nextAccountIndex) <$> getUserAccounts db userId walletId))
APICreateWallet mnemonic_ -> withUser $ \user -> do
wallet_ <- withFastStore getWallet
when (isJust wallet_) $ throwWalletError WEMasterExists
-- the counter starts at 0 for a generated seed and is unknown for an imported one
-- the counter starts at 1 for a generated seed, leaving account 0 to other wallets, and is unknown for an imported one
(master, nextAccount) <- case mnemonic_ of
Nothing -> (,Just 0) <$> (liftIO . newWalletMaster =<< asks random)
Nothing -> (,Just 1) <$> (liftIO . newWalletMaster =<< asks random)
Just phrase -> (,Nothing) <$> liftWallet (importWalletMaster phrase)
created <- withFastStore' $ \db -> createWallet db master nextAccount
unless created $ throwWalletError WEMasterExists
pure $ CRWallet user (Just [])
pure $ CRWallet user (Just $ WalletInfo [] nextAccount)
APIBindWalletAccount userId accountIdx_ -> withUserId userId $ \user@User {viewPwdHash} -> do
when (isJust viewPwdHash) $ throwWalletError WEHiddenProfile
(wallet, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_
CRWalletAddress user . snd <$> walletAccount wallet n
(master, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_
CRWalletAddress user . snd <$> walletAccount master n
APIGetWalletAddress accountIdx_ -> withUser $ \user -> do
(wallet, n) <- withWalletStore (`resolveAccount` accountIdx_)
CRWalletAddress user . snd <$> walletAccount wallet n
(master, n) <- withWalletStore (`resolveAccount` accountIdx_)
CRWalletAddress user . snd <$> walletAccount master n
APIExportWalletMnemonic -> withUser $ \user -> do
Wallet {walletMaster} <- withFastStore getWallet >>= maybe (throwWalletError WENoMaster) pure
pure $ CRWalletMnemonic user (seedMnemonic walletMaster)
pure $ CRWalletMnemonic user (masterMnemonic walletMaster)
APIExportWalletAccount userId n -> withUserId userId $ \user -> do
(wallet@Wallet {walletId}, _) <- withWalletStore (`resolveAccount` Just n)
held <- withFastStore' $ \db -> accountHeldBy db walletId userId n
unless held $ throwWalletError WEAccountNotHeld
(k, a) <- walletAccount wallet n
master <- withWalletStore $ \db -> heldAccount db userId n
(k, a) <- walletAccount master n
pure $ CRWalletAccountSecret user a (accountSecret k)
APIDeleteWallet -> withUser_ $ do
deleted <- withFastStore' deleteWallet
@@ -6035,10 +6034,10 @@ liftWallet = liftEitherWith (ChatError . CEWallet)
withWalletStore :: (DB.Connection -> ExceptT StoreError IO (Either WalletError a)) -> CM a
withWalletStore action = liftWallet =<< withFastStore action
walletAccount :: Wallet -> AccountIndex -> CM (AccountKey, WalletAddress)
walletAccount Wallet {walletMaster} n = do
walletAccount :: WalletMaster -> AccountIndex -> CM (AccountKey, WalletAddress)
walletAccount master n = do
g <- asks random
liftIO $ deriveAccount g walletMaster n
liftIO $ deriveAccount g master n
chatCommandP :: Parser ChatCommand
chatCommandP =
@@ -6714,8 +6713,12 @@ chatCommandP =
text1P = safeDecodeUtf8 <$> A.takeTill (== ' ')
char_ = optional . A.char
accountIndexP = do
ds <- A.lookAhead $ A.takeWhile1 isDigit
when (B.length (B.dropWhile (== '0') ds) > 10) tooLarge
i <- A.decimal
maybe (fail "account index too large") pure $ if i <= toInteger (maxBound :: Word32) then mkAccountIndex (fromInteger i) else Nothing
if i <= toInteger (maxBound :: Word32) then either fail pure $ mkAccountIndex (fromInteger i) else tooLarge
where
tooLarge = fail "account index too large"
displayNameP :: Parser Text
displayNameP = safeDecodeUtf8 <$> displayNameP_
@@ -11,7 +11,7 @@ m20260924_wallet_seeds =
[r|
CREATE TABLE wallet_seeds (
wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entropy BYTEA NOT NULL CHECK (length(entropy) = 32),
entropy BYTEA NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)),
master BYTEA NOT NULL CHECK (length(master) = 64),
next_account_index BIGINT CHECK (next_account_index BETWEEN 0 AND 2147483648),
single_seed SMALLINT NOT NULL DEFAULT 1
@@ -1757,7 +1757,7 @@ CREATE TABLE test_chat_schema.wallet_seeds (
master bytea NOT NULL,
next_account_index bigint,
single_seed smallint DEFAULT 1 NOT NULL,
CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = 32)),
CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = ANY (ARRAY[16, 20, 24, 28, 32]))),
CONSTRAINT wallet_seeds_master_check CHECK ((length(master) = 64)),
CONSTRAINT wallet_seeds_next_account_index_check CHECK (((next_account_index >= 0) AND (next_account_index <= '2147483648'::bigint)))
);
@@ -10,7 +10,7 @@ m20260924_wallet_seeds =
[sql|
CREATE TABLE wallet_seeds (
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
entropy BLOB NOT NULL CHECK (length(entropy) = 32),
entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)),
master BLOB NOT NULL CHECK (length(master) = 64),
next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648),
single_seed INTEGER NOT NULL DEFAULT 1
@@ -983,7 +983,7 @@ CREATE TABLE badge_code_redemptions(
) STRICT;
CREATE TABLE wallet_seeds(
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
entropy BLOB NOT NULL CHECK(length(entropy) = 32),
entropy BLOB NOT NULL CHECK(length(entropy) IN(16, 20, 24, 28, 32)),
master BLOB NOT NULL CHECK(length(master) = 64),
next_account_index INTEGER CHECK(next_account_index BETWEEN 0 AND 2147483648),
single_seed INTEGER NOT NULL DEFAULT 1
+31 -24
View File
@@ -13,25 +13,26 @@ module Simplex.Chat.Store.Wallets
deleteWallet,
resolveAccount,
getUserAccounts,
accountHeldBy,
heldAccount,
bindAccount,
)
where
import Control.Monad.Except
import Data.Bifunctor (first)
import Control.Monad.IO.Class (liftIO)
import qualified Data.ByteArray as BA
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Word (Word32)
import Simplex.Chat.Store.Shared (StoreError (..))
import Simplex.Chat.Wallet (AccountIndex, WalletError (..))
import Simplex.Chat.Wallet (WalletError (..))
import Simplex.Messaging.Agent.Protocol (UserId)
import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Crypto.BIP32 (WalletMaster, masterBytes, masterEntropy, parseWalletMaster)
import Simplex.Messaging.Crypto.BIP39 (unEntropy)
import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex, unAccountIndex)
import Simplex.Messaging.Crypto.BIP44 (AccountIndex, mkAccountIndex, unAccountIndex)
import Simplex.Messaging.Util (liftEitherWith)
#if defined(dbPostgres)
@@ -47,7 +48,7 @@ type SeedId = Int64
data Wallet = Wallet
{ walletId :: SeedId,
walletMaster :: WalletMaster,
nextAccount :: Maybe Word32
nextAccountIndex :: Maybe Word32
}
getWallet :: DB.Connection -> ExceptT StoreError IO (Maybe Wallet)
@@ -56,8 +57,8 @@ getWallet db =
>>= mapM toWallet
where
toWallet :: (SeedId, ByteString, ByteString, Maybe Word32) -> ExceptT StoreError IO Wallet
toWallet (walletId, entropy, master, nextAccount) =
liftEitherWith SEInternalError $ (\walletMaster -> Wallet {walletId, walletMaster, nextAccount}) <$> parseWalletMaster (BA.convert entropy) (BA.convert master)
toWallet (walletId, entropy, master, nextAccountIndex) =
liftEitherWith SEInternalError $ (\walletMaster -> Wallet {walletId, walletMaster, nextAccountIndex}) <$> parseWalletMaster (BA.convert entropy) (BA.convert master)
createWallet :: DB.Connection -> WalletMaster -> Maybe Word32 -> IO Bool
createWallet db master nextAccount =
@@ -75,16 +76,19 @@ deleteWallet :: DB.Connection -> IO Bool
deleteWallet db =
rowReturned $ DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id"
resolveAccount :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (Wallet, AccountIndex))
resolveAccount db accountIdx_ =
resolveAccount :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (WalletMaster, AccountIndex))
resolveAccount db accountIdx_ = fmap (first walletMaster) <$> resolveAccount_ db accountIdx_
resolveAccount_ :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (Wallet, AccountIndex))
resolveAccount_ db accountIdx_ =
getWallet db >>= \case
Nothing -> pure $ Left WENoMaster
Just w@Wallet {nextAccount} -> pure $ (w,) <$> maybe next Right accountIdx_
Just w@Wallet {nextAccountIndex} -> pure $ (w,) <$> maybe next Right accountIdx_
where
next = maybe (Left WECounterUnknown) (maybe (Left WEAccountsExhausted) Right . mkAccountIndex) nextAccount
next = maybe (Left WECounterUnknown) (first (const WEAccountsExhausted) . mkAccountIndex) nextAccountIndex
getUserAccounts :: DB.Connection -> SeedId -> UserId -> IO [AccountIndex]
getUserAccounts db sId userId =
getUserAccounts :: DB.Connection -> UserId -> SeedId -> IO [AccountIndex]
getUserAccounts db userId sId =
map fromOnly
<$> DB.query
db
@@ -101,19 +105,19 @@ accountUser db sId n =
maybeFirstRow fromOnly $
DB.query db "SELECT user_id FROM wallet_accounts WHERE wallet_seed_id = ? AND account_index = ?" (sId, n)
bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (Wallet, AccountIndex))
bindAccount db userId accountIdx_ = resolveAccount db accountIdx_ >>= either (pure . Left) (liftIO . bind)
bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (WalletMaster, AccountIndex))
bindAccount db userId accountIdx_ = resolveAccount_ db accountIdx_ >>= either (pure . Left) (liftIO . bind)
where
bind r@(Wallet {walletId}, n) = do
bind (Wallet {walletId, walletMaster}, n) = do
held <-
accountUser db walletId n >>= \case
Just (Just heldBy) -> pure $ heldBy == userId
Just Nothing -> setAccountUser db walletId userId n
Nothing -> True <$ insertAccount db walletId userId n
if held then Right r <$ raiseNextAccount db walletId n else pure $ Left WEAccountBound
Just Nothing -> setAccountUser db userId walletId n
Nothing -> True <$ insertAccount db userId walletId n
if held then Right (walletMaster, n) <$ raiseNextAccount db walletId n else pure $ Left WEAccountBound
setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool
setAccountUser db sId userId n =
setAccountUser :: DB.Connection -> UserId -> SeedId -> AccountIndex -> IO Bool
setAccountUser db userId sId n =
rowReturned $
DB.query
db
@@ -124,8 +128,11 @@ setAccountUser db sId userId n =
|]
(userId, sId, n)
accountHeldBy :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool
accountHeldBy db sId userId n = (== Just (Just userId)) <$> accountUser db sId n
heldAccount :: DB.Connection -> UserId -> AccountIndex -> ExceptT StoreError IO (Either WalletError WalletMaster)
heldAccount db userId n =
getWallet db >>= \case
Nothing -> pure $ Left WENoMaster
Just Wallet {walletId, walletMaster} -> liftIO $ (\held -> if held == Just (Just userId) then Right walletMaster else Left WEAccountNotHeld) <$> accountUser db walletId n
raiseNextAccount :: DB.Connection -> SeedId -> AccountIndex -> IO ()
raiseNextAccount db sId n =
@@ -137,8 +144,8 @@ raiseNextAccount db sId n =
|]
(unAccountIndex n + 1, sId, n)
insertAccount :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO ()
insertAccount db sId userId n =
insertAccount :: DB.Connection -> UserId -> SeedId -> AccountIndex -> IO ()
insertAccount db userId sId n =
DB.execute db "INSERT INTO wallet_accounts (wallet_seed_id, account_index, user_id) VALUES (?, ?, ?)" (sId, n, userId)
rowReturned :: IO [Only Int64] -> IO Bool
+8 -6
View File
@@ -60,7 +60,7 @@ import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme
import Simplex.Chat.Wallet (WalletAddress (..), WalletError (..))
import Simplex.Chat.Wallet (WalletAddress (..), WalletError (..), WalletInfo (..))
import qualified Simplex.FileTransfer.Transport as XFTP
import Simplex.Messaging.Agent (DatabaseDiff (..))
import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..))
@@ -197,10 +197,12 @@ 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 accounts_ -> ttyUser u $ case accounts_ of
CRWallet u info_ -> ttyUser u $ case info_ of
Nothing -> ["no wallet on this device"]
Just [] -> ["wallet, no accounts for this profile"]
Just accounts -> [plain $ "accounts: " <> T.intercalate ", " (map (tshow . unAccountIndex) accounts)]
Just WalletInfo {accountIndexes, nextAccountIndex} ->
let next = maybe "unknown" tshow nextAccountIndex
accounts = if null accountIndexes then "no accounts for this profile" else "accounts: " <> T.intercalate ", " (map (tshow . unAccountIndex) accountIndexes)
in [plain $ "wallet, next account " <> next <> ", " <> accounts]
CRWalletMnemonic u mnemonic -> ttyUser u [plain mnemonic]
CRWalletAddress u a -> ttyUser u [walletAddressRow a]
CRWalletAccountSecret u a secret -> ttyUser u [walletAddressRow a <> " " <> plain secret]
@@ -1113,7 +1115,7 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of
walletAddressRow :: WalletAddress -> StyledString
walletAddressRow WalletAddress {accountIndex, keyPath, address} =
plain $ tshow (unAccountIndex accountIndex) <> " " <> keyPath <> " " <> address
plain $ tshow (unAccountIndex accountIndex) <> " " <> keyPath <> " " <> safeDecodeUtf8 (strEncode address)
viewContactsList :: [Contact] -> [StyledString]
viewContactsList =
@@ -2817,7 +2819,7 @@ viewChatError isCmd logLevel testView = \case
reason = case walletErr of
WENoMaster -> "this device has no wallet"
WEMasterExists -> "this device already has a wallet"
WEBadMnemonic -> "not a valid 24 word recovery phrase"
WEBadMnemonic -> "not a valid recovery phrase"
WEHiddenProfile -> "an account cannot be bound to a hidden profile"
WEAccountBound -> "another profile holds this account"
WEAccountNotHeld -> "this profile does not hold this account"
+21 -14
View File
@@ -2,14 +2,14 @@
{-# LANGUAGE TemplateHaskell #-}
module Simplex.Chat.Wallet
( AccountIndex,
AccountKey,
( AccountKey,
WalletAddress (..),
WalletInfo (..),
WalletError (..),
newWalletMaster,
entropyFromMnemonic,
importWalletMaster,
seedMnemonic,
masterMnemonic,
deriveAccount,
accountSecret,
)
@@ -18,15 +18,16 @@ where
import Control.Concurrent.STM
import Crypto.Random (ChaChaDRG)
import qualified Data.Aeson.TH as JQ
import Data.Bifunctor (first)
import qualified Data.ByteArray.Encoding as BAE
import Data.Text (Text)
import Data.Text.Encoding (decodeLatin1)
import Data.Word (Word32)
import qualified Simplex.Messaging.Crypto.BIP32 as B32
import qualified Simplex.Messaging.Crypto.BIP39 as B39
import Simplex.Messaging.Crypto.BIP44 (AccountIndex, CoinType (..), bip44Path)
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Eth.Address (addressFromPrivateKey)
import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON)
type AccountKey = S.Secp256k1PrivateKey
@@ -34,7 +35,13 @@ type AccountKey = S.Secp256k1PrivateKey
data WalletAddress = WalletAddress
{ accountIndex :: AccountIndex,
keyPath :: Text,
address :: Text
address :: Address
}
deriving (Show)
data WalletInfo = WalletInfo
{ accountIndexes :: [AccountIndex],
nextAccountIndex :: Maybe Word32
}
deriving (Show)
@@ -53,24 +60,22 @@ masterStrength :: B39.EntropyStrength
masterStrength = B39.ES256
newWalletMaster :: TVar ChaChaDRG -> IO B32.WalletMaster
newWalletMaster g = (`B32.mkWalletMaster` "") <$> atomically (B39.randomEntropy masterStrength g)
newWalletMaster g = B32.mkWalletMaster <$> atomically (B39.randomEntropy masterStrength g)
entropyFromMnemonic :: Text -> Either WalletError B39.WalletEntropy
entropyFromMnemonic phrase = case B39.parsePhrase phrase of
Right ent | B39.entropyWordCount ent == 24 -> Right ent
_ -> Left WEBadMnemonic
entropyFromMnemonic = first (const WEBadMnemonic) . B39.parsePhrase
importWalletMaster :: Text -> Either WalletError B32.WalletMaster
importWalletMaster phrase = (`B32.mkWalletMaster` "") <$> entropyFromMnemonic phrase
importWalletMaster phrase = B32.mkWalletMaster <$> entropyFromMnemonic phrase
seedMnemonic :: B32.WalletMaster -> Text
seedMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy
masterMnemonic :: B32.WalletMaster -> Text
masterMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy
deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (AccountKey, WalletAddress)
deriveAccount g master n = do
k <- B32.xkKey <$> B32.derivePath g (B32.walletMasterKey master) path
a <- addressFromPrivateKey g k
pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = decodeLatin1 $ strEncode a})
pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = a})
where
path = bip44Path Ethereum n
@@ -79,4 +84,6 @@ accountSecret k = "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 $ S.unPriva
$(JQ.deriveJSON defaultJSON ''WalletAddress)
$(JQ.deriveJSON defaultJSON ''WalletInfo)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "WE") ''WalletError)
+50 -45
View File
@@ -11,11 +11,10 @@ import qualified Data.ByteString.Char8 as B
import Data.Char (toUpper)
import Data.Either (isRight)
import Data.List (nub)
import Data.Maybe (fromJust)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Word (Word32)
import Simplex.Chat.Wallet (AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, seedMnemonic)
import Simplex.Chat.Wallet (AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, masterMnemonic)
import qualified Simplex.Messaging.Crypto as C
import qualified Simplex.Messaging.Crypto.BIP32 as B32
import qualified Simplex.Messaging.Crypto.BIP39 as B39
@@ -33,12 +32,12 @@ testPhrase24 :: Text
testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"]
walletMaster :: Text -> B32.WalletMaster
walletMaster phrase = B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) ""
walletMaster phrase = B32.mkWalletMaster (either error id $ B39.parsePhrase phrase)
walletAccount :: Text -> Word32 -> IO (AccountKey, WalletAddress)
walletAccount phrase n = do
g <- C.newRandom
deriveAccount g (walletMaster phrase) (fromJust $ mkAccountIndex n)
deriveAccount g (walletMaster phrase) (either error id $ mkAccountIndex n)
addressFromSecret :: String -> IO String
addressFromSecret secret = do
@@ -57,7 +56,7 @@ accountBound cc idx = (take 1 . words <$> getTermLine cc) `shouldReturn` [idx]
walletDerivationTests :: Spec
walletDerivationTests = do
Hspec.it "derives the addresses another wallet derives for the same phrase" $ do
let addrOf n = address . snd <$> walletAccount testPhrase12 n
let addrOf n = strEncode . address . snd <$> walletAccount testPhrase12 n
addrOf 0 `shouldReturn` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
addrOf 1 `shouldReturn` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265"
Hspec.it "the exported secret is the one another wallet shows for that account" $ do
@@ -75,10 +74,10 @@ walletDerivationTests = do
(keyPath . snd <$> walletAccount testPhrase12 0) `shouldReturn` "m/44'/60'/0'/0/0"
(keyPath . snd <$> walletAccount testPhrase12 7) `shouldReturn` "m/44'/60'/7'/0/0"
Hspec.it "round-trips the phrase it was imported from" $
seedMnemonic (walletMaster testPhrase24) `shouldBe` testPhrase24
Hspec.it "accepts only 24 words with a valid checksum" $ do
masterMnemonic (walletMaster testPhrase24) `shouldBe` testPhrase24
Hspec.it "accepts a phrase of any BIP-39 length with a valid checksum" $ do
entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight
entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic
entropyFromMnemonic testPhrase12 `shouldSatisfy` isRight
entropyFromMnemonic (T.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic
walletTests :: SpecWith TestParams
@@ -110,50 +109,56 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet 1"
alice <## "no wallet on this device"
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet create new"
alice <## "wallet: this device already has a wallet"
alice ##> "/_wallet delete"
alice <## "ok"
alice ##> ("/_wallet create mnemonic=" <> unwords (replicate 24 "abandon"))
alice <## "wallet: not a valid 24 word recovery phrase"
alice <## "wallet: not a valid recovery phrase"
alice ##> ("/_wallet create mnemonic=" <> map toUpper (T.unpack testPhrase24))
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account unknown, no accounts for this profile"
alice ##> "/_wallet export master"
alice <## T.unpack testPhrase24
alice ##> "/_wallet delete"
alice <## "ok"
alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase12)
alice <## "wallet, next account unknown, no accounts for this profile"
alice ##> "/_wallet address account=0"
alice <## "0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
testWalletBind :: HasCallStack => TestParams -> IO ()
testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet bind 1"
alice `accountBound` "0"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet bind 1"
alice `accountBound` "1"
alice ##> "/_wallet bind 1"
alice `accountBound` "2"
alice ##> "/_wallet bind 1 account=0"
alice `accountBound` "0"
alice ##> "/_wallet 1"
alice <## "accounts: 0, 1"
alice <## "wallet, next account 3, accounts: 0, 1, 2"
testWalletAccountsPerProfile :: HasCallStack => TestParams -> IO ()
testWalletAccountsPerProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet bind 1"
alice `accountBound` "0"
alice `accountBound` "1"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/_wallet 2"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 2, no accounts for this profile"
alice ##> "/_wallet bind 2"
alice `accountBound` "1"
alice ##> "/_wallet bind 2 account=0"
alice `accountBound` "2"
alice ##> "/_wallet bind 2 account=1"
alice <## "wallet: another profile holds this account"
testWalletBindByIndexThenNext :: HasCallStack => TestParams -> IO ()
testWalletBindByIndexThenNext ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet bind 1 account=2"
alice `accountBound` "2"
alice ##> "/_wallet bind 1"
@@ -161,34 +166,34 @@ testWalletBindByIndexThenNext ps = withNewTestChat ps "alice" aliceProfile $ \al
alice ##> "/_wallet bind 1 account=1"
alice `accountBound` "1"
alice ##> "/_wallet 1"
alice <## "accounts: 1, 2, 3"
alice <## "wallet, next account 4, accounts: 1, 2, 3"
alice ##> "/_wallet bind 1"
alice `accountBound` "4"
testWalletDeletedProfileAccount :: HasCallStack => TestParams -> IO ()
testWalletDeletedProfileAccount ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet bind 1"
alice `accountBound` "0"
alice `accountBound` "1"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/delete user alice"
alice <### ["ok", "completed deleting user"]
alice ##> "/_wallet 2"
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet bind 2 account=0"
alice `accountBound` "0"
alice <## "wallet, next account 2, no accounts for this profile"
alice ##> "/_wallet bind 2 account=1"
alice `accountBound` "1"
testWalletAddress :: HasCallStack => TestParams -> IO ()
testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet address"
addr <- getTermLine alice
alice ##> "/_wallet address"
getTermLine alice `shouldReturn` addr
words addr !! 1 `shouldBe` "m/44'/60'/0'/0/0"
words addr !! 1 `shouldBe` "m/44'/60'/1'/0/0"
alice ##> "/_wallet address account=3"
at3 <- getTermLine alice
words at3 !! 1 `shouldBe` "m/44'/60'/3'/0/0"
@@ -197,12 +202,12 @@ testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet bind 1 account=abc"
alice <## "bad chat command: Failed reading: empty"
alice ##> "/_wallet 1"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
testWalletExport :: HasCallStack => TestParams -> IO ()
testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase24)
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account unknown, no accounts for this profile"
alice ##> "/_wallet export master"
alice <## T.unpack testPhrase24
alice ##> "/_wallet bind 1 account=0"
@@ -219,7 +224,7 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
(idx', path', addr', secret') <- exportRow <$> getTermLine alice
idx' `shouldBe` "1"
path' `shouldBe` "m/44'/60'/1'/0/0"
(T.unpack . address . snd <$> walletAccount testPhrase24 1) `shouldReturn` addr'
(B.unpack . strEncode . address . snd <$> walletAccount testPhrase24 1) `shouldReturn` addr'
addressFromSecret secret' `shouldReturn` addr'
alice ##> "/_wallet address account=1"
(words <$> getTermLine alice) `shouldReturn` ["1", "m/44'/60'/1'/0/0", addr']
@@ -227,7 +232,7 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
testWalletImport :: HasCallStack => TestParams -> IO ()
testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase24)
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account unknown, no accounts for this profile"
alice ##> "/_wallet bind 1"
alice <## "wallet: the next account is unknown after an import"
alice ##> "/_wallet address"
@@ -241,14 +246,14 @@ testWalletPersists :: HasCallStack => TestParams -> IO ()
testWalletPersists ps = do
phrase <- withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet bind 1 account=2"
alice `accountBound` "2"
alice ##> "/_wallet export master"
getTermLine alice
withTestChat ps "alice" $ \alice -> do
alice ##> "/_wallet 1"
alice <## "accounts: 2"
alice <## "wallet, next account 3, accounts: 2"
alice ##> "/_wallet export master"
alice <## phrase
alice ##> "/_wallet bind 1"
@@ -257,7 +262,7 @@ testWalletPersists ps = do
testWalletDelete :: HasCallStack => TestParams -> IO ()
testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet bind 1 account=1"
alice `accountBound` "1"
alice ##> "/_wallet delete"
@@ -265,14 +270,14 @@ testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet 1"
alice <## "no wallet on this device"
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet bind 1"
alice `accountBound` "0"
alice `accountBound` "1"
testWalletHiddenProfile :: HasCallStack => TestParams -> IO ()
testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/hide user my_password"
@@ -285,15 +290,15 @@ testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice ->
testWalletExportNotHeld :: HasCallStack => TestParams -> IO ()
testWalletExportNotHeld ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet bind 1"
alice `accountBound` "0"
alice ##> "/_wallet export account 1 0"
alice `accountBound` "1"
alice ##> "/_wallet export account 1 1"
(_, path, _, _) <- exportRow <$> getTermLine alice
path `shouldBe` "m/44'/60'/0'/0/0"
path `shouldBe` "m/44'/60'/1'/0/0"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/_wallet export account 2 0"
alice ##> "/_wallet export account 2 1"
alice <## "wallet: this profile does not hold this account"
alice ##> "/_wallet export account 2 7"
alice <## "wallet: this profile does not hold this account"
@@ -301,7 +306,7 @@ testWalletExportNotHeld ps = withNewTestChat ps "alice" aliceProfile $ \alice ->
testWalletIndexTooLarge :: HasCallStack => TestParams -> IO ()
testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice <## "wallet, next account 1, no accounts for this profile"
alice ##> "/_wallet address account=2147483648"
alice <## "bad chat command: Failed reading: empty"
alice ##> "/_wallet bind 1 account=2147483648"