commit 77c9cec58db400a026ecc7f7a3bb62f201aaebb0
parent 17605c40ade29c79001e2f36ac5b399c1ebb0b96
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 22 Jul 2026 13:41:12 +0200
Add recovery phrase wallet setup
Diffstat:
6 files changed, 559 insertions(+), 36 deletions(-)
diff --git a/README.md b/README.md
@@ -10,7 +10,7 @@ The current devnet assumes friendly nodes. It has one binary that acts as wallet
cargo run -- --http 127.0.0.1:8443 --p2p 127.0.0.1:9444
```
-Open `http://127.0.0.1:8443` and complete the initial setup screen. The wallet is generated into `.mivora/`, peers and setup state are stored in `.mivora/config.json`, and no chain is created yet.
+Open `http://127.0.0.1:8443` and complete the initial setup modal. The setup flow lets you generate a local recovery phrase or import one, verifies generated phrases with a 4-word check, stores the wallet in `.mivora/wallet.json`, stores peers and setup state in `.mivora/config.json`, and does not create a chain yet.
After setup, restart with genesis mode:
@@ -83,7 +83,7 @@ The measured VDF round count is only the initial delay. After the first blocks,
## Wallet Storage
-Mivora creates a new wallet file the first time a node starts or joins a chain. By default it lives at `.mivora/wallet.json`, or at `<data-dir>/wallet.json` when `--data-dir` is set. Pass `--wallet path/to/wallet.json` to choose a specific wallet file.
+Mivora creates a new wallet file the first time a node starts or joins a chain. By default it lives at `.mivora/wallet.json`, or at `<data-dir>/wallet.json` when `--data-dir` is set. Pass `--wallet path/to/wallet.json` to choose a specific wallet file. New wallet files store a 24-word recovery phrase and the derived Ed25519 public key address.
There is no default wallet seed in the binary. Keep the wallet file private; it contains the local wallet seed used to derive the address.
diff --git a/assets/mivora-ui.js b/assets/mivora-ui.js
@@ -9,6 +9,14 @@ window.mivoraApp = function mivoraApp() {
mempool: [],
peers: [],
config: { setup_complete: false },
+ setupWallet: { address: null, seed_phrase: null },
+ setupWalletMode: "create",
+ setupSeedStep: "write",
+ generatedSeedPhrase: "",
+ verifyChallenges: [],
+ verifyAnswers: {},
+ importSeedPhrase: "",
+ walletVerified: false,
burnAmount: 0,
transferTo: "",
transferAmount: 25,
@@ -28,6 +36,9 @@ window.mivoraApp = function mivoraApp() {
async bootstrap() {
await this.refreshConfig();
+ if (!this.config.setup_complete) {
+ await this.refreshWalletSetup();
+ }
this.tab = this.tabFromHash();
window.addEventListener("hashchange", () => {
this.tab = this.tabFromHash();
@@ -67,8 +78,133 @@ window.mivoraApp = function mivoraApp() {
this.config = await this.fetchJson("/api/config");
},
+ async refreshWalletSetup() {
+ const payload = await this.fetchJson("/api/wallet/setup");
+ if (!payload.ok) {
+ throw new Error(payload.error || "Could not load wallet setup");
+ }
+ this.setupWallet = payload;
+ if (
+ payload.seed_phrase &&
+ payload.seed_phrase !== this.generatedSeedPhrase &&
+ this.setupWalletMode === "create" &&
+ !this.walletVerified
+ ) {
+ this.generatedSeedPhrase = payload.seed_phrase;
+ this.walletVerified = false;
+ this.setupSeedStep = "write";
+ this.verifyChallenges = [];
+ this.verifyAnswers = {};
+ }
+ },
+
+ setupSeedWords() {
+ return this.generatedSeedPhrase ? this.generatedSeedPhrase.split(/\s+/) : [];
+ },
+
+ setupAddress() {
+ return this.setupWallet.address || this.status.wallet_address || "-";
+ },
+
+ selectSetupWalletMode(mode) {
+ this.setupWalletMode = mode;
+ this.walletVerified = mode === "import" ? this.walletVerified && !this.generatedSeedPhrase : false;
+ },
+
+ async generateSetupSeed() {
+ try {
+ const payload = await this.postWalletSetup("/api/wallet/generate", {});
+ this.setupWallet = payload;
+ this.generatedSeedPhrase = payload.seed_phrase || "";
+ this.setupWalletMode = "create";
+ this.setupSeedStep = "write";
+ this.walletVerified = false;
+ this.verifyChallenges = [];
+ this.verifyAnswers = {};
+ await this.refresh();
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
+ beginSeedVerification() {
+ const words = this.setupSeedWords();
+ if (words.length < 4) {
+ this.showFlash("Generate a recovery phrase first", "error");
+ return;
+ }
+ const positions = words.map((_, index) => index);
+ for (let index = positions.length - 1; index > 0; index -= 1) {
+ const swapIndex = Math.floor(Math.random() * (index + 1));
+ [positions[index], positions[swapIndex]] = [positions[swapIndex], positions[index]];
+ }
+ this.verifyChallenges = positions
+ .slice(0, 4)
+ .sort((left, right) => left - right)
+ .map((index) => ({ index, position: index + 1 }));
+ this.verifyAnswers = {};
+ for (const challenge of this.verifyChallenges) {
+ this.verifyAnswers[challenge.index] = "";
+ }
+ this.setupSeedStep = "verify";
+ },
+
+ verifyGeneratedSeed() {
+ const words = this.setupSeedWords();
+ const ok = this.verifyChallenges.every((challenge) => {
+ const expected = words[challenge.index] || "";
+ const actual = (this.verifyAnswers[challenge.index] || "").trim().toLowerCase();
+ return actual === expected;
+ });
+ if (!ok) {
+ this.showFlash("Seed word check failed", "error");
+ return;
+ }
+ this.walletVerified = true;
+ this.setupSeedStep = "verified";
+ this.showFlash("Recovery phrase verified", "success");
+ },
+
+ async importSetupSeed() {
+ try {
+ const payload = await this.postWalletSetup("/api/wallet/import", {
+ seed_phrase: this.importSeedPhrase,
+ });
+ this.setupWallet = payload;
+ this.generatedSeedPhrase = "";
+ this.verifyChallenges = [];
+ this.verifyAnswers = {};
+ this.walletVerified = true;
+ this.setupSeedStep = "verified";
+ await this.refresh();
+ this.showFlash("Recovery phrase imported", "success");
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
+ async postWalletSetup(path, fields) {
+ const body = new URLSearchParams();
+ for (const [key, value] of Object.entries(fields)) {
+ body.set(key, value);
+ }
+ const response = await fetch(path, {
+ method: "POST",
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
+ body,
+ });
+ const payload = await response.json();
+ if (!response.ok || !payload.ok) {
+ throw new Error(payload.error || `${path} returned ${response.status}`);
+ }
+ return payload;
+ },
+
async completeSetup() {
try {
+ if (!this.walletVerified) {
+ throw new Error("Verify or import a recovery phrase first");
+ }
const response = await fetch("/api/config", {
method: "POST",
headers: {
@@ -82,6 +218,10 @@ window.mivoraApp = function mivoraApp() {
throw new Error(payload.error || `/api/config returned ${response.status}`);
}
await this.refreshConfig();
+ this.generatedSeedPhrase = "";
+ this.importSeedPhrase = "";
+ this.verifyChallenges = [];
+ this.verifyAnswers = {};
this.showFlash("Setup complete", "success");
this.setTab("wallet");
} catch (error) {
@@ -99,6 +239,9 @@ window.mivoraApp = function mivoraApp() {
this.fetchJson("/api/peers"),
]);
this.config = config;
+ if (!this.config.setup_complete) {
+ await this.refreshWalletSetup();
+ }
this.status = status;
this.mergeFreshBlocks(blocks, { animateHead: true });
this.mempool = mempool;
@@ -279,7 +422,7 @@ window.mivoraApp = function mivoraApp() {
async copyAddress() {
try {
- await navigator.clipboard.writeText(this.status.wallet_address || "");
+ await navigator.clipboard.writeText(this.setupAddress());
this.showFlash("Address copied", "success");
} catch (error) {
this.showFlash("Could not copy address", "error");
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -1,6 +1,6 @@
use std::{net::SocketAddr, path::PathBuf, sync::Arc};
-use anyhow::{Context, Result};
+use anyhow::{Context, Result, bail};
use axum::{
Form, Json, Router,
extract::{Query, State},
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use tokio::{net::TcpListener, sync::Mutex};
use crate::{
- adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork},
+ adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, wallet_store},
app::{NodeStatus, PeerInfo, SharedNode, SharedPeerBook},
domain::{Amount, Block, Transaction},
};
@@ -27,6 +27,7 @@ struct HttpState {
gossip: GossipNetwork,
ui_config: Arc<Mutex<UiConfig>>,
config_path: PathBuf,
+ wallet_path: PathBuf,
}
#[derive(Debug, Deserialize)]
@@ -51,6 +52,11 @@ struct ConfigForm {
}
#[derive(Debug, Deserialize)]
+struct SeedPhraseForm {
+ seed_phrase: String,
+}
+
+#[derive(Debug, Deserialize)]
struct BlocksQuery {
before_height: Option<u64>,
limit: Option<usize>,
@@ -62,12 +68,21 @@ struct ActionResponse {
error: Option<String>,
}
+#[derive(Debug, Serialize)]
+struct WalletSetupResponse {
+ ok: bool,
+ error: Option<String>,
+ address: Option<String>,
+ seed_phrase: Option<String>,
+}
+
pub async fn serve(
node: SharedNode,
peers: SharedPeerBook,
gossip: GossipNetwork,
ui_config: Arc<Mutex<UiConfig>>,
config_path: PathBuf,
+ wallet_path: PathBuf,
addr: SocketAddr,
) -> Result<()> {
let state = HttpState {
@@ -76,6 +91,7 @@ pub async fn serve(
gossip,
ui_config,
config_path,
+ wallet_path,
};
let app = Router::new()
.route("/", get(index))
@@ -84,6 +100,9 @@ pub async fn serve(
.route("/api/status", get(api_status))
.route("/api/blocks", get(api_blocks))
.route("/api/config", get(api_config).post(api_config_form))
+ .route("/api/wallet/setup", get(api_wallet_setup))
+ .route("/api/wallet/generate", post(api_wallet_generate_form))
+ .route("/api/wallet/import", post(api_wallet_import_form))
.route("/api/mempool", get(api_mempool))
.route("/api/peers", get(api_peers).post(api_peer_form))
.route(
@@ -152,6 +171,10 @@ async fn api_config(State(state): State<HttpState>) -> Json<UiConfig> {
Json(state.ui_config.lock().await.clone())
}
+async fn api_wallet_setup(State(state): State<HttpState>) -> Json<WalletSetupResponse> {
+ wallet_setup_json(wallet_setup_response(&state).await)
+}
+
async fn api_mempool(State(state): State<HttpState>) -> Json<Vec<Transaction>> {
Json(state.node.lock().await.pending_transactions())
}
@@ -169,6 +192,17 @@ async fn api_config_form(
action_json(config_store::save(&state.config_path, &config))
}
+async fn api_wallet_generate_form(State(state): State<HttpState>) -> Json<WalletSetupResponse> {
+ wallet_setup_json(replace_setup_wallet_with_generated_seed(&state).await)
+}
+
+async fn api_wallet_import_form(
+ State(state): State<HttpState>,
+ Form(form): Form<SeedPhraseForm>,
+) -> Json<WalletSetupResponse> {
+ wallet_setup_json(import_setup_wallet_seed(&state, &form.seed_phrase).await)
+}
+
async fn api_burn_per_block_form(
State(state): State<HttpState>,
Form(form): Form<AmountForm>,
@@ -242,6 +276,74 @@ async fn add_peer(state: &HttpState, peer: String) -> Result<()> {
config_store::save(&state.config_path, &config)
}
+async fn wallet_setup_response(state: &HttpState) -> Result<WalletSetupResponse> {
+ let setup_complete = state.ui_config.lock().await.setup_complete;
+ let seed_phrase = if setup_complete {
+ None
+ } else {
+ wallet_store::setup_seed_phrase(&state.wallet_path)?
+ };
+ let address = state.node.lock().await.wallet_address().to_string();
+ Ok(WalletSetupResponse {
+ ok: true,
+ error: None,
+ address: Some(address),
+ seed_phrase,
+ })
+}
+
+async fn replace_setup_wallet_with_generated_seed(
+ state: &HttpState,
+) -> Result<WalletSetupResponse> {
+ ensure_wallet_setup_open(state).await?;
+ let (wallet, seed_phrase) =
+ wallet_store::replace_with_generated_seed_phrase(&state.wallet_path)?;
+ let address = wallet.address().to_string();
+ state.node.lock().await.replace_wallet(wallet);
+ Ok(WalletSetupResponse {
+ ok: true,
+ error: None,
+ address: Some(address),
+ seed_phrase: Some(seed_phrase),
+ })
+}
+
+async fn import_setup_wallet_seed(
+ state: &HttpState,
+ seed_phrase: &str,
+) -> Result<WalletSetupResponse> {
+ ensure_wallet_setup_open(state).await?;
+ let wallet = wallet_store::replace_with_imported_seed_phrase(&state.wallet_path, seed_phrase)?;
+ let address = wallet.address().to_string();
+ state.node.lock().await.replace_wallet(wallet);
+ Ok(WalletSetupResponse {
+ ok: true,
+ error: None,
+ address: Some(address),
+ seed_phrase: None,
+ })
+}
+
+async fn ensure_wallet_setup_open(state: &HttpState) -> Result<()> {
+ let setup_complete = state.ui_config.lock().await.setup_complete;
+ if setup_complete {
+ bail!("wallet setup is already complete");
+ }
+ Ok(())
+}
+
+fn wallet_setup_json(result: Result<WalletSetupResponse>) -> Json<WalletSetupResponse> {
+ match result {
+ Ok(response) => Json(response),
+ Err(error) => Json(WalletSetupResponse {
+ ok: false,
+ error: Some(format!("{error:#}")),
+ address: None,
+ seed_phrase: None,
+ }),
+ }
+}
+
async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> {
let result = {
let mut node = state.node.lock().await;
@@ -312,6 +414,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
button:hover { border-color: #d5f55f; color: #d5f55f; }
button.primary { background: #d5f55f; border-color: #d5f55f; color: #15171a; }
button.primary:hover { background: #e4ff83; color: #15171a; }
+ button.subtle { background: transparent; }
button:disabled { cursor: default; opacity: .5; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.metric, .panel { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 13px; }
@@ -321,8 +424,9 @@ const INDEX_HTML: &str = r#"<!doctype html>
.split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, .72fr); gap: 12px; }
form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
label { display: grid; gap: 5px; color: #a8b2b8; font-size: 13px; }
- input { min-width: 180px; border: 1px solid #3a444b; border-radius: 6px; padding: 9px 10px; font: inherit; background: #101215; color: #edf2f5; }
- input:focus { outline: 2px solid #d5f55f; outline-offset: 1px; }
+ input, textarea { min-width: 180px; border: 1px solid #3a444b; border-radius: 6px; padding: 9px 10px; font: inherit; background: #101215; color: #edf2f5; }
+ textarea { min-height: 118px; resize: vertical; line-height: 1.45; }
+ input:focus, textarea:focus { outline: 2px solid #d5f55f; outline-offset: 1px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; border-bottom: 1px solid #2a3035; padding: 8px; vertical-align: top; }
th { color: #8d989f; font-size: 11px; text-transform: uppercase; }
@@ -335,16 +439,26 @@ const INDEX_HTML: &str = r#"<!doctype html>
.ok { color: #d5f55f; }
.page-title { margin-bottom: 16px; }
.setup-overlay { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 22px; background: rgba(8, 9, 10, .72); backdrop-filter: blur(8px); }
- .setup-modal { width: min(860px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 18px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .42); }
+ .setup-modal { width: min(980px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 18px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .42); }
.setup-modal-head { display: grid; gap: 5px; margin-bottom: 16px; }
.setup-modal-head h2 { margin: 0; font-size: 24px; }
.setup-welcome { color: #d5f55f; font-size: 12px; font-weight: 900; text-transform: uppercase; }
.setup-copy { max-width: 620px; color: #a8b2b8; line-height: 1.45; }
.setup-grid { width: 100%; display: grid; grid-template-columns: minmax(0, .9fr) minmax(320px, .7fr); gap: 12px; align-items: start; }
.setup-section { border: 1px solid #2f363c; border-radius: 8px; padding: 13px; background: #111316; }
- .setup-actions { display: flex; justify-content: flex-end; margin-top: 14px; }
+ .setup-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
.setup-peer-list { margin-top: 14px; display: grid; gap: 8px; }
.setup-peer-row { display: flex; justify-content: space-between; gap: 12px; align-items: center; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #181b1f; }
+ .segmented { display: inline-flex; gap: 4px; padding: 4px; border: 1px solid #2f363c; border-radius: 8px; background: #181b1f; }
+ .segmented button { border-color: transparent; background: transparent; color: #9fa8ad; }
+ .segmented button.active { background: #d5f55f; color: #15171a; }
+ .seed-panel { display: grid; gap: 12px; }
+ .seed-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 8px; }
+ .seed-word { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 7px; align-items: center; border: 1px solid #2f363c; border-radius: 6px; padding: 7px 8px; background: #181b1f; }
+ .seed-word .index { color: #8d989f; font-size: 11px; font-weight: 800; }
+ .seed-word .word { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 800; color: #c7f5ea; }
+ .verify-grid { display: grid; gap: 8px; }
+ .setup-status { border: 1px solid #566d25; border-radius: 8px; padding: 10px; background: #1c2516; color: #d5f55f; font-weight: 800; }
.wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; }
.wallet-actions { display: grid; gap: 12px; }
.mining-grid { width: 100%; display: grid; grid-template-columns: minmax(0, .95fr) minmax(300px, .7fr); gap: 12px; align-items: start; }
@@ -406,7 +520,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/mivora-ui.js?v=18"></script>
+ <script defer src="/assets/mivora-ui.js?v=19"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="mivoraApp()" x-init="init()" x-cloak>
@@ -635,15 +749,67 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="setup-copy">Confirm the local wallet address and add any peers before this node starts from a saved configuration.</div>
</div>
<div class="setup-grid">
- <div class="setup-section">
+ <div class="setup-section seed-panel">
<div class="panel-head">
<h3>Wallet</h3>
<button type="button" @click="copyAddress">Copy</button>
</div>
- <div class="receive-address">
- <div class="muted">Public key / address</div>
- <div class="address-box"><code x-text="status.wallet_address || '-'"></code></div>
- <div class="muted">Balance <strong x-text="status.wallet_balance ?? '-'"></strong></div>
+ <div class="address-box"><code x-text="setupAddress()"></code></div>
+ <div class="segmented" role="tablist" aria-label="Wallet setup mode">
+ <button type="button" :class="{ active: setupWalletMode === 'create' }" @click="selectSetupWalletMode('create')">Create</button>
+ <button type="button" :class="{ active: setupWalletMode === 'import' }" @click="selectSetupWalletMode('import')">Import</button>
+ </div>
+ <div x-show="setupWalletMode === 'create'" class="seed-panel">
+ <template x-if="setupSeedWords().length > 0 && setupSeedStep === 'write'">
+ <div class="seed-panel">
+ <div class="seed-grid">
+ <template x-for="(word, index) in setupSeedWords()" :key="index">
+ <div class="seed-word">
+ <span class="index" x-text="index + 1"></span>
+ <span class="word" x-text="word"></span>
+ </div>
+ </template>
+ </div>
+ <div class="setup-actions">
+ <button type="button" class="subtle" @click="generateSetupSeed">Regenerate</button>
+ <button type="button" class="primary" @click="beginSeedVerification">I wrote it down</button>
+ </div>
+ </div>
+ </template>
+ <template x-if="setupSeedWords().length === 0">
+ <div class="seed-panel">
+ <div class="muted">This wallet does not have a recovery phrase yet.</div>
+ <button type="button" class="primary" @click="generateSetupSeed">Generate recovery phrase</button>
+ </div>
+ </template>
+ <template x-if="setupSeedStep === 'verify'">
+ <div class="seed-panel">
+ <div class="verify-grid">
+ <template x-for="challenge in verifyChallenges" :key="challenge.index">
+ <label>
+ Word <span x-text="challenge.position"></span>
+ <input x-model="verifyAnswers[challenge.index]" autocomplete="off">
+ </label>
+ </template>
+ </div>
+ <div class="setup-actions">
+ <button type="button" class="subtle" @click="setupSeedStep = 'write'">Back</button>
+ <button type="button" class="primary" @click="verifyGeneratedSeed">Verify</button>
+ </div>
+ </div>
+ </template>
+ <template x-if="setupSeedStep === 'verified' && walletVerified">
+ <div class="setup-status">Recovery phrase verified</div>
+ </template>
+ </div>
+ <div x-show="setupWalletMode === 'import'" class="seed-panel">
+ <form @submit.prevent="importSetupSeed">
+ <label>Recovery phrase<textarea x-model="importSeedPhrase" autocomplete="off" spellcheck="false"></textarea></label>
+ <button class="primary" type="submit">Import</button>
+ </form>
+ <template x-if="walletVerified">
+ <div class="setup-status">Recovery phrase imported</div>
+ </template>
</div>
</div>
<div class="setup-section">
@@ -664,7 +830,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
</div>
<div class="setup-actions">
- <button class="primary" type="button" @click="completeSetup">Continue</button>
+ <button class="primary" type="button" :disabled="!walletVerified" @click="completeSetup">Continue</button>
</div>
</section>
</div>
diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs
@@ -10,6 +10,35 @@ use serde::{Deserialize, Serialize};
use crate::domain::Wallet;
const WALLET_FILE_VERSION: u32 = 2;
+const GENERATED_SEED_WORDS: usize = 24;
+const IMPORT_SEED_WORD_COUNTS: [usize; 2] = [12, 24];
+const SEED_WORDS: &[&str] = &[
+ "able", "acid", "acorn", "adapt", "agent", "anchor", "angle", "apple", "asset", "atlas",
+ "badge", "balance", "beacon", "benefit", "binary", "bitter", "blanket", "border", "brave",
+ "bright", "broker", "budget", "cactus", "canvas", "carbon", "castle", "census", "circle",
+ "citizen", "clerk", "climate", "coffee", "copper", "corner", "cotton", "cradle", "credit",
+ "crisp", "custom", "damage", "decade", "degree", "delight", "delta", "device", "dinner",
+ "direct", "domain", "donor", "driver", "dynamic", "eager", "early", "earth", "echo", "economy",
+ "edge", "effort", "elder", "ember", "enable", "engine", "equal", "estate", "fabric", "factor",
+ "famous", "father", "feature", "federal", "fiction", "filter", "finger", "finish", "flame",
+ "forest", "forum", "frost", "future", "galaxy", "garden", "gentle", "giant", "glass", "globe",
+ "golden", "grain", "gravity", "green", "harbor", "hazard", "honest", "human", "humble",
+ "iceberg", "impact", "income", "index", "infant", "island", "jacket", "jewel", "journal",
+ "junior", "kernel", "ladder", "language", "laser", "leader", "legend", "lemon", "level",
+ "liberty", "linear", "local", "lottery", "lunar", "magnet", "market", "matrix", "member",
+ "memory", "merit", "method", "middle", "minute", "mirror", "model", "moment", "native",
+ "network", "neutral", "noble", "normal", "notice", "novel", "object", "ocean", "offer",
+ "olive", "orbit", "origin", "oxygen", "packet", "panel", "parent", "pattern", "people",
+ "pepper", "permit", "planet", "plastic", "policy", "postal", "prefer", "profit", "public",
+ "puzzle", "quality", "quantum", "quiet", "random", "rapid", "radius", "reason", "record",
+ "region", "repair", "reward", "ribbon", "rocket", "sample", "scale", "scheme", "secret",
+ "sector", "select", "senior", "shadow", "signal", "silver", "simple", "sister", "sketch",
+ "social", "solar", "source", "spare", "spirit", "stable", "station", "stone", "street",
+ "summer", "supply", "system", "talent", "target", "temple", "tenant", "theory", "ticket",
+ "timber", "token", "travel", "treat", "trust", "tunnel", "twelve", "unique", "update",
+ "useful", "valid", "valley", "vendor", "verify", "victory", "village", "virtual", "volume",
+ "wallet", "window", "winter", "wonder", "yellow", "zero",
+];
#[derive(Debug, Serialize, Deserialize)]
struct WalletFile {
@@ -23,12 +52,7 @@ pub fn load_or_create(path: &Path) -> Result<Wallet> {
return load(path);
}
- if let Some(parent) = path.parent() {
- fs::create_dir_all(parent)
- .with_context(|| format!("failed to create wallet directory {}", parent.display()))?;
- }
-
- let seed = random_seed()?;
+ let seed = generate_seed_phrase()?;
let wallet = Wallet::from_seed(&seed);
let mut file = create_wallet_file(path)?;
write_wallet_file(&mut file, seed, wallet.address())
@@ -37,11 +61,35 @@ pub fn load_or_create(path: &Path) -> Result<Wallet> {
Ok(wallet)
}
+pub fn replace_with_generated_seed_phrase(path: &Path) -> Result<(Wallet, String)> {
+ let seed = generate_seed_phrase()?;
+ let wallet = write_wallet(path, seed.clone(), WalletFileMode::Replace)?;
+ Ok((wallet, seed))
+}
+
+pub fn replace_with_imported_seed_phrase(path: &Path, seed_phrase: &str) -> Result<Wallet> {
+ let seed = normalize_seed_phrase(seed_phrase)?;
+ write_wallet(path, seed, WalletFileMode::Replace)
+}
+
+pub fn setup_seed_phrase(path: &Path) -> Result<Option<String>> {
+ if !path.exists() {
+ return Ok(None);
+ }
+ let stored = read_wallet_file(path)?;
+ let normalized = match normalize_seed_phrase(&stored.seed) {
+ Ok(seed) => seed,
+ Err(_) => return Ok(None),
+ };
+ if normalized == stored.seed {
+ Ok(Some(normalized))
+ } else {
+ Ok(None)
+ }
+}
+
fn load(path: &Path) -> Result<Wallet> {
- let bytes =
- fs::read(path).with_context(|| format!("failed to read wallet file {}", path.display()))?;
- let stored: WalletFile = serde_json::from_slice(&bytes)
- .with_context(|| format!("failed to parse wallet file {}", path.display()))?;
+ let stored = read_wallet_file(path)?;
let wallet = Wallet::from_seed(&stored.seed);
if stored.version == 1 {
@@ -73,6 +121,26 @@ fn load(path: &Path) -> Result<Wallet> {
Ok(wallet)
}
+fn read_wallet_file(path: &Path) -> Result<WalletFile> {
+ let bytes =
+ fs::read(path).with_context(|| format!("failed to read wallet file {}", path.display()))?;
+ serde_json::from_slice(&bytes)
+ .with_context(|| format!("failed to parse wallet file {}", path.display()))
+}
+
+enum WalletFileMode {
+ CreateNew,
+ Replace,
+}
+
+fn write_wallet(path: &Path, seed: String, mode: WalletFileMode) -> Result<Wallet> {
+ let wallet = Wallet::from_seed(&seed);
+ let mut file = open_wallet_file(path, mode)?;
+ write_wallet_file(&mut file, seed, wallet.address())
+ .with_context(|| format!("failed to write wallet file {}", path.display()))?;
+ Ok(wallet)
+}
+
fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()> {
let stored = WalletFile {
version: WALLET_FILE_VERSION,
@@ -85,16 +153,54 @@ fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()>
Ok(())
}
-fn random_seed() -> Result<String> {
- let mut bytes = [0_u8; 32];
+fn generate_seed_phrase() -> Result<String> {
+ let mut bytes = [0_u8; GENERATED_SEED_WORDS];
getrandom::getrandom(&mut bytes)
.map_err(|error| anyhow!("failed to read system randomness: {error:?}"))?;
- Ok(hex_encode(&bytes))
+ let words = bytes
+ .iter()
+ .map(|byte| SEED_WORDS[usize::from(*byte) % SEED_WORDS.len()])
+ .collect::<Vec<_>>();
+ Ok(words.join(" "))
+}
+
+fn normalize_seed_phrase(seed_phrase: &str) -> Result<String> {
+ let words = seed_phrase
+ .split_whitespace()
+ .map(|word| word.trim().to_ascii_lowercase())
+ .filter(|word| !word.is_empty())
+ .collect::<Vec<_>>();
+ if !IMPORT_SEED_WORD_COUNTS.contains(&words.len()) {
+ bail!("seed phrase must contain 12 or 24 words");
+ }
+ for word in &words {
+ if !word.chars().all(|ch| ch.is_ascii_lowercase()) {
+ bail!("seed phrase words must contain only letters");
+ }
+ }
+ Ok(words.join(" "))
}
fn create_wallet_file(path: &Path) -> Result<File> {
+ open_wallet_file(path, WalletFileMode::CreateNew)
+}
+
+fn open_wallet_file(path: &Path, mode: WalletFileMode) -> Result<File> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("failed to create wallet directory {}", parent.display()))?;
+ }
+
let mut options = OpenOptions::new();
- options.write(true).create_new(true);
+ options.write(true);
+ match mode {
+ WalletFileMode::CreateNew => {
+ options.create_new(true);
+ }
+ WalletFileMode::Replace => {
+ options.create(true).truncate(true);
+ }
+ }
#[cfg(unix)]
{
@@ -107,17 +213,16 @@ fn create_wallet_file(path: &Path) -> Result<File> {
.with_context(|| format!("failed to create wallet file {}", path.display()))
}
-fn hex_encode(bytes: &[u8]) -> String {
- bytes.iter().map(|byte| format!("{byte:02x}")).collect()
-}
-
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
- use super::load_or_create;
+ use super::{
+ load_or_create, replace_with_generated_seed_phrase, replace_with_imported_seed_phrase,
+ setup_seed_phrase,
+ };
#[test]
fn creates_and_reuses_wallet_file() {
@@ -131,6 +236,101 @@ mod tests {
let stored = fs::read_to_string(path).unwrap();
assert!(stored.contains(first.address()));
assert!(!stored.contains("dev-wallet"));
+ assert!(
+ setup_seed_phrase(&dir.path().join("wallet.json"))
+ .unwrap()
+ .is_some()
+ );
+ }
+
+ #[test]
+ fn generated_wallet_uses_recovery_phrase() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+
+ let (_wallet, seed_phrase) = replace_with_generated_seed_phrase(&path).unwrap();
+ let words = seed_phrase.split_whitespace().collect::<Vec<_>>();
+
+ assert_eq!(words.len(), 24);
+ assert_eq!(
+ setup_seed_phrase(&path).unwrap().as_deref(),
+ Some(seed_phrase.as_str())
+ );
+ }
+
+ #[test]
+ fn generated_verified_phrase_imports_to_same_wallet() {
+ let dir = tempdir().unwrap();
+ let generated_path = dir.path().join("generated-wallet.json");
+ let imported_path = dir.path().join("imported-wallet.json");
+
+ let (generated_wallet, seed_phrase) =
+ replace_with_generated_seed_phrase(&generated_path).unwrap();
+ assert_recovery_words_verify(&seed_phrase, &[0, 6, 13, 23]);
+
+ let generated_loaded = load_or_create(&generated_path).unwrap();
+ assert_eq!(generated_wallet.address(), generated_loaded.address());
+
+ let imported_wallet =
+ replace_with_imported_seed_phrase(&imported_path, &seed_phrase).unwrap();
+ assert_recovery_words_verify(
+ setup_seed_phrase(&imported_path)
+ .unwrap()
+ .as_deref()
+ .unwrap(),
+ &[0, 6, 13, 23],
+ );
+
+ let imported_loaded = load_or_create(&imported_path).unwrap();
+ assert_eq!(generated_wallet.address(), imported_wallet.address());
+ assert_eq!(generated_wallet.address(), imported_loaded.address());
+ assert_eq!(
+ setup_seed_phrase(&generated_path).unwrap(),
+ setup_seed_phrase(&imported_path).unwrap()
+ );
+ }
+
+ #[test]
+ fn imports_normalized_seed_phrase() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+
+ let wallet = replace_with_imported_seed_phrase(
+ &path,
+ " Able ACID acorn adapt agent anchor angle apple asset atlas badge balance ",
+ )
+ .unwrap();
+ let loaded = load_or_create(&path).unwrap();
+
+ assert_eq!(wallet.address(), loaded.address());
+ assert_eq!(
+ setup_seed_phrase(&path).unwrap().as_deref(),
+ Some("able acid acorn adapt agent anchor angle apple asset atlas badge balance")
+ );
+ }
+
+ #[test]
+ fn rejects_invalid_imported_seed_phrase() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+
+ let error = replace_with_imported_seed_phrase(&path, "too few words").unwrap_err();
+
+ assert!(error.to_string().contains("12 or 24 words"));
+ }
+
+ fn assert_recovery_words_verify(seed_phrase: &str, indexes: &[usize]) {
+ let words = seed_phrase.split_whitespace().collect::<Vec<_>>();
+ assert_eq!(words.len(), 24);
+ for index in indexes {
+ let answer = words[*index].to_ascii_uppercase();
+ assert_eq!(
+ answer.trim().to_ascii_lowercase(),
+ words[*index],
+ "word {} should verify case-insensitively",
+ index + 1
+ );
+ }
}
#[test]
diff --git a/src/app.rs b/src/app.rs
@@ -156,6 +156,11 @@ impl NodeCore {
self.wallet.address()
}
+ pub fn replace_wallet(&mut self, wallet: Wallet) {
+ self.wallet = wallet;
+ self.last_auto_burn_height = None;
+ }
+
pub fn ledger(&self) -> &Ledger {
&self.ledger
}
diff --git a/src/main.rs b/src/main.rs
@@ -84,7 +84,16 @@ async fn main() -> Result<()> {
println!("setup mode: no chain selected; skipping mining and chain persistence");
}
- http::serve(node, peers, gossip, ui_config, config_path, opts.http_addr).await
+ http::serve(
+ node,
+ peers,
+ gossip,
+ ui_config,
+ config_path,
+ wallet_path,
+ opts.http_addr,
+ )
+ .await
}
async fn initialize_ledger(