core: one command for the seed, whichever way it arrives

create and import differed only in where the entropy came from and already
shared one store function, so they are one command with the source named:
/_wallet create new, or /_wallet create seed=<phrase>. Naming it means no
seed is generated by typing a prefix.

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-11 12:28:46 +00:00
co-authored by Claude Opus 5
parent bfa510cdb1
commit 443fd43545
5 changed files with 32 additions and 36 deletions
-1
View File
@@ -455,7 +455,6 @@ undocumentedCommands =
"APIWalletDelete",
"APIWalletExportDerivedSecret",
"APIWalletExportSeedMnemonic",
"APIWalletImport",
"CheckChatRunning",
"ConfirmRemoteCtrl",
"ConnectRemoteCtrl",
+12 -9
View File
@@ -66,17 +66,20 @@ at the profile level, while what a profile buys sits at the address level.
Internal API. The names commands will call these; users will not.
```
/_wallet the next name addresses
/_wallet create generate the seed. Refused if the device has one
/_wallet import <phrase> store a seed. Refused if the device has one
/_wallet export the seed mnemonic
/_wallet export <name> one derived secret, as 0x and 64 hex digits
/_wallet delete delete the seed
/_wallet the next name addresses
/_wallet create new generate the seed
/_wallet create seed=<phrase> take the seed from a phrase
/_wallet export the seed mnemonic
/_wallet export <name> one derived secret, as 0x and 64 hex digits
/_wallet delete delete the seed
```
A seed is created only when asked for, never at startup and never as a side
effect of reading. None of these is forwarded to a remote host: the recovery
phrase must not leave the device, and the raw command would be logged there.
Creating and importing are one command, because they differ only in where the
entropy comes from, and either is refused if the device already has a seed. The
source is always named, so no seed is generated by typing a prefix. A seed is
created only when asked for, never at startup and never as a side effect of
reading. None of these is forwarded to a remote host: the recovery phrase must
not leave the device, and the raw command would be logged there.
## Schema
+2 -4
View File
@@ -418,8 +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
| APIWalletCreate
| APIWalletImport {recoveryPhrase :: Text}
| APIWalletCreate {recoveryPhrase :: Maybe Text}
| APIWalletExportSeedMnemonic
| APIWalletExportDerivedSecret {nameIndex :: NameIndex}
| APIWalletDelete
@@ -749,8 +748,7 @@ allowRemoteCommand = \case
ExecChatStoreSQL _ -> False
ExecAgentStoreSQL _ -> False
APIWallet -> False
APIWalletCreate -> False
APIWalletImport _ -> False
APIWalletCreate {} -> False
APIWalletExportSeedMnemonic -> False
APIWalletExportDerivedSecret {} -> False
APIWalletDelete -> False
+6 -10
View File
@@ -1498,14 +1498,10 @@ processChatCommand cxt nm = \case
Just seed -> do
next <- withFastStore' $ \db -> getNextNameIndex db (wsId seed)
CRWallet user True <$> nameKeyRows seed next
APIWalletCreate -> withUser $ \_ -> do
g <- asks random
entropy <- atomically $ newSeed MS256 g
created <- withFastStore' $ \db -> createSeed db entropy
unless created $ throwCmdError "this device already has a wallet key"
processChatCommand cxt nm APIWallet
APIWalletImport phrase -> withUser $ \_ -> do
entropy <- either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase)
APIWalletCreate phrase_ -> withUser $ \_ -> do
entropy <- case phrase_ of
Nothing -> asks random >>= atomically . newSeed MS256
Just phrase -> either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase)
created <- withFastStore' $ \db -> createSeed db entropy
unless created $ throwCmdError "this device already has a wallet key"
processChatCommand cxt nm APIWallet
@@ -5587,8 +5583,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 create" $> APIWalletCreate,
"/_wallet import " *> (APIWalletImport <$> textP),
"/_wallet create new" $> APIWalletCreate Nothing,
"/_wallet create seed=" *> (APIWalletCreate . Just <$> textP),
"/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP),
"/_wallet export" $> APIWalletExportSeedMnemonic,
"/_wallet delete" $> APIWalletDelete,
+12 -12
View File
@@ -75,18 +75,18 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice <## "no wallet key"
alice ##> "/_wallet export"
alice <## "bad chat command: no wallet key on this device"
alice ##> "/_wallet create"
alice ##> "/_wallet create new"
rows <- nameRows alice
map fst rows `shouldBe` ["m/44'/60'/0'/0/1", "m/44'/60'/0'/0/2"]
length (nub $ map snd rows) `shouldBe` 2
-- create is for the seed, and this device has one
alice ##> "/_wallet create"
alice ##> "/_wallet create new"
alice <## "bad chat command: this device already has a wallet key"
testWalletPersists :: HasCallStack => TestParams -> IO ()
testWalletPersists ps = do
rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create"
alice ##> "/_wallet create new"
nameRows alice
-- same database, new session: a name bought at that address must stay reachable
withTestChat ps "alice" $ \alice -> do
@@ -96,7 +96,7 @@ testWalletPersists ps = do
testWalletSharedByProfiles :: HasCallStack => TestParams -> IO ()
testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice ##> ("/_wallet create seed=" <> B.unpack testPhrase)
rows <- nameRows alice
alice ##> "/create user alisa"
showActiveUser alice "alisa"
@@ -109,20 +109,20 @@ testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice
testWalletImport :: HasCallStack => TestParams -> IO ()
testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice ##> ("/_wallet create seed=" <> B.unpack testPhrase)
alice <## "m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
alice <## "m/44'/60'/0'/0/2 0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A"
alice ##> "/_wallet export"
alice <## B.unpack testPhrase
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice ##> ("/_wallet create seed=" <> 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 create seed=" <> B.unpack (B.unwords $ replicate 12 "abandon"))
alice <## "bad chat command: bad recovery phrase"
testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO ()
testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice ##> ("/_wallet create seed=" <> B.unpack testPhrase)
_ <- nameRows alice
alice ##> "/_wallet export 1"
alice <## "m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 0x9a983cb3d832fbde5ab49d692b7a8bf5b5d232479c99333d0fc8e1d21f1b55b6"
@@ -135,22 +135,22 @@ testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \al
testWalletDelete :: HasCallStack => TestParams -> IO ()
testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice ##> ("/_wallet create seed=" <> B.unpack testPhrase)
_ <- nameRows alice
alice ##> "/_wallet delete"
alice <## "no wallet key"
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice ##> ("/_wallet create seed=" <> B.unpack testPhrase)
_ <- nameRows alice
pure ()
testWalletImportThenRestore :: HasCallStack => TestParams -> IO ()
testWalletImportThenRestore ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice ##> ("/_wallet create seed=" <> B.unpack testPhrase)
_ <- nameRows alice
-- restoring the database replaces the seed with what the backup held, which is nothing
forgetSeed alice
alice ##> "/_wallet"
alice <## "no wallet key"
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice ##> ("/_wallet create seed=" <> B.unpack testPhrase)
rows <- nameRows alice
map fst rows `shouldBe` ["m/44'/60'/0'/0/1", "m/44'/60'/0'/0/2"]