mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 15:38:56 +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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user