Skip to content

Commit d42fbe6

Browse files
committed
feat: add wallet generation and improve local signer UX
- Add POST /api/wallets/generate endpoint that creates a fresh BIP-39 seed phrase and returns the derived address for "Create wallet" flow - Add `generate_local_wallet()` function to generate 12-word seed phrases with OsRng entropy, deriving account 0 at m/44'/60'/0'/0/0 - Redesign local hot wallet setup with two modes: "Create wallet" (server generates phrase, shown once for backup) and "Import wallet" (paste existing key with warnings) - Change default local signer from private key to seed phrase at account 0 - Add reveal/hide and copy/download buttons for generated seed phrases on the frontend - Require explicit backup confirmation checkbox before allowing creation with generated wallet - Update wizard and signer UI labels from "Signer" to "Wallet" for clarity - Add seed phrase validation test and round-trip test verifying generated wallets can create bots - Add `rand` dependency (0.8) for secure entropy generation alongside coins-bip39 - Never store or echo back generated seed phrases server-side; only derived hex keys are persisted
1 parent 8dc6669 commit d42fbe6

11 files changed

Lines changed: 454 additions & 43 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
412b30ea7b1b91ee738f97729df48925cc38bf60
1+
fd7697729b1de3c524d3eafe9e1c4cf9dea1b4a1

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.148
1+
0.1.149

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stitch-bot"
3-
version = "0.1.148"
3+
version = "0.1.149"
44
edition = "2021"
55
description = "Stitch — Textile filler-network operator bot; market-makes the filler order book with signed UniswapX limit orders."
66
license = "AGPL-3.0-or-later"
@@ -58,6 +58,9 @@ async-trait = "0.1"
5858
# BIP-39/BIP-32 seed-phrase derivation for the hot wallet (same crate ethers/alloy
5959
# use, so account 0 at m/44'/60'/0'/0/0 matches MetaMask/Rabby/etc.).
6060
coins-bip39 = "0.12"
61+
# Same major as coins-bip39 — used to generate a fresh BIP-39 phrase for
62+
# "Create wallet" in the panel (OsRng).
63+
rand = "0.8"
6164
axum = "0.8"
6265
base64 = "0.22"
6366
k256 = { version = "0.13", features = ["ecdsa"] }

