core: create makes the seed, bind claims a path

The two were one command: on a device that already had a seed, /_wallet
create only bound a profile. Now create makes the seed and refuses when one
exists, and binds every profile that exists, as a new seed has no account
that already owns a name.

/_wallet import binds nothing. Which account a profile had is what an
import is recovering, and the seed does not say, so the profile says it
with /_wallet bind <account>. /_wallet bind with no account takes the next
free one.

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 Brenzikofer
2026-09-10 12:51:09 +00:00
co-authored by Claude Opus 5
parent 07acdbb85b
commit 9cba89aa17
4 changed files with 80 additions and 56 deletions
+1 -1
View File
@@ -418,7 +418,7 @@ data ChatCommand
| APISendServiceRequest {userId :: UserId, sendTarget :: ConnectTarget 'CMContact, requestTimeout :: Maybe NominalDiffTime, signKey :: Maybe (C.StoredPrivateKey 'C.Ed25519), request :: J.Object}
| APISendServiceResponse {userId :: UserId, requestId :: AgentInvId, responseData :: J.Object}
| APIWallet
| APIWalletBind {accountIndex :: AccountIndex}
| APIWalletBind {boundAccountIndex :: Maybe AccountIndex}
| APIWalletCreate
| APIWalletImport {recoveryPhrase :: Text}
| APIWalletExportSeedMnemonic
+10 -8
View File
@@ -58,7 +58,7 @@ 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 (bindAccountIndex, deleteSeed, getAccountIndex, getDeviceSeed, getOrCreateAccountRef, getSeedProfiles, importSeed)
import Simplex.Chat.Store.Wallets (bindAccountIndex, createSeed, deleteSeed, getAccountIndex, getDeviceSeed, getSeedProfiles, importSeed)
import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, accountSecret, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath)
import Simplex.Chat.Call
import Simplex.Chat.Controller
@@ -1502,19 +1502,20 @@ processChatCommand cxt nm = \case
pure (renderNameKeyPath acct k, tshow $ accountAddress acc)
profiles <- withFastStore' $ \db -> getSeedProfiles db (wsId seed) user
pure $ CRWallet user True paths profiles
APIWalletBind acct -> withUser $ \user -> do
APIWalletBind acct_ -> withUser $ \user -> do
seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure
bound <- withFastStore' $ \db -> bindAccountIndex db user (wsId seed) acct
bound <- withFastStore' $ \db -> bindAccountIndex db user (wsId seed) acct_
unless bound $ throwCmdError "another profile uses this account"
processChatCommand cxt nm APIWallet
APIWalletCreate -> withUser $ \user -> do
APIWalletCreate -> withUser $ \_ -> do
g <- asks random
entropy <- atomically $ newSeed MS256 g
withFastStore' $ \db -> getOrCreateAccountRef db user entropy
created <- withFastStore' $ \db -> createSeed db entropy
unless created $ throwCmdError "this device already has a wallet key"
processChatCommand cxt nm APIWallet
APIWalletImport phrase -> withUser $ \user -> do
APIWalletImport phrase -> withUser $ \_ -> do
entropy <- either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase)
imported <- withFastStore' $ \db -> importSeed db user entropy
imported <- withFastStore' $ \db -> importSeed db entropy
unless imported $ throwCmdError "this device already has a wallet key"
processChatCommand cxt nm APIWallet
APIWalletExportSeedMnemonic -> withUser $ \user -> do
@@ -5590,7 +5591,8 @@ 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 bind " *> (APIWalletBind <$> keyIndexP),
"/_wallet bind " *> (APIWalletBind . Just <$> keyIndexP),
"/_wallet bind" $> APIWalletBind Nothing,
"/_wallet create" $> APIWalletCreate,
"/_wallet import " *> (APIWalletImport <$> textP),
"/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP <* A.space <*> keyIndexP),
+51 -41
View File
@@ -8,13 +8,14 @@ module Simplex.Chat.Store.Wallets
( getDeviceSeed,
getAccountIndex,
getSeedProfiles,
getOrCreateAccountRef,
createSeed,
importSeed,
bindAccountIndex,
deleteSeed,
)
where
import Control.Monad (forM_)
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Text (Text)
@@ -62,47 +63,53 @@ getSeedProfiles db (SeedId sId) User {userId} =
|]
(sId, userId)
bindAccount :: DB.Connection -> User -> SeedId -> AccountIndex -> IO ()
bindAccount db User {userId} (SeedId sId) acct =
bindUser :: DB.Connection -> Int64 -> SeedId -> Int64 -> IO ()
bindUser db uId (SeedId sId) acct =
DB.execute
db
"UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?"
(sId, fromIntegral acct :: Int64, userId)
(sId, acct, uId)
getOrCreateAccountRef :: DB.Connection -> User -> ByteString -> IO ()
getOrCreateAccountRef db user entropy =
getAccountIndex db user >>= \case
Just _ -> pure ()
Nothing -> getDeviceSeed db >>= maybe (createWalletSeed db entropy) pure >>= bindNewAccount db user
-- | False if the device already has a key.
importSeed :: DB.Connection -> User -> ByteString -> IO Bool
importSeed db user entropy =
-- | False if the device already has a key. Every profile is bound, as a new
-- seed has no account that already owns a name.
createSeed :: DB.Connection -> ByteString -> IO Bool
createSeed db entropy =
getDeviceSeed db >>= \case
Just _ -> pure False
Nothing -> True <$ (createWalletSeed db entropy >>= bindNewAccount db user)
-- | False if another profile holds the account. The counter moves past it, so
-- the next profile is not handed the same one.
bindAccountIndex :: DB.Connection -> User -> SeedId -> AccountIndex -> IO Bool
bindAccountIndex db user sId@(SeedId sId') acct = do
taken <- maybeFirstRow fromOnly $
DB.query
db
"SELECT 1 FROM users WHERE wallet_seed_id = ? AND wallet_account_index = ? AND user_id != ?"
(sId', fromIntegral acct :: Int64, userId user)
case (taken :: Maybe Int64) of
Just _ -> pure False
Nothing -> do
bindAccount db user sId acct
DB.execute
db
"UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index <= ?"
(fromIntegral acct + 1 :: Int64, sId', fromIntegral acct :: Int64)
s <- createWalletSeed db entropy
uIds <- map fromOnly <$> DB.query_ db "SELECT user_id FROM users ORDER BY user_id"
forM_ (zip uIds [0 ..]) $ \(uId, acct) -> bindUser db uId (wsId s) acct
setNextAccountIndex db (wsId s) (fromIntegral $ length uIds)
pure True
bindNewAccount :: DB.Connection -> User -> WalletSeed -> IO ()
bindNewAccount db user s = takeAccountIndex db (wsId s) >>= bindAccount db user (wsId s)
-- | False if the device already has a key. No profile is bound: which account
-- a profile had is what the import is recovering, and the seed does not say.
importSeed :: DB.Connection -> ByteString -> IO Bool
importSeed db entropy =
getDeviceSeed db >>= \case
Just _ -> pure False
Nothing -> True <$ createWalletSeed db entropy
-- | Without an account the next free one is taken. False if another profile
-- holds the account asked for.
bindAccountIndex :: DB.Connection -> User -> SeedId -> Maybe AccountIndex -> IO Bool
bindAccountIndex db User {userId} sId@(SeedId sId') = \case
Nothing -> True <$ (takeAccountIndex db sId >>= bindUser db userId sId)
Just acct -> do
taken <-
maybeFirstRow fromOnly $
DB.query
db
"SELECT 1 FROM users WHERE wallet_seed_id = ? AND wallet_account_index = ? AND user_id != ?"
(sId', fromIntegral acct :: Int64, userId)
case (taken :: Maybe Int64) of
Just _ -> pure False
Nothing -> do
bindUser db userId sId (fromIntegral acct)
-- the counter moves past it, so the next profile is not handed the same one
setNextAccountIndex db sId (fromIntegral acct + 1)
pure True
createWalletSeed :: DB.Connection -> ByteString -> IO WalletSeed
createWalletSeed db entropy = do
@@ -111,18 +118,21 @@ createWalletSeed db entropy = do
pure WalletSeed {wsId = SeedId sId, wsEntropy = entropy}
-- | 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')
subtract 1 <$> getNextAccountIndex db sId
getNextAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex
getNextAccountIndex db (SeedId sId) =
maybe 0 (fromIntegral :: Int64 -> AccountIndex)
takeAccountIndex :: DB.Connection -> SeedId -> IO Int64
takeAccountIndex db (SeedId sId) = do
DB.execute db "UPDATE wallet_seeds SET next_account_index = next_account_index + 1 WHERE wallet_seed_id = ?" (Only sId)
maybe 0 (subtract 1)
<$> ( maybeFirstRow fromOnly $
DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId)
)
setNextAccountIndex :: DB.Connection -> SeedId -> Int64 -> IO ()
setNextAccountIndex db (SeedId sId) acct =
DB.execute
db
"UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index < ?"
(acct, sId, acct)
-- | Profiles are unbound first, as the foreign key is ON DELETE RESTRICT.
deleteSeed :: DB.Connection -> SeedId -> IO ()
deleteSeed db (SeedId sId) = do