mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 22:18:36 +00:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
157 lines
7.2 KiB
Haskell
157 lines
7.2 KiB
Haskell
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE PostfixOperators #-}
|
|
|
|
module WalletTests where
|
|
|
|
import ChatClient
|
|
import ChatTests.DBUtils
|
|
import ChatTests.Utils
|
|
import Data.ByteString.Char8 (ByteString)
|
|
import qualified Data.ByteString.Char8 as B
|
|
import Data.Either (isLeft)
|
|
import Data.List (nub)
|
|
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
|
|
import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, recoveryKeyPhrase, renderNameKeyPath, seedMaster)
|
|
import Simplex.Messaging.Eth.Address (addressFromPrivateKey)
|
|
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"
|
|
|
|
testSeed :: WalletSeed
|
|
testSeed = WalletSeed {wsId = 1, wsEntropy = either error id $ importRecoveryKey testPhrase}
|
|
|
|
nameKey :: NameIndex -> Either String S.PrivateKey
|
|
nameKey nm = seedMaster testSeed >>= \m -> deriveNameKey m nm
|
|
|
|
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 "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
|
|
|
|
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
|
|
|
|
testWalletCreate :: HasCallStack => TestParams -> IO ()
|
|
testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
|
alice ##> "/_wallet"
|
|
alice <## "no wallet key"
|
|
-- asking creates nothing
|
|
alice ##> "/_wallet"
|
|
alice <## "no wallet key"
|
|
alice ##> "/_wallet export"
|
|
alice <## "bad chat command: no wallet key on this device"
|
|
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 create new"
|
|
alice <## "bad chat command: this device already has a wallet key"
|
|
|
|
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
|
|
|
|
testWalletSharedByProfiles :: HasCallStack => TestParams -> IO ()
|
|
testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
|
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
|
|
rows <- nameRows alice
|
|
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
|
|
alice ##> "/_wallet"
|
|
rows' <- nameRows alice
|
|
rows' `shouldBe` rows
|
|
alice ##> "/_wallet export"
|
|
alice <## B.unpack testPhrase
|
|
|
|
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"
|
|
|
|
testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO ()
|
|
testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
|
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
|
|
_ <- nameRows alice
|
|
alice ##> "/_wallet export 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 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 4294967296"
|
|
alice <## "bad chat command: Failed reading: empty"
|
|
|
|
testWalletDelete :: HasCallStack => TestParams -> IO ()
|
|
testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
|
alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase)
|
|
_ <- nameRows alice
|
|
alice ##> "/_wallet delete"
|
|
alice <## "no wallet key"
|
|
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"]
|