src/panel/http/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ fn public_routes() -> Router<AppState> {
256256
fn protected_routes(state: &AppState) -> Router<AppState> {
257257
Router::new()
258258
.route("/api/corridors", get(wizard::corridors))
259+
.route("/api/wallets/generate", post(wizard::generate_wallet))
259260
.route("/api/signer/check", post(wizard::check_signer))
260261
.route("/api/bots", get(bots::list).post(wizard::create))
261262
.route("/api/bots/{name}", get(bots::show).delete(bots::remove))

src/panel/http/wizard.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@ pub async fn corridors() -> Response {
4646
Json(serde_json::json!({ "corridors": list })).into_response()
4747
}
4848

49+
/// Generate a fresh hot wallet for the "Create wallet" step.
50+
///
51+
/// Returns the seed phrase once so the SPA can show a backup screen. The phrase
52+
/// is not stored server-side — the client posts it back (as `seedPhrase`) on
53+
/// create / change-signer, and the writer persists only the derived hex key.
54+
pub async fn generate_wallet() -> Result<Response, ApiError> {
55+
let wallet = crate::signer::generate_local_wallet()?;
56+
Ok(Json(serde_json::json!({
57+
"address": format!("{:?}", wallet.address).to_lowercase(),
58+
"seedPhrase": wallet.seed_phrase,
59+
}))
60+
.into_response())
61+
}
62+
4963
/// The signer half of the wizard payload.
5064
///
5165
/// Tagged on `kind` so the shape and the backend can't disagree, and so a missing
@@ -447,6 +461,41 @@ mod tests {
447461
.contains("[[pools]]"));
448462
}
449463

464+
#[tokio::test]
465+
async fn generate_wallet_returns_a_phrase_and_matching_address() {
466+
let h = harness("gen-wallet");
467+
let (status, body) = h.post_json("/api/wallets/generate", json!({})).await;
468+
assert_eq!(status, StatusCode::OK, "{body}");
469+
let v = Harness::parse(&body);
470+
let phrase = v["seedPhrase"].as_str().expect("seedPhrase");
471+
assert_eq!(phrase.split_whitespace().count(), 12);
472+
let address = v["address"].as_str().expect("address");
473+
assert!(address.starts_with("0x"));
474+
assert_eq!(address.len(), 42);
475+
// Round-trip through create so the derived key is what the fleet sees.
476+
let (status, _) = h
477+
.post_json(
478+
"/api/bots",
479+
json!({
480+
"name": "fresh",
481+
"corridorId": "cngn-usdt-bsc",
482+
"signer": { "kind": "local", "seedPhrase": phrase },
483+
}),
484+
)
485+
.await;
486+
assert_eq!(status, StatusCode::CREATED, "create with generated phrase");
487+
let (_, show) = h.get("/api/bots/fresh").await;
488+
let shown = Harness::parse(&show);
489+
let shown_addr = shown["config"]["operatorAddress"]
490+
.as_str()
491+
.or_else(|| shown["operatorAddress"].as_str())
492+
.expect("operator address on bot");
493+
assert_eq!(shown_addr.to_lowercase(), address.to_lowercase());
494+
// Secrets stay write-only — the generate response is the only place the
495+
// phrase appears, and the bot detail never echoes key material.
496+
assert!(!show.contains(phrase));
497+
}
498+
450499
#[tokio::test]
451500
async fn signer_check_warns_when_another_bot_shares_the_wallet_on_the_chain() {
452501
let h = harness("signer-check");

src/signer/mod.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,29 @@ pub fn parse_private_key(raw: &str) -> anyhow::Result<SigningKey> {
226226
/// imported here resolves to the same address the operator sees in their wallet.
227227
pub const DEFAULT_DERIVATION_PATH: &str = "m/44'/60'/0'/0/0";
228228

229+
/// A freshly generated hot wallet: BIP-39 seed phrase (12 words) plus the
230+
/// account-0 address at [`DEFAULT_DERIVATION_PATH`]. The panel shows the phrase
231+
/// once so the operator can back it up; only the derived hex key is persisted.
232+
#[derive(Debug, Clone)]
233+
pub struct GeneratedLocalWallet {
234+
pub address: Address,
235+
pub seed_phrase: String,
236+
}
237+
238+
/// Generate a new local operator wallet (OsRng entropy → BIP-39 → account 0).
239+
pub fn generate_local_wallet() -> anyhow::Result<GeneratedLocalWallet> {
240+
use coins_bip39::{English, Mnemonic};
241+
use rand::rngs::OsRng;
242+
243+
let mnemonic = Mnemonic::<English>::new(&mut OsRng);
244+
let seed_phrase = mnemonic.to_phrase();
245+
let key = parse_mnemonic(&seed_phrase)?;
246+
Ok(GeneratedLocalWallet {
247+
address: address_from_signing_key(&key),
248+
seed_phrase,
249+
})
250+
}
251+
229252
/// Derive the operator signing key from a BIP-39 seed phrase at
230253
/// [`DEFAULT_DERIVATION_PATH`]. The phrase is validated (wordlist + checksum) by
231254
/// the parse; a bad word or wrong length fails here rather than deriving a
@@ -450,6 +473,17 @@ mod tests {
450473
assert_eq!(from_phrase.to_bytes(), key().to_bytes());
451474
}
452475

476+
#[test]
477+
fn generate_local_wallet_is_twelve_words_and_round_trips() {
478+
let wallet = generate_local_wallet().expect("generate");
479+
let words: Vec<_> = wallet.seed_phrase.split_whitespace().collect();
480+
assert_eq!(words.len(), 12, "expected 12-word phrase");
481+
let derived = parse_mnemonic(&wallet.seed_phrase).expect("phrase parses");
482+
assert_eq!(address_from_signing_key(&derived), wallet.address);
483+
let other = generate_local_wallet().expect("second generate");
484+
assert_ne!(wallet.address, other.address, "two draws must not collide");
485+
}
486+
453487
#[test]
454488
fn mnemonic_tolerates_surrounding_whitespace() {
455489
let padded = format!(" {MNEMONIC}\n");

web/src/api.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,17 @@ export const api = {
141141
createBot: (body: unknown) =>
142142
request<{ bot: Bot; message: string }>('/api/bots', json(body)),
143143

144+
/**
145+
* Mint a fresh hot wallet for the Create wallet step. Returns address + seed
146+
* phrase once — nothing is stored until the client posts the phrase back on
147+
* create / change-signer.
148+
*/
149+
generateWallet: () =>
150+
request<{ address: string; seedPhrase: string }>(
151+
'/api/wallets/generate',
152+
json({}),
153+
),
154+
144155
/**
145156
* Dry-run: which other bots already use this signer on this chain. Used to warn
146157
* before create / change-signer — sharing a wallet races nonces.

web/src/components/ChangeSigner.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,21 +92,22 @@ export default function ChangeSigner({
9292

9393
if (!open) {
9494
return (
95-
<Card title="Signer">
95+
<Card title="Wallet">
9696
<p className="text-sm text-muted">
97-
Switch this bot's signer backend (hot wallet, Turnkey, or MPCVault). This
98-
writes the new credentials and recreates the container — a raw config edit
99-
can't, because the backend's secret lives outside the TOML.
97+
Create a new hot wallet, import an existing key, or switch to Turnkey /
98+
MPCVault. This writes the credentials and recreates the container — a
99+
raw config edit can't, because the backend's secret lives outside the
100+
TOML.
100101
</p>
101102
<div className="mt-3">
102-
<Button onClick={() => setOpen(true)}>Change signer</Button>
103+
<Button onClick={() => setOpen(true)}>Change wallet</Button>
103104
</div>
104105
</Card>
105106
)
106107
}
107108

108109
return (
109-
<Card title="Change signer">
110+
<Card title="Change wallet">
110111
<div className="space-y-4">
111112
<Banner tone="warning">
112113
This recreates {bot}'s container with the new backend. Orders it already

0 commit comments

Comments
 (0)