iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

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:
MREADME.md | 4++--
Massets/mivora-ui.js | 145++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/adapters/http.rs | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/adapters/wallet_store.rs | 238++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/app.rs | 5+++++
Msrc/main.rs | 11++++++++++-
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(