mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-30 00:08:44 +00:00
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
130 lines
5.8 KiB
Haskell
130 lines
5.8 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 (intersect, nub)
|
|
import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, recoveryKeyPhrase, renderNameKeyPath)
|
|
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 = SeedId 1, wsEntropy = either error id $ importRecoveryKey testPhrase}
|
|
|
|
walletDerivationTests :: Spec
|
|
walletDerivationTests = do
|
|
-- profile 0's names are MetaMask's account list, and each profile's first
|
|
-- name is the matching Ledger Live account
|
|
Hspec.it "name keys line up with other wallets' derivation" $ do
|
|
let addrOf i k = either error (show . accountAddress) (deriveNameKey testSeed i k)
|
|
-- MetaMask accounts 1 and 2 for this phrase
|
|
addrOf 0 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
|
|
addrOf 0 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
|
|
-- Ledger Live account 2 for this phrase
|
|
addrOf 1 0 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265"
|
|
Hspec.it "renders the path a name key sits at" $ do
|
|
renderNameKeyPath 0 0 `shouldBe` "m/44'/60'/0'/0/0"
|
|
renderNameKeyPath 2 7 `shouldBe` "m/44'/60'/2'/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 key until asked, then derives addresses" testWalletCreate
|
|
it "the key and the addresses come back after a restart" testWalletPersists
|
|
it "a second profile gets its own account, on the same key" testWalletSecondProfile
|
|
it "imports a phrase, exports it, and refuses a second import" testWalletImport
|
|
it "deletes the key only with the last word of the phrase" testWalletDelete
|
|
|
|
accountRows :: HasCallStack => TestCC -> String -> Int -> IO [(String, String)]
|
|
accountRows cc profile acct = do
|
|
cc <## ("account " <> show acct <> " (" <> profile <> ")")
|
|
mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int]
|
|
where
|
|
nameRow l = case words l of
|
|
["name", _, 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"
|
|
rows <- accountRows alice "alice, active" 0
|
|
map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"]
|
|
length (nub $ map snd rows) `shouldBe` 2
|
|
|
|
testWalletPersists :: HasCallStack => TestParams -> IO ()
|
|
testWalletPersists ps = do
|
|
rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
|
alice ##> "/_wallet create"
|
|
accountRows alice "alice, active" 0
|
|
-- same database, new session: a name bought at that address must stay reachable
|
|
withTestChat ps "alice" $ \alice -> do
|
|
alice ##> "/_wallet"
|
|
rows' <- accountRows alice "alice, active" 0
|
|
rows' `shouldBe` rows
|
|
|
|
testWalletSecondProfile :: HasCallStack => TestParams -> IO ()
|
|
testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
|
alice ##> "/_wallet create"
|
|
rows <- accountRows alice "alice, active" 0
|
|
alice ##> "/_wallet export"
|
|
phrase <- getTermLine alice
|
|
alice ##> "/create user alisa"
|
|
showActiveUser alice "alisa"
|
|
alice ##> "/_wallet"
|
|
_ <- accountRows alice "alice" 0
|
|
alice <## "this profile has no wallet key"
|
|
-- the key belongs to the device, so a profile without an account exports it too
|
|
alice ##> "/_wallet export"
|
|
alice <## phrase
|
|
alice ##> "/_wallet create"
|
|
_ <- accountRows alice "alice" 0
|
|
rows' <- accountRows alice "alisa, active" 1
|
|
null (map snd rows `intersect` map snd rows') `shouldBe` True
|
|
|
|
testWalletImport :: HasCallStack => TestParams -> IO ()
|
|
testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
|
alice ##> ("/_wallet import " <> B.unpack testPhrase)
|
|
alice <## "account 0 (alice, active)"
|
|
alice <## " name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
|
|
alice <## " name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
|
|
alice ##> "/_wallet export"
|
|
alice <## B.unpack testPhrase
|
|
alice ##> ("/_wallet import " <> 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 import " <> B.unpack (B.unwords $ replicate 12 "abandon"))
|
|
alice <## "bad chat command: bad recovery phrase"
|
|
|
|
testWalletDelete :: HasCallStack => TestParams -> IO ()
|
|
testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
|
alice ##> ("/_wallet import " <> B.unpack testPhrase)
|
|
_ <- accountRows alice "alice, active" 0
|
|
alice ##> "/_wallet delete abandon"
|
|
alice <## "bad chat command: this deletes the wallet key for all profiles on this device, to confirm pass the last word of the recovery phrase"
|
|
alice ##> "/_wallet delete About"
|
|
alice <## "no wallet key"
|
|
-- deleting unbinds the profile, so a key can be imported again
|
|
alice ##> ("/_wallet import " <> B.unpack testPhrase)
|
|
_ <- accountRows alice "alice, active" 0
|
|
pure ()
|