mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 15:48:54 +00:00
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
ee11b368b6
commit
70d4c128bd
@@ -1,14 +1,5 @@
|
||||
# Release History
|
||||
|
||||
## Unreleased
|
||||
|
||||
Wallet (in development, CLI only): creates the key that will own your SimpleX
|
||||
names, so a name you buy later has an owner this app can still derive. One key
|
||||
per device, one account per chat profile, each name at `m/44'/60'/<profile>'/0/<name>`
|
||||
(standard BIP-44, so your recovery phrase works in other wallets). `/wallet`
|
||||
shows the addresses, `/wallet import` and `/wallet export` move the key,
|
||||
`/wallet delete` removes it. You cannot buy a name yet.
|
||||
|
||||
## v6.5
|
||||
|
||||
30 April, 2026
|
||||
|
||||
@@ -325,7 +325,7 @@ data ChatController = ChatController
|
||||
contactMergeEnabled :: TVar Bool
|
||||
}
|
||||
|
||||
data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase | HSWallet
|
||||
data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase
|
||||
deriving (Show)
|
||||
|
||||
data ChatCommand
|
||||
|
||||
@@ -14,7 +14,6 @@ module Simplex.Chat.Help
|
||||
markdownInfo,
|
||||
settingsInfo,
|
||||
databaseHelpInfo,
|
||||
walletHelpInfo,
|
||||
)
|
||||
where
|
||||
|
||||
@@ -89,7 +88,7 @@ chatHelpInfo =
|
||||
green "Create your address: " <> highlight "/address",
|
||||
"",
|
||||
green "Other commands:",
|
||||
indent <> highlight "/help <topic> " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db", "wallet"],
|
||||
indent <> highlight "/help <topic> " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db"],
|
||||
indent <> highlight "/profile " <> " - show / update user profile",
|
||||
indent <> highlight "/delete <contact>" <> " - delete contact and all messages with them",
|
||||
indent <> highlight "/chats " <> " - most recent chats",
|
||||
@@ -219,30 +218,6 @@ myAddressHelpInfo =
|
||||
"The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"]
|
||||
]
|
||||
|
||||
walletHelpInfo :: [StyledString]
|
||||
walletHelpInfo =
|
||||
map
|
||||
styleMarkdown
|
||||
[ green "Wallet commands:",
|
||||
indent <> highlight "/wallet " <> " - your key, and the addresses it derives",
|
||||
indent <> highlight "/wallet create " <> " - create your key, or add this profile to it",
|
||||
indent <> highlight "/wallet import <phrase>" <> " - use a key you already have",
|
||||
indent <> highlight "/wallet export " <> " - show your recovery phrase",
|
||||
indent <> highlight "/wallet delete <word> " <> " - delete the key, confirmed by the last word of the phrase",
|
||||
"",
|
||||
"Please note: this is in development. You cannot buy a name yet.",
|
||||
"",
|
||||
"One key per device, one account per chat profile. Each name gets its own",
|
||||
"key at " <> highlight "m/44'/60'/<profile>'/0/<name>" <> ". This is standard BIP-44, so your",
|
||||
"phrase works in other wallets.",
|
||||
"",
|
||||
"The key is stored in the chat database. It is only encrypted if you set a",
|
||||
"database passphrase with " <> highlight "/db encrypt" <> ", and it is included in " <> highlight "/db export" <> ".",
|
||||
"",
|
||||
"Anyone who has your recovery phrase controls your names. Keep it secret,",
|
||||
"and keep a copy."
|
||||
]
|
||||
|
||||
incognitoHelpInfo :: [StyledString]
|
||||
incognitoHelpInfo =
|
||||
map
|
||||
|
||||
@@ -1496,7 +1496,7 @@ processChatCommand cxt nm = \case
|
||||
accs <- case seed_ of
|
||||
Nothing -> pure []
|
||||
Just seed -> do
|
||||
-- a hidden profile is left out, as it is by /users
|
||||
-- hidden profiles are left out, as they are by /users
|
||||
as <- filter (\(_, _, active, hidden) -> active || not hidden) <$> withFastStore' (\db -> getSeedAccounts db (wsId seed))
|
||||
forM as $ \(n, acct, active, _) -> do
|
||||
keys <- forM [0 .. walletNamesShown - 1] $ \k -> do
|
||||
@@ -5462,12 +5462,12 @@ withExpirationDate globalTTL chatItemTTL action = do
|
||||
let ttl = fromMaybe globalTTL chatItemTTL
|
||||
when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs
|
||||
|
||||
-- | Name keys shown per profile by /wallet, to check derivation against other wallets.
|
||||
-- | Name keys shown per profile, to check derivation against other wallets.
|
||||
walletNamesShown :: NameIndex
|
||||
walletNamesShown = 2
|
||||
|
||||
noKeyError :: String
|
||||
noKeyError = "no wallet key for this profile - create one with /wallet create"
|
||||
noKeyError = "no wallet key for this profile"
|
||||
|
||||
chatCommandP :: Parser ChatCommand
|
||||
chatCommandP =
|
||||
@@ -5587,11 +5587,11 @@ chatCommandP =
|
||||
"/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)),
|
||||
"/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP),
|
||||
"/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP),
|
||||
"/wallet create" $> APIWalletCreate,
|
||||
"/wallet import " *> (APIWalletImport <$> textP),
|
||||
"/wallet export" $> APIWalletExport,
|
||||
"/wallet delete " *> (APIWalletDelete <$> textP),
|
||||
"/wallet" $> APIWallet,
|
||||
"/_wallet create" $> APIWalletCreate,
|
||||
"/_wallet import " *> (APIWalletImport <$> textP),
|
||||
"/_wallet export" $> APIWalletExport,
|
||||
"/_wallet delete " *> (APIWalletDelete <$> textP),
|
||||
"/_wallet" $> APIWallet,
|
||||
"/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP),
|
||||
"/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType),
|
||||
"/_call reject @" *> (APIRejectCall <$> A.decimal),
|
||||
@@ -5711,7 +5711,6 @@ chatCommandP =
|
||||
("/help remote" <|> "/hr") $> ChatHelp HSRemote,
|
||||
("/help settings" <|> "/hs") $> ChatHelp HSSettings,
|
||||
("/help db" <|> "/hd") $> ChatHelp HSDatabase,
|
||||
("/help wallet" <|> "/hw") $> ChatHelp HSWallet,
|
||||
("/help" <|> "/h") $> ChatHelp HSMain,
|
||||
("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile),
|
||||
"/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP),
|
||||
|
||||
@@ -12,9 +12,9 @@ m20260908_wallet_seeds =
|
||||
CREATE TABLE wallet_seeds (
|
||||
wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
seed BYTEA NOT NULL,
|
||||
-- Known issue: see the SQLite migration.
|
||||
-- see the SQLite migration
|
||||
next_account_index BIGINT NOT NULL DEFAULT 0,
|
||||
-- one key per device for now; drop when several are supported
|
||||
-- one key per device for now
|
||||
single_seed SMALLINT NOT NULL DEFAULT 1 UNIQUE
|
||||
);
|
||||
|
||||
|
||||
@@ -11,11 +11,10 @@ m20260908_wallet_seeds =
|
||||
CREATE TABLE wallet_seeds (
|
||||
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes
|
||||
-- Known issue: after importing a phrase this starts at 0, so a recovered
|
||||
-- device can re-issue an account that already owns names. A recovery scan
|
||||
-- will raise it.
|
||||
-- known issue: after import this starts at 0, so a recovered device can
|
||||
-- re-issue an account that already owns names
|
||||
next_account_index INTEGER NOT NULL DEFAULT 0,
|
||||
-- one key per device for now; drop when several are supported
|
||||
-- one key per device for now
|
||||
single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE
|
||||
) STRICT;
|
||||
|
||||
|
||||
@@ -857,11 +857,10 @@ CREATE TABLE rcv_roster_transfers(
|
||||
CREATE TABLE wallet_seeds(
|
||||
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes
|
||||
-- Known issue: after importing a phrase this starts at 0, so a recovered
|
||||
-- device can re-issue an account that already owns names. A recovery scan
|
||||
-- will raise it.
|
||||
-- known issue: after import this starts at 0, so a recovered device can
|
||||
-- re-issue an account that already owns names
|
||||
next_account_index INTEGER NOT NULL DEFAULT 0,
|
||||
-- one key per device for now; drop when several are supported
|
||||
-- one key per device for now
|
||||
single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE
|
||||
) STRICT;
|
||||
CREATE INDEX contact_profiles_index ON contact_profiles(
|
||||
|
||||
@@ -68,8 +68,6 @@ getBoundAccount db user =
|
||||
Nothing -> pure Nothing
|
||||
Just r -> fmap (\s -> (s, r)) <$> getWalletSeed db (arSeedId r)
|
||||
|
||||
-- | Profiles bound to this seed: display name, account index, whether active,
|
||||
-- whether hidden.
|
||||
getSeedAccounts :: DB.Connection -> SeedId -> IO [(Text, AccountIndex, Bool, Bool)]
|
||||
getSeedAccounts db (SeedId sId) =
|
||||
map toRow
|
||||
@@ -83,17 +81,13 @@ getSeedAccounts db (SeedId sId) =
|
||||
where
|
||||
toRow (n, ix, BI active, pwdHash) = (n, fromIntegral (ix :: Int64), active, isJust (pwdHash :: Maybe ByteString))
|
||||
|
||||
-- | Bind this profile to the device seed, creating it from @entropy@ if there
|
||||
-- is none.
|
||||
getOrCreateAccountRef :: DB.Connection -> User -> ByteString -> IO (WalletSeed, AccountRef)
|
||||
getOrCreateAccountRef db user entropy =
|
||||
getBoundAccount db user >>= \case
|
||||
Just bound -> pure bound
|
||||
Nothing -> getDeviceSeed db >>= maybe (createWalletSeed db entropy) pure >>= bindNewAccount db user
|
||||
|
||||
-- | Nothing if the device already has a key. One transaction, so a phrase
|
||||
-- cannot be discarded in favour of a key created meanwhile; single_seed is
|
||||
-- UNIQUE, so a concurrent insert cannot add a second key either.
|
||||
-- | Nothing if the device already has a key.
|
||||
importSeed :: DB.Connection -> User -> ByteString -> IO (Maybe (WalletSeed, AccountRef))
|
||||
importSeed db user entropy =
|
||||
getDeviceSeed db >>= \case
|
||||
@@ -113,8 +107,7 @@ createWalletSeed db entropy = do
|
||||
sId <- insertedRowId db
|
||||
pure WalletSeed {wsId = SeedId sId, wsEntropy = entropy}
|
||||
|
||||
-- | Incremented in SQL so that concurrent purchases cannot be handed the same
|
||||
-- account, and read back inside the same transaction.
|
||||
-- | Incremented in SQL, so two profiles cannot be handed the same account.
|
||||
takeAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex
|
||||
takeAccountIndex db sId@(SeedId sId') = do
|
||||
DB.execute db "UPDATE wallet_seeds SET next_account_index = next_account_index + 1 WHERE wallet_seed_id = ?" (Only sId')
|
||||
@@ -127,7 +120,7 @@ getNextAccountIndex db (SeedId sId) =
|
||||
DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId)
|
||||
)
|
||||
|
||||
-- | Profiles are unbound first: the foreign key is ON DELETE RESTRICT.
|
||||
-- | Profiles are unbound first, as the foreign key is ON DELETE RESTRICT.
|
||||
deleteSeed :: DB.Connection -> SeedId -> IO ()
|
||||
deleteSeed db (SeedId sId) = do
|
||||
DB.execute db "UPDATE users SET wallet_seed_id = NULL, wallet_account_index = NULL WHERE wallet_seed_id = ?" (Only sId)
|
||||
|
||||
@@ -181,7 +181,6 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
|
||||
HSRemote -> remoteHelpInfo
|
||||
HSSettings -> settingsInfo
|
||||
HSDatabase -> databaseHelpInfo
|
||||
HSWallet -> walletHelpInfo
|
||||
CRWelcome user -> chatWelcome user
|
||||
CRContactsList u cs -> ttyUser u $ viewContactsList cs
|
||||
CRUserContactLink u UserContactLink {connLinkContact, addressSettings} -> ttyUser u $ connReqContact_ showFullLinks "Your chat address:" connLinkContact <> viewAddressSettings addressSettings
|
||||
@@ -190,21 +189,17 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
|
||||
CRServiceResponse u resp -> ttyUser u ["service response: " <> viewJSON resp]
|
||||
CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)]
|
||||
CRWallet u exists accs
|
||||
| not exists -> ttyUser u ["no wallet key on this device - create one with " <> highlight' "/wallet create"]
|
||||
| not exists -> ttyUser u ["no wallet key"]
|
||||
| otherwise ->
|
||||
ttyUser u $
|
||||
("key 1" : concatMap accountRows accs)
|
||||
<> ["this profile has no key yet - add one with " <> highlight' "/wallet create" | not (any (\(_, _, active, _) -> active) accs)]
|
||||
concatMap accountRows accs
|
||||
<> ["this profile has no wallet key" | not (any (\(_, _, active, _) -> active) accs)]
|
||||
where
|
||||
accountRows (n, acct, active, keys) =
|
||||
plain (" account " <> tshow acct <> " (" <> n <> (if active then ", active" else "") <> ")")
|
||||
plain ("account " <> tshow acct <> " (" <> n <> (if active then ", active" else "") <> ")")
|
||||
: zipWith nameRow [0 :: Int ..] keys
|
||||
nameRow k (path, addr) = plain $ " name " <> tshow k <> " " <> path <> " " <> addr
|
||||
CRWalletPhrase u phrase ->
|
||||
ttyUser u
|
||||
[ "write this down - anyone who knows these words controls the names this key owns:",
|
||||
plain $ " " <> phrase
|
||||
]
|
||||
nameRow k (path, addr) = plain $ " name " <> tshow k <> " " <> path <> " " <> addr
|
||||
CRWalletPhrase u phrase -> ttyUser u [plain phrase]
|
||||
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
|
||||
|
||||
@@ -1,39 +1,20 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
-- | The wallet: BIP-39 seeds, and the keys derived from them.
|
||||
-- | BIP-39 seeds and the keys derived from them.
|
||||
--
|
||||
-- * __seed__: BIP-39 entropy. Generic, /not/ name-specific.
|
||||
-- * __account__: a profile's slot in a seed, BIP-44 account index @i@.
|
||||
-- * __name key__: @m\/44'\/60'\/i'\/0\/k@: one key per name, at BIP-44
|
||||
-- address index @k@ under the profile that buys it. This is what the
|
||||
-- registry records as the name's owner.
|
||||
-- * __wallet__: this module, creation and derivation.
|
||||
--
|
||||
-- One key per name, not one per profile. A per-profile key would mean exporting
|
||||
-- it hands over every name that profile owns, and would put every name's signed
|
||||
-- record edits behind one shared nonce counter on the resolver. Both are avoided
|
||||
-- by giving each name its own address index. @k = 0@ is the profile's first
|
||||
-- name.
|
||||
--
|
||||
-- The schema allows several seeds; a profile binds to exactly one plus its own
|
||||
-- account index. Only one seed per device is reachable today.
|
||||
--
|
||||
-- This module is pure. Persistence lives in "Simplex.Chat.Store.Wallets".
|
||||
--
|
||||
-- There is no signing here yet, so nothing can be bought or edited: this is the
|
||||
-- key material and the derivation only.
|
||||
-- One key per name, not one per profile, so that exporting a name's key hands
|
||||
-- over that name only.
|
||||
module Simplex.Chat.Wallet
|
||||
( SeedId (..),
|
||||
WalletSeed (..),
|
||||
AccountIndex,
|
||||
NameIndex,
|
||||
AccountRef (..),
|
||||
WalletAccount (..),
|
||||
WalletAccount,
|
||||
newSeed,
|
||||
importRecoveryKey,
|
||||
recoveryKeyPhrase,
|
||||
deriveNameKey,
|
||||
nameKeyPath,
|
||||
renderNameKeyPath,
|
||||
accountAddress,
|
||||
)
|
||||
@@ -54,16 +35,12 @@ import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey)
|
||||
newtype SeedId = SeedId Int64
|
||||
deriving (Eq, Ord, Show)
|
||||
|
||||
-- | BIP-44 account index within a seed. One per chat profile.
|
||||
-- | BIP-44 account index, one per chat profile.
|
||||
type AccountIndex = Word32
|
||||
|
||||
-- | BIP-44 address index within a profile account. One per name.
|
||||
-- | BIP-44 address index, one per name.
|
||||
type NameIndex = Word32
|
||||
|
||||
-- | A seed, held as BIP-39 entropy. Stored in the chat database so it rides the
|
||||
-- existing archive export and Migrate-to-another-device flows.
|
||||
--
|
||||
-- 'Show' is redacting: this is the root secret behind every name it owns.
|
||||
data WalletSeed = WalletSeed
|
||||
{ wsId :: SeedId,
|
||||
wsEntropy :: ByteString
|
||||
@@ -73,14 +50,12 @@ data WalletSeed = WalletSeed
|
||||
instance Show WalletSeed where
|
||||
show s = "WalletSeed " <> show (wsId s) <> " <redacted>"
|
||||
|
||||
-- | What a chat profile stores: which seed, and which account index within it.
|
||||
data AccountRef = AccountRef
|
||||
{ arSeedId :: SeedId,
|
||||
arIndex :: AccountIndex
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | A derived account: the reference plus the key it resolves to.
|
||||
data WalletAccount = WalletAccount
|
||||
{ waRef :: AccountRef,
|
||||
waKey :: S.PrivateKey
|
||||
@@ -90,35 +65,23 @@ data WalletAccount = WalletAccount
|
||||
instance Show WalletAccount where
|
||||
show a = "WalletAccount " <> show (waRef a) <> " <redacted>"
|
||||
|
||||
-- | Fresh seed entropy. The caller stores it; this module never persists.
|
||||
-- A 25th-word passphrase is deliberately not used: it would be a second secret
|
||||
-- to back up.
|
||||
-- | No 25th-word passphrase: it would be a second secret to back up.
|
||||
newSeed :: B39.MnemonicStrength -> TVar ChaChaDRG -> STM ByteString
|
||||
newSeed strength g = B39.mnemonicToEntropy <$> B39.randomMnemonic strength g
|
||||
|
||||
-- | Import from a recovery phrase, validating the wordlist and the BIP-39
|
||||
-- checksum. Returns the entropy; the caller persists it.
|
||||
importRecoveryKey :: ByteString -> Either String ByteString
|
||||
importRecoveryKey phrase = B39.mnemonicToEntropy <$> B39.parseMnemonic phrase
|
||||
|
||||
-- | The phrase to show under "recovery key". Anyone who knows these words
|
||||
-- controls every name this seed owns, so the risk to state is theft, not loss.
|
||||
recoveryKeyPhrase :: WalletSeed -> Either String ByteString
|
||||
recoveryKeyPhrase s = B39.mnemonicPhrase <$> B39.entropyToMnemonic (wsEntropy s)
|
||||
|
||||
-- | @m\/44'\/60'\/i'\/0\/k@ is the standard BIP-44 layout, with the profile at
|
||||
-- the account level and the name at the address level. Nothing here is a custom
|
||||
-- path, so profile @i@'s names are the account list an ordinary Ethereum wallet
|
||||
-- would show for that account.
|
||||
-- | Standard BIP-44, so the phrase reaches the same addresses in other wallets.
|
||||
nameKeyPath :: AccountIndex -> NameIndex -> [Word32]
|
||||
nameKeyPath acc nm = [B32.hardened 44, B32.hardened 60, B32.hardened acc, 0, nm]
|
||||
|
||||
-- | The path a name's key was derived at, for display. Users need it only to
|
||||
-- import a single name into a third-party wallet.
|
||||
renderNameKeyPath :: AccountIndex -> NameIndex -> Text
|
||||
renderNameKeyPath acc nm = decodeLatin1 . B32.renderPath $ nameKeyPath acc nm
|
||||
|
||||
-- | Derive the key that owns one name.
|
||||
deriveNameKey :: WalletSeed -> AccountIndex -> NameIndex -> Either String WalletAccount
|
||||
deriveNameKey s acc nm = do
|
||||
m <- B39.entropyToMnemonic (wsEntropy s)
|
||||
@@ -126,6 +89,5 @@ deriveNameKey s acc nm = do
|
||||
xk <- B32.derivePath master (nameKeyPath acc nm)
|
||||
pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = acc}, waKey = B32.xkKey xk}
|
||||
|
||||
-- | The Ethereum address that owns the name this key was derived for.
|
||||
accountAddress :: WalletAccount -> Address
|
||||
accountAddress = addressFromPrivateKey . waKey
|
||||
|
||||
+34
-56
@@ -14,22 +14,17 @@ import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, derive
|
||||
import Test.Hspec hiding (it)
|
||||
import qualified Test.Hspec as Hspec
|
||||
|
||||
-- | The BIP-39 test vector every wallet ships with, so the addresses below can
|
||||
-- be checked against MetaMask and Ledger Live.
|
||||
-- | 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}
|
||||
|
||||
-- | Derivation is the part other wallets have to agree with, so it is pinned
|
||||
-- here rather than left to the end-to-end tests.
|
||||
walletDerivationTests :: Spec
|
||||
walletDerivationTests = do
|
||||
-- Name keys are plain BIP-44: profile 0's names are exactly MetaMask's
|
||||
-- account list (m/44'/60'/0'/0/k), and each profile's first name is the
|
||||
-- matching Ledger Live account (m/44'/60'/i'/0/0). That is what lets an owner
|
||||
-- move a single name into another wallet.
|
||||
-- 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
|
||||
@@ -42,22 +37,20 @@ walletDerivationTests = do
|
||||
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
|
||||
-- Without the checksum a mistyped word imports a key that owns nothing.
|
||||
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 shows the derived addresses" testWalletCreate
|
||||
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
|
||||
|
||||
-- | The derivation path and address of each name shown for a profile's account.
|
||||
accountRows :: HasCallStack => TestCC -> String -> Int -> IO [(String, String)]
|
||||
accountRows cc profile acct = do
|
||||
cc <## (" account " <> show acct <> " (" <> profile <> ")")
|
||||
cc <## ("account " <> show acct <> " (" <> profile <> ")")
|
||||
mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int]
|
||||
where
|
||||
nameRow l = case words l of
|
||||
@@ -66,81 +59,66 @@ accountRows cc profile acct = do
|
||||
|
||||
testWalletCreate :: HasCallStack => TestParams -> IO ()
|
||||
testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
||||
alice ##> "/wallet"
|
||||
alice <## "no wallet key on this device - create one with /wallet create"
|
||||
-- asked again: still nothing, and nothing was created by asking
|
||||
alice ##> "/wallet"
|
||||
alice <## "no wallet key on this device - create one with /wallet create"
|
||||
alice ##> "/wallet export"
|
||||
alice <## "bad chat command: no wallet key for this profile - create one with /wallet create"
|
||||
alice ##> "/wallet create"
|
||||
alice <## "key 1"
|
||||
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 for this profile"
|
||||
alice ##> "/_wallet create"
|
||||
rows <- accountRows alice "alice, active" 0
|
||||
-- one key per name: the two addresses differ and sit at consecutive indices
|
||||
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"
|
||||
alice <## "key 1"
|
||||
alice ##> "/_wallet create"
|
||||
accountRows alice "alice, active" 0
|
||||
-- same database, new session: the seed has to come back from the DB, or the
|
||||
-- name bought at that address is unreachable
|
||||
-- same database, new session: a name bought at that address must stay reachable
|
||||
withTestChat ps "alice" $ \alice -> do
|
||||
alice ##> "/wallet"
|
||||
alice <## "key 1"
|
||||
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"
|
||||
alice <## "key 1"
|
||||
alice ##> "/_wallet create"
|
||||
rows <- accountRows alice "alice, active" 0
|
||||
alice ##> "/create user alisa"
|
||||
showActiveUser alice "alisa"
|
||||
alice ##> "/wallet"
|
||||
alice <## "key 1"
|
||||
alice ##> "/_wallet"
|
||||
_ <- accountRows alice "alice" 0
|
||||
alice <## "this profile has no key yet - add one with /wallet create"
|
||||
-- the same key, a different account, so the two profiles do not share names
|
||||
alice ##> "/wallet create"
|
||||
alice <## "key 1"
|
||||
alice <## "this profile has no wallet key"
|
||||
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 <## "key 1"
|
||||
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 <## "write this down - anyone who knows these words controls the names this key owns:"
|
||||
alice <## (" " <> B.unpack testPhrase)
|
||||
-- one key per device: a second would make the addresses shown depend on which
|
||||
-- key was picked
|
||||
alice ##> ("/wallet import " <> B.unpack testPhrase)
|
||||
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 ##> ("/_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)
|
||||
alice <## "key 1"
|
||||
alice ##> ("/_wallet import " <> B.unpack testPhrase)
|
||||
_ <- accountRows alice "alice, active" 0
|
||||
alice ##> "/wallet delete abandon"
|
||||
alice ##> "/_wallet delete abandon"
|
||||
alice <## "bad chat command: to confirm, pass the last word of the recovery phrase"
|
||||
alice ##> "/wallet delete about"
|
||||
alice <## "no wallet key on this device - create one with /wallet create"
|
||||
-- deleting unbinds the profile, so a real phrase can now be imported
|
||||
alice ##> ("/wallet import " <> B.unpack testPhrase)
|
||||
alice <## "key 1"
|
||||
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 ()
|
||||
|
||||
Reference in New Issue
Block a user