/goal iteration 4 - unconfirmed

This commit is contained in:
Alain Brenzikofer committed 2026-09-21 14:42:34 +02:00
1 parent 4f21e68972
commit bfc4e1fbaa
17 files changed
+798 -280

No files matched your search

+240 -116
View File
@@ -6,159 +6,283 @@ module WalletTests where
import ChatClient
import ChatTests.DBUtils
import ChatTests.Utils
import qualified Data.ByteArray as BA
import qualified Data.ByteArray.Encoding as BAE
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Either (isLeft)
import Data.Either (isRight)
import Data.List (nub)
import qualified Data.Text as T
import Simplex.Chat.Wallet (AccountIndex, WalletError (..), WalletSeed (..), accountSecret, deriveAccountKey, entropyFromMnemonic, renderAccountPath, seedMaster, seedMnemonic)
import qualified Simplex.Messaging.Crypto.BIP39 as B39
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Chat.Controller (redactedCommand)
import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, recoveryKeyPhrase, renderNameKeyPath, seedMaster)
import Simplex.Messaging.Eth.Address (addressFromPrivateKey)
import Simplex.Messaging.Util (safeDecodeUtf8)
import Test.Hspec hiding (it)
import qualified Test.Hspec as Hspec
-- | The standard BIP-39 test vector, so the addresses can be checked elsewhere.
testPhrase :: ByteString
testPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
-- | The standard BIP-39 test vector, 12 words, so the addresses below can be
-- checked against any other wallet. Importing takes 24 words, so this phrase is
-- only used for derivation, never through a command.
testPhrase12 :: ByteString
testPhrase12 = B.unwords $ replicate 11 "abandon" <> ["about"]
testSeed :: WalletSeed
testSeed = WalletSeed {wsId = 1, wsEntropy = either error id $ importRecoveryKey testPhrase}
-- | The 24 word all-zero-entropy vector, the length the commands take. It is a
-- different seed from the 12 word one, so it reaches different addresses.
testPhrase24 :: ByteString
testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"]
nameKey :: NameIndex -> Either String S.PrivateKey
nameKey nm = seedMaster testSeed >>= \m -> deriveNameKey m nm
seedFromPhrase :: ByteString -> WalletSeed
seedFromPhrase phrase =
WalletSeed {wsId = 1, wsEntropy = BA.convert . B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase}
accountKey :: WalletSeed -> AccountIndex -> S.PrivateKey
accountKey seed n = either (error . show) id $ seedMaster seed >>= \m -> deriveAccountKey m n
walletDerivationTests :: Spec
walletDerivationTests = do
Hspec.it "name keys line up with other wallets' derivation" $ do
let addrOf k = either error (show . addressFromPrivateKey) (nameKey k)
-- MetaMask accounts 2 and 3 for this phrase; account 1 is the unused m/44'/60'/0'/0/0
addrOf 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
addrOf 2 `shouldBe` "0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A"
Hspec.it "derives the same secret as other wallets" $
-- MetaMask account 2 for this phrase, as exported by "Show private key"
either error nameKeySecret (nameKey 1)
`shouldBe` "0x9a983cb3d832fbde5ab49d692b7a8bf5b5d232479c99333d0fc8e1d21f1b55b6"
Hspec.it "renders the path a name key sits at" $ do
renderNameKeyPath 1 `shouldBe` "m/44'/60'/0'/0/1"
renderNameKeyPath 7 `shouldBe` "m/44'/60'/0'/0/7"
Hspec.it "accounts are the accounts another wallet derives for the same phrase" $ do
let addrOf = show . addressFromPrivateKey . accountKey (seedFromPhrase testPhrase12)
-- Ledger Live accounts 1 and 2 for this phrase, the published values for it
addrOf 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
addrOf 1 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265"
Hspec.it "the exported secret is the one another wallet shows for that account" $ do
let k = accountKey (seedFromPhrase testPhrase12) 0
-- as a wallet shows it for m/44'/60'/0'/0/0 of this phrase
accountSecret k `shouldBe` "0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727"
Hspec.it "every account has its own address" $ do
let seed = seedFromPhrase testPhrase12
addrs = map (show . addressFromPrivateKey . accountKey seed) [0 .. 9]
length (nub addrs) `shouldBe` 10
Hspec.it "a secret whose first byte is zero keeps its 64 hex digits" $ do
let k = either error id . S.mkPrivateKey $ B.pack ('\0' : replicate 31 '\1')
secret = T.unpack $ accountSecret k
take 4 secret `shouldBe` "0x00"
length secret `shouldBe` 66
Hspec.it "renders the path an account sits at" $ do
renderAccountPath 0 `shouldBe` "m/44'/60'/0'/0/0"
renderAccountPath 7 `shouldBe` "m/44'/60'/7'/0/0"
Hspec.it "round-trips the phrase it was imported from" $
recoveryKeyPhrase testSeed `shouldBe` Right testPhrase
Hspec.it "refuses a phrase with a bad checksum" $
importRecoveryKey (B.unwords $ replicate 12 "abandon") `shouldSatisfy` isLeft
Hspec.it "keeps a secret out of the log a remote host writes" $ do
redactedCommand ("/_wallet create mnemonic=" <> safeDecodeUtf8 testPhrase)
`shouldBe` "/_wallet create mnemonic=<redacted>"
redactedCommand "/_wallet export name 1 secret=shibboleth"
`shouldBe` "/_wallet export name 1 secret=<redacted>"
redactedCommand "/_wallet export name 1" `shouldBe` "/_wallet export name 1"
seedMnemonic (seedFromPhrase testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24)
Hspec.it "takes 24 words only, with a valid checksum" $ do
entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight
entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic
entropyFromMnemonic (B.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic
testWalletHiddenProfile :: HasCallStack => TestParams -> IO ()
testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/hide user my_password"
alice <## "current user alisa:"
alice <## "messages are hidden (use /tail to view)"
alice <## "profile is hidden"
alice ##> "/_wallet bind"
alice <## "wallet: a hidden profile cannot own an account"
testWalletExportNotMine :: HasCallStack => TestParams -> IO ()
testWalletExportNotMine ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet bind"
alice <## "accounts: 0"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
-- account 0 is the other profile's, and its key is not this profile's to take
alice ##> "/_wallet export account 0"
alice <## "wallet: another profile holds this account"
-- an account nobody holds is still derivable, which is what a scan needs
alice ##> "/_wallet export account 7"
row <- getTermLine alice
words row !! 1 `shouldBe` "m/44'/60'/7'/0/0"
testWalletIndexTooLarge :: HasCallStack => TestParams -> IO ()
testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
-- 2^31 is already a hardened component, so it would derive account 0's key
alice ##> "/_wallet address account=2147483648"
alice <## "wallet: account index is too large to harden"
alice ##> "/_wallet bind account=2147483648"
alice <## "wallet: account index is too large to harden"
alice ##> "/_wallet export account 2147483648"
alice <## "wallet: account index is too large to harden"
-- the largest index that can be hardened is usable, and the counter follows it
alice ##> "/_wallet bind account=2147483647"
alice <## "accounts: 2147483647"
alice ##> "/_wallet bind"
alice <## "wallet: account index is too large to harden"
-- | The address a wallet reaches when the secret is imported as a private key.
addressFromSecret :: String -> String
addressFromSecret secret =
show . addressFromPrivateKey . either error id . S.mkPrivateKey . either error id $
BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)
walletTests :: SpecWith TestParams
walletTests = do
it "creates no seed until asked, then derives addresses" testWalletCreate
it "the seed and the addresses come back after a restart" testWalletPersists
it "every profile sees the same names, as the seed is the device's" testWalletSharedByProfiles
it "imports a phrase, exports it, and refuses a second import" testWalletImport
it "exports the secret of any name key" testWalletExportDerivedSecret
it "deletes the seed, and a seed can be imported again" testWalletDelete
it "discards a seed imported before the database is restored" testWalletImportThenRestore
-- | The state a chat database backed up before the seed restores to.
forgetSeed :: HasCallStack => TestCC -> IO ()
forgetSeed cc = cc ##> "/sql chat DELETE FROM wallet_seeds"
nameRows :: HasCallStack => TestCC -> IO [(String, String)]
nameRows cc = mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int]
where
nameRow l = case words l of
[path, addr] -> (path, addr)
_ -> error $ "unexpected wallet row: " <> l
it "creates no wallet until asked, and only one" testWalletCreate
it "binds the next free account, and re-binding one it holds changes nothing" testWalletBind
it "keeps each profile's accounts apart" testWalletAccountsPerProfile
it "taking the next account skips one already bound by index" testWalletBindByIndexThenNext
it "derives an address without taking it" testWalletAddress
it "exports the master phrase and one account's secret" testWalletExport
it "will not take a new account on an imported phrase" testWalletImport
it "the wallet and the accounts come back after a restart" testWalletPersists
it "deletes the wallet, and one can be made again" testWalletDelete
it "a hidden profile is bound no account" testWalletHiddenProfile
it "will not export an account another profile holds" testWalletExportNotMine
it "refuses an index BIP-32 cannot harden, on every command" testWalletIndexTooLarge
testWalletCreate :: HasCallStack => TestParams -> IO ()
testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet"
alice <## "no wallet key"
-- asking creates nothing
alice <## "no wallet on this device"
-- reading creates nothing
alice ##> "/_wallet"
alice <## "no wallet key"
alice ##> "/_wallet export"
alice <## "bad chat command: no wallet key on this device"
alice <## "no wallet on this device"
alice ##> "/_wallet export master"
alice <## "wallet: this device has no wallet"
alice ##> "/_wallet create new"
rows <- nameRows alice
map fst rows `shouldBe` ["m/44'/60'/0'/0/1", "m/44'/60'/0'/0/2"]
length (nub $ map snd rows) `shouldBe` 2
-- create is for the seed, and this device has one
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet create new"
alice <## "bad chat command: this device already has a wallet key"
alice <## "wallet: this device already has a wallet"
alice ##> "/_wallet delete"
alice <## "ok"
-- a mistyped phrase says nothing about which word was wrong
alice ##> ("/_wallet create mnemonic=" <> B.unpack (B.unwords $ replicate 24 "abandon"))
alice <## "wallet: not a valid 24 word recovery phrase"
testWalletPersists :: HasCallStack => TestParams -> IO ()
testWalletPersists ps = do
rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
nameRows alice
-- same database, new session: a name bought at that address must stay reachable
withTestChat ps "alice" $ \alice -> do
alice ##> "/_wallet"
rows' <- nameRows alice
rows' `shouldBe` rows
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"
alice <## "accounts: 0"
-- a profile owns as many accounts as it owns names
alice ##> "/_wallet bind"
alice <## "accounts: 0, 1"
-- binding one it already holds changes nothing
alice ##> "/_wallet bind account=0"
alice <## "accounts: 0, 1"
testWalletSharedByProfiles :: HasCallStack => TestParams -> IO ()
testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
rows <- nameRows alice
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 bind"
alice <## "accounts: 0"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
-- the seed belongs to the device, so a name is not a profile's to see or not
-- the wallet is the device's, the accounts are the profile's
alice ##> "/_wallet"
rows' <- nameRows alice
rows' `shouldBe` rows
alice ##> "/_wallet export"
alice <## B.unpack testPhrase
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet bind"
alice <## "accounts: 1"
alice ##> "/_wallet bind account=0"
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"
-- an account a scan found, bound by index, is still taken
alice ##> "/_wallet bind account=2"
alice <## "accounts: 2"
alice ##> "/_wallet bind"
alice <## "accounts: 2, 3"
-- accounts are listed by index, not by the order they were bound
alice ##> "/_wallet bind account=1"
alice <## "accounts: 1, 2, 3"
-- and binding a low index never moves the counter back onto an account held
alice ##> "/_wallet bind"
alice <## "accounts: 1, 2, 3, 4"
testWalletAddress :: HasCallStack => TestParams -> IO ()
testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
-- reading the next free account does not take it
alice ##> "/_wallet address"
addr <- getTermLine alice
alice ##> "/_wallet address"
addr' <- getTermLine alice
addr' `shouldBe` addr
words addr !! 1 `shouldBe` "m/44'/60'/0'/0/0"
alice ##> "/_wallet address account=3"
at3 <- getTermLine alice
words at3 !! 1 `shouldBe` "m/44'/60'/3'/0/0"
-- a malformed index is a parse error, never a silent bind of the next account
alice ##> "/_wallet bind account=abc"
alice <## "bad chat command: Failed reading: empty"
alice ##> "/_wallet"
alice <## "wallet, no accounts for this profile"
-- an index BIP-32 cannot harden is refused rather than folded onto a low one
alice ##> "/_wallet address account=2147483648"
alice <## "wallet: account index is too large to harden"
testWalletExport :: HasCallStack => TestParams -> IO ()
testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase24)
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet export master"
alice <## B.unpack testPhrase24
alice ##> "/_wallet export account 0"
row <- getTermLine alice
case words row of
[idx, path, address, secret] -> do
idx `shouldBe` "0"
path `shouldBe` "m/44'/60'/0'/0/0"
-- m/44'/60'/0'/0/0 of the 24 word vector, pinned outside this
-- implementation, so a change of path fails here rather than shipping
address `shouldBe` "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb"
addressFromSecret secret `shouldBe` address
_ -> expectationFailure $ "unexpected export row: " <> row
testWalletImport :: HasCallStack => TestParams -> IO ()
testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
alice <## "m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
alice <## "m/44'/60'/0'/0/2 0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A"
alice ##> "/_wallet export"
alice <## B.unpack testPhrase
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
alice <## "bad chat command: this device already has a wallet key"
-- a mistyped phrase says nothing about which word was wrong
alice ##> ("/_wallet create mnemonic=" <> B.unpack (B.unwords $ replicate 12 "abandon"))
alice <## "bad chat command: bad recovery phrase"
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase24)
alice <## "wallet, no accounts for this profile"
-- the phrase does not say how many accounts it has been used for
alice ##> "/_wallet bind"
alice <## "wallet: unknown how many accounts this phrase has used, a scan of the chain has to run first"
alice ##> "/_wallet address"
alice <## "wallet: unknown how many accounts this phrase has used, a scan of the chain has to run first"
-- binding an account a scan found is what a restored device does
alice ##> "/_wallet bind account=4"
alice <## "accounts: 4"
-- and the counter stays unknown, because the phrase still does not say
alice ##> "/_wallet bind"
alice <## "wallet: unknown how many accounts this phrase has used, a scan of the chain has to run first"
testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO ()
testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
_ <- nameRows alice
alice ##> "/_wallet export name 1"
alice <## "m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 0x9a983cb3d832fbde5ab49d692b7a8bf5b5d232479c99333d0fc8e1d21f1b55b6"
-- a secret whose first byte is zero keeps its 64 hex digits
alice ##> "/_wallet export name 15"
alice <## "m/44'/60'/0'/0/15 0xa25d37554EB084969C85362f7E6B1A6108e51d0e 0x009a1ccd9c667416d9db6246a35d022b1799517c0cd8547bb07ce280c119ae3c"
-- an index BIP-32 cannot reach is rejected, not wrapped into another key
alice ##> "/_wallet export name 4294967296"
alice <## "bad chat command: Failed reading: empty"
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 bind account=2"
alice <## "accounts: 2"
alice ##> "/_wallet export master"
getTermLine alice
-- same database, new session: an account holding a name must stay reachable
withTestChat ps "alice" $ \alice -> do
alice ##> "/_wallet"
alice <## "accounts: 2"
alice ##> "/_wallet export master"
alice <## phrase
testWalletDelete :: HasCallStack => TestParams -> IO ()
testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
_ <- nameRows alice
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet bind account=1"
alice <## "accounts: 1"
alice ##> "/_wallet delete"
alice <## "ok"
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
_ <- nameRows alice
pure ()
testWalletImportThenRestore :: HasCallStack => TestParams -> IO ()
testWalletImportThenRestore ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
_ <- nameRows alice
-- restoring the database replaces the seed with what the backup held, which is nothing
forgetSeed alice
alice ##> "/_wallet"
alice <## "no wallet key"
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
rows <- nameRows alice
map fst rows `shouldBe` ["m/44'/60'/0'/0/1", "m/44'/60'/0'/0/2"]
alice <## "no wallet on this device"
-- the accounts went with the entropy they were counted against
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"