core: wallet review fixes, and show the derived addresses

Concurrency: the account index is incremented in SQL and read back in the
same transaction, so two profiles cannot be handed the same key. Importing
a phrase is one transaction and single_seed is UNIQUE, so a phrase cannot
be discarded in favour of a key created meanwhile, and a device cannot end
up with two keys.

Wallet commands are no longer forwarded to a remote host: the recovery
phrase must not leave the device, and the raw command is logged there.

/wallet delete removes the key, confirmed by the last word of the phrase,
so creating a key before importing your own is no longer a dead end.

/wallet now shows every profile on the key with the first two name
addresses each, to check derivation against other wallets. Hidden profiles
are left out, as they are by /users. A bad phrase no longer says which word
was wrong. /wallet export uses the profile's own key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
This commit is contained in:
Alain BrenzikoferandClaude Opus 5 committed 2026-09-09 09:05:47 +00:00
1 parent 257cdc02c8
commit ee11b368b6
13 files changed
+232 -152

No files matched your search

+7 -1
View File
@@ -421,6 +421,7 @@ data ChatCommand
| APIWalletCreate
| APIWalletImport {recoveryPhrase :: Text}
| APIWalletExport
| APIWalletDelete {confirmWord :: Text}
| APISendCallInvitation ContactId CallType
| SendCallInvitation ContactName CallType
| APIRejectCall ContactId
@@ -746,6 +747,11 @@ allowRemoteCommand = \case
DeleteRemoteCtrl _ -> False
ExecChatStoreSQL _ -> False
ExecAgentStoreSQL _ -> False
APIWallet -> False
APIWalletCreate -> False
APIWalletImport _ -> False
APIWalletExport -> False
APIWalletDelete _ -> False
_ -> True
data RelayConnectionResult = RelayConnectionResult
@@ -847,7 +853,7 @@ data ChatResponse
| CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact}
| CRServiceResponse {user :: User, responseData :: J.Object}
| CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId}
| CRWallet {user :: User, walletKeyExists :: Bool, walletAccount :: Maybe (AccountIndex, Text, Text)}
| CRWallet {user :: User, walletKeyExists :: Bool, walletAccounts :: [(Text, AccountIndex, Bool, [(Text, Text)])]}
| CRWalletPhrase {user :: User, recoveryPhrase :: Text}
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
+15 -11
View File
@@ -223,20 +223,24 @@ walletHelpInfo :: [StyledString]
walletHelpInfo =
map
styleMarkdown
[ green "Your wallet key:",
indent <> highlight "/wallet " <> " - the address that would own the next name you buy",
indent <> highlight "/wallet create " <> " - create the key, and this profile's account under it",
indent <> highlight "/wallet import " <> " <phrase> - use a key you already have",
indent <> highlight "/wallet export " <> " - the recovery phrase, to write down",
[ 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",
"",
"One key per device, and one account per chat profile under it. A name gets",
"its own key at " <> highlight "m/44'/60'/<profile>'/0/<name>" <> ". That is ordinary BIP-44,",
"so importing the phrase into another wallet reaches the same addresses.",
"Please note: this is in development. You cannot buy a name yet.",
"",
"Anyone who knows a recovery phrase controls the names it owns. The risk is",
"theft, not loss.",
"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.",
"",
"Please note: this is in development. Nothing can be bought or signed yet."
"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]
+36 -21
View File
@@ -58,8 +58,8 @@ import qualified Data.UUID.V4 as V4
import Simplex.Chat.Library.Subscriber
import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim)
import Simplex.Chat.Store.Wallets (boundAccount, deviceSeed, getOrCreateAccountRef)
import Simplex.Chat.Wallet (AccountRef (..), accountAddress, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath)
import Simplex.Chat.Store.Wallets (deleteSeed, getBoundAccount, getDeviceSeed, getOrCreateAccountRef, getSeedAccounts, importSeed)
import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..))
@@ -1491,34 +1491,41 @@ processChatCommand cxt nm = \case
let AgentInvId invId = requestId
connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData)
pure $ CRServiceReplyAccepted user (AgentConnId connId)
-- Read-only: a profile is never given keys as a side effect of asking which
-- address it has.
APIWallet -> withUser $ \user -> do
exists <- isJust <$> withFastStore' deviceSeed
acc_ <- withFastStore' $ \db -> boundAccount db user
-- name index 0: with no purchases yet the next name is always the first
a <- forM acc_ $ \(seed, AccountRef {arIndex}) -> do
acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed arIndex 0
pure (arIndex, renderNameKeyPath arIndex 0, tshow (accountAddress acc))
pure $ CRWallet user exists a
-- Creates the device key on first use, and this profile's account under it.
-- A second profile lands on its own account index rather than sharing one.
seed_ <- withFastStore' getDeviceSeed
accs <- case seed_ of
Nothing -> pure []
Just seed -> do
-- a hidden profile is left out, as it is 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
acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed acct k
pure (renderNameKeyPath acct k, tshow (accountAddress acc))
pure (n, acct, active, keys)
pure $ CRWallet user (isJust seed_) accs
APIWalletCreate -> withUser $ \user -> do
g <- asks random
void $ withFastStore' $ \db -> getOrCreateAccountRef db user (atomically $ newSeed MS256 g)
entropy <- atomically $ newSeed MS256 g
void $ withFastStore' $ \db -> getOrCreateAccountRef db user entropy
processChatCommand cxt nm APIWallet
-- One key per device: importing onto a device that already has one would make
-- the addresses it shows depend on which key was picked.
APIWalletImport phrase -> withUser $ \user -> do
exists <- isJust <$> withFastStore' deviceSeed
when exists $ throwCmdError "this device already has a wallet key"
entropy <- either (throwCmdError . ("wallet: " <>)) pure $ importRecoveryKey (encodeUtf8 phrase)
void $ withFastStore' $ \db -> getOrCreateAccountRef db user (pure entropy)
entropy <- either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase)
r <- withFastStore' $ \db -> importSeed db user entropy
when (isNothing r) $ throwCmdError "this device already has a wallet key"
processChatCommand cxt nm APIWallet
APIWalletExport -> withUser $ \user -> do
seed <- withFastStore' deviceSeed >>= maybe (throwCmdError "no wallet key on this device") pure
(seed, _) <- withFastStore' (\db -> getBoundAccount db user) >>= maybe (throwCmdError noKeyError) pure
phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed
pure $ CRWalletPhrase user (safeDecodeUtf8 phrase)
APIWalletDelete confirmWord -> withUser $ \user -> do
seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure
phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed
case reverse . T.words $ safeDecodeUtf8 phrase of
w : _ | w == confirmWord -> do
withFastStore' $ \db -> deleteSeed db (wsId seed)
processChatCommand cxt nm APIWallet
_ -> throwCmdError "to confirm, pass the last word of the recovery phrase"
APISendCallInvitation contactId callType -> withUser $ \user -> do
-- party initiating call
ct <- withFastStore $ \db -> getContact db cxt user contactId
@@ -5455,6 +5462,13 @@ 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.
walletNamesShown :: NameIndex
walletNamesShown = 2
noKeyError :: String
noKeyError = "no wallet key for this profile - create one with /wallet create"
chatCommandP :: Parser ChatCommand
chatCommandP =
choice
@@ -5576,6 +5590,7 @@ chatCommandP =
"/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),
@@ -12,9 +12,10 @@ m20260908_wallet_seeds =
CREATE TABLE wallet_seeds (
wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
seed BYTEA NOT NULL,
-- High-water mark for account allocation; see the SQLite migration for why
-- this cannot be derived from MAX(users.wallet_account_index).
next_account_index BIGINT NOT NULL DEFAULT 0
-- Known issue: see the SQLite migration.
next_account_index BIGINT NOT NULL DEFAULT 0,
-- one key per device for now; drop when several are supported
single_seed SMALLINT NOT NULL DEFAULT 1 UNIQUE
);
ALTER TABLE users ADD COLUMN wallet_seed_id BIGINT REFERENCES wallet_seeds ON DELETE RESTRICT;
@@ -1538,7 +1538,8 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS
CREATE TABLE test_chat_schema.wallet_seeds (
wallet_seed_id bigint NOT NULL,
seed bytea NOT NULL,
next_account_index bigint DEFAULT 0 NOT NULL
next_account_index bigint DEFAULT 0 NOT NULL,
single_seed smallint DEFAULT 1 NOT NULL
);
@@ -1917,6 +1918,11 @@ ALTER TABLE ONLY test_chat_schema.wallet_seeds
ALTER TABLE ONLY test_chat_schema.wallet_seeds
ADD CONSTRAINT wallet_seeds_single_seed_key UNIQUE (single_seed);
ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions
ADD CONSTRAINT xftp_file_descriptions_pkey PRIMARY KEY (file_descr_id);
@@ -5,28 +5,23 @@ module Simplex.Chat.Store.SQLite.Migrations.M20260908_wallet_seeds where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
-- | Wallet seeds: BIP-39 entropy, one or more per database.
--
-- The schema allows several seeds, with each chat profile bound to exactly one
-- of them plus its own BIP-44 account index. Only the single-seed case is
-- reachable today.
m20260908_wallet_seeds :: Query
m20260908_wallet_seeds =
[sql|
CREATE TABLE wallet_seeds (
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes
-- High-water mark for account allocation. Deliberately not derived from
-- MAX(users.wallet_account_index): after recovery from the phrase alone that
-- table is empty while accounts 0..N already hold names on chain, so a new
-- profile would silently reuse a recovered account's keys.
next_account_index INTEGER NOT NULL DEFAULT 0
-- 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.
next_account_index INTEGER NOT NULL DEFAULT 0,
-- one key per device for now; drop when several are supported
single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE
) STRICT;
ALTER TABLE users ADD COLUMN wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT;
ALTER TABLE users ADD COLUMN wallet_account_index INTEGER;
-- required: the .lint check enforces an index on every foreign key
CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id);
|]
@@ -857,11 +857,12 @@ 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
-- High-water mark for account allocation. Deliberately not derived from
-- MAX(users.wallet_account_index): after recovery from the phrase alone that
-- table is empty while accounts 0..N already hold names on chain, so a new
-- profile would silently reuse a recovered account's keys.
next_account_index INTEGER NOT NULL DEFAULT 0
-- 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.
next_account_index INTEGER NOT NULL DEFAULT 0,
-- one key per device for now; drop when several are supported
single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE
) STRICT;
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
+69 -44
View File
@@ -2,39 +2,42 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
-- | Persistence for wallet seeds and per-profile accounts.
--
-- The schema holds several seeds and binds each chat profile to one of them
-- plus its own account index. One seed per device is reachable today, so
-- 'deviceSeed' is the seed, and 'getOrCreateAccountRef' creates it on first use.
module Simplex.Chat.Store.Wallets
( deviceSeed,
boundAccount,
( getDeviceSeed,
getBoundAccount,
getSeedAccounts,
getOrCreateAccountRef,
importSeed,
deleteSeed,
)
where
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Maybe (isJust)
import Data.Text (Text)
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Chat.Types (User (..))
import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), SeedId (..), WalletSeed (..))
import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow)
import Simplex.Messaging.Agent.Store.DB (BoolInt (..))
import qualified Simplex.Messaging.Agent.Store.DB as DB
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..))
import Database.PostgreSQL.Simple.SqlQQ (sql)
#else
import Database.SQLite.Simple (Only (..))
import Database.SQLite.Simple.QQ (sql)
#endif
toSeed :: (Int64, ByteString) -> WalletSeed
toSeed (sId, seed) = WalletSeed {wsId = SeedId sId, wsEntropy = seed}
-- | The seed on this device, or Nothing if the wallet has never been used.
deviceSeed :: DB.Connection -> IO (Maybe WalletSeed)
deviceSeed db =
getDeviceSeed :: DB.Connection -> IO (Maybe WalletSeed)
getDeviceSeed db =
maybeFirstRow toSeed $
DB.query_ db "SELECT wallet_seed_id, seed FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1"
@@ -43,14 +46,6 @@ getWalletSeed db (SeedId sId) =
maybeFirstRow toSeed $
DB.query db "SELECT wallet_seed_id, seed FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId)
-- | Insert a seed. Callers generate the entropy; this module never does, so the
-- DRG stays with the agent.
createWalletSeed :: DB.Connection -> ByteString -> IO WalletSeed
createWalletSeed db seed = do
DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only seed)
sId <- insertedRowId db
pure WalletSeed {wsId = SeedId sId, wsEntropy = seed}
getAccountRef :: DB.Connection -> User -> IO (Maybe AccountRef)
getAccountRef db User {userId} = do
r <-
@@ -67,39 +62,63 @@ bindAccount db User {userId} AccountRef {arSeedId = SeedId sId, arIndex} =
"UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?"
(sId, fromIntegral arIndex :: Int64, userId)
-- | The seed and account this profile is bound to, or Nothing if it has never
-- used the wallet. Creates nothing: a profile is never given keys as a side
-- effect of reading.
boundAccount :: DB.Connection -> User -> IO (Maybe (WalletSeed, AccountRef))
boundAccount db user =
getBoundAccount :: DB.Connection -> User -> IO (Maybe (WalletSeed, AccountRef))
getBoundAccount db user =
getAccountRef db user >>= \case
Nothing -> pure Nothing
Just r -> fmap (\s -> (s, r)) <$> getWalletSeed db (arSeedId r)
-- | Bind this profile to the device's seed, creating that seed from @mkSeed@ if
-- there is none yet. Every profile gets its own account index within it.
getOrCreateAccountRef :: DB.Connection -> User -> IO ByteString -> IO (WalletSeed, AccountRef)
getOrCreateAccountRef db user mkSeed =
boundAccount db user >>= \case
Just bound -> pure bound
Nothing -> do
s <- deviceSeed db >>= maybe (mkSeed >>= createWalletSeed db) pure
ix <- takeAccountIndex db (wsId s)
let r = AccountRef {arSeedId = wsId s, arIndex = ix}
bindAccount db user r
pure (s, 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
<$> DB.query
db
[sql|
SELECT local_display_name, wallet_account_index, active_user, view_pwd_hash
FROM users WHERE wallet_seed_id = ? ORDER BY wallet_account_index
|]
(Only sId)
where
toRow (n, ix, BI active, pwdHash) = (n, fromIntegral (ix :: Int64), active, isJust (pwdHash :: Maybe ByteString))
-- | Take the next account index and advance the seed's high-water mark.
--
-- The mark is stored rather than computed as @MAX(users.wallet_account_index)@,
-- because after recovery from the phrase alone the @users@ table is empty while
-- accounts @0..N@ already hold names on chain. Computing it would hand the first
-- newly created profile index 0 and, with it, a recovered account's keys.
-- | 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.
importSeed :: DB.Connection -> User -> ByteString -> IO (Maybe (WalletSeed, AccountRef))
importSeed db user entropy =
getDeviceSeed db >>= \case
Just _ -> pure Nothing
Nothing -> Just <$> (createWalletSeed db entropy >>= bindNewAccount db user)
bindNewAccount :: DB.Connection -> User -> WalletSeed -> IO (WalletSeed, AccountRef)
bindNewAccount db user s = do
ix <- takeAccountIndex db (wsId s)
let r = AccountRef {arSeedId = wsId s, arIndex = ix}
bindAccount db user r
pure (s, r)
createWalletSeed :: DB.Connection -> ByteString -> IO WalletSeed
createWalletSeed db entropy = do
DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only entropy)
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.
takeAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex
takeAccountIndex db sId@(SeedId sId') = do
ix <- getNextAccountIndex db sId
DB.execute db "UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ?" (fromIntegral ix + 1 :: Int64, sId')
pure ix
DB.execute db "UPDATE wallet_seeds SET next_account_index = next_account_index + 1 WHERE wallet_seed_id = ?" (Only sId')
subtract 1 <$> getNextAccountIndex db sId
getNextAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex
getNextAccountIndex db (SeedId sId) =
@@ -107,3 +126,9 @@ getNextAccountIndex db (SeedId sId) =
<$> ( maybeFirstRow fromOnly $
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.
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)
DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId)
+11 -10
View File
@@ -189,16 +189,17 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"]
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 acc_ ->
ttyUser u $ case acc_ of
Just (acct, path, addr) ->
[ plain $ "wallet account " <> tshow acct,
plain $ "next name will be owned by " <> addr,
plain $ " at " <> path
]
Nothing
| exists -> ["wallet key on this device, but this profile has no account - add one with " <> highlight' "/wallet create"]
| otherwise -> ["no wallet key on this device - create one with " <> highlight' "/wallet create"]
CRWallet u exists accs
| not exists -> ttyUser u ["no wallet key on this device - create one with " <> highlight' "/wallet create"]
| 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)]
where
accountRows (n, acct, active, keys) =
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:",
+6 -6
View File
@@ -2,12 +2,12 @@
-- | The wallet: 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
-- * __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.
-- * __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
@@ -91,7 +91,7 @@ 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
-- A 25th-word passphrase is deliberately not used: 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
@@ -106,7 +106,7 @@ importRecoveryKey phrase = B39.mnemonicToEntropy <$> B39.parseMnemonic phrase
recoveryKeyPhrase :: WalletSeed -> Either String ByteString
recoveryKeyPhrase s = B39.mnemonicPhrase <$> B39.entropyToMnemonic (wsEntropy s)
-- | @m\/44'\/60'\/i'\/0\/k@ — the standard BIP-44 layout, with the profile at
-- | @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.