commit 17605c40ade29c79001e2f36ac5b399c1ebb0b96
parent ee737d81f17431058921ef6f526c3e895bc8f168
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 22 Jul 2026 13:14:32 +0200
Move initial setup into UI config flow
Diffstat:
6 files changed, 513 insertions(+), 136 deletions(-)
diff --git a/README.md b/README.md
@@ -7,20 +7,23 @@ The current devnet assumes friendly nodes. It has one binary that acts as wallet
## Run
```sh
-cargo run -- --start --http 127.0.0.1:8443 --p2p 127.0.0.1:9444
+cargo run -- --http 127.0.0.1:8443 --p2p 127.0.0.1:9444
```
-Open `http://127.0.0.1:8443`. The wallet is generated into `.mivora/`.
-The validated chain is persisted to `.mivora/chain.sqlite3` and resumes automatically when the same data directory is started again.
+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.
-Mining is automatic. There is no "mine block" button and no exact sleep. Each node can burn its configured amount once per chain height. Those burns become one-shot leader tickets for a future height after the launch profile's maturity delay. Only the selected ticket owner builds the next block, signs a leader proof, performs the VDF work, and gossips the finished block. The VDF is the clock.
-
-The plain command above creates the default zero-balance starter chain: genesis mints 1 coin and immediately burns it into the first leader ticket. Because non-genesis blocks must include at least one burn, the default 0-coin burn rate will wait instead of starting VDF work. For a local demo with room to configure automatic burns, leave one extra coin after genesis and set the burn rate in the Configuration tab:
+After setup, restart with genesis mode:
```sh
-cargo run -- --start --genesis-amount 2 --http 127.0.0.1:8443 --p2p 127.0.0.1:9444
+cargo run -- --genesis --http 127.0.0.1:8443 --p2p 127.0.0.1:9444
```
+`--genesis` only works when the wallet file already exists. It creates the starter chain, measures 10,000 VDF rounds locally, extrapolates that measurement to a 60-second initial round count, and persists the validated chain to `.mivora/chain.sqlite3`. The same data directory resumes automatically on later runs.
+
+Mining is automatic. There is no "mine block" button and no exact sleep. Each node can burn its configured amount once per chain height. Those burns become one-shot leader tickets for a future height after the launch profile's maturity delay. Only the selected ticket owner builds the next block, signs a leader proof, performs the VDF work, and gossips the finished block. The VDF is the clock.
+
+Genesis leaves the starter wallet with 1 spendable coin after the 1-coin bootstrap burn creates the first leader ticket. Because non-genesis blocks must include at least one burn, the default 0-coin burn rate will wait instead of starting VDF work until the burn rate is raised in the Configuration tab.
+
The management UI is a small AlpineJS app served from local vendored assets. It polls JSON endpoints every few seconds and includes:
- wallet and transaction controls,
@@ -36,7 +39,7 @@ cargo run -- --data-dir .mivora-bob --http 127.0.0.1:8444 --p2p 127.0.0.1:9445 -
`--join` fetches a chain snapshot from the peer before mining starts and announces this node's P2P listener back to that peer, so newly mined blocks can flow back without restarting the first node. If the peer cannot provide a snapshot, the node exits instead of silently starting a separate chain. Additional peers can be added from the Configuration screen.
-If `<data-dir>/chain.sqlite3` already exists, the node resumes that chain first. That makes restarts boring in the good way: `--start` will not create a new genesis over an existing local chain, and `--join` remains useful for reconnecting to peers without replacing local state. Pass `--chain-db path/to/chain.sqlite3` to override the database path.
+If `<data-dir>/chain.sqlite3` already exists, the node resumes that chain first. That makes restarts boring in the good way: `--genesis` will not create a new genesis over an existing local chain, and `--join` remains useful for reconnecting to peers without replacing local state. Pass `--chain-db path/to/chain.sqlite3` to override the database path.
Nodes also run a self-healing sync loop. They periodically compare known peer heights and tip hashes, request missing block ranges when a peer is ahead, and validate those blocks before importing them. Full snapshots are kept for initial join and fallback cases, not as the normal catch-up path. The mempool tolerates future-nonce transactions from peers and mines them once the missing nonce gap is filled.
@@ -46,21 +49,27 @@ The UI separates local height from shared height. Local height is the node's own
To start a small friendly network:
-1. Start your node with a public P2P bind:
+1. Start setup with a public P2P bind and complete the initial setup screen:
```sh
-cargo run -- --start --genesis-amount 2 --p2p 0.0.0.0:9444 --http 127.0.0.1:8443
+cargo run -- --p2p 0.0.0.0:9444 --http 127.0.0.1:8443
```
-2. Open the Configuration tab and set the starter burn rate so block 1 has a burn.
-3. Give friends your public `host:9444`.
-4. Friends join your chain:
+2. Restart with genesis mode:
+
+```sh
+cargo run -- --genesis --p2p 0.0.0.0:9444 --http 127.0.0.1:8443
+```
+
+3. Open the Configuration tab and set the starter burn rate so block 1 has a burn.
+4. Give friends your public `host:9444`.
+5. Friends join your chain:
```sh
cargo run -- --data-dir .mivora-friend --p2p 0.0.0.0:9445 --http 127.0.0.1:8443 --join your-host:9444
```
-Friends who join after you start will adopt your genesis and current chain. With the default genesis amount, the starter wallet begins with a 0 balance because genesis mints 1 coin and immediately burns it as the first leader ticket. For a moving demo, `--genesis-amount 2` leaves the starter one coin that can be configured as the block 1 burn from the UI, creating the ticket for block 2. After the starter mines the first block reward, send friends coins from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-coin automatic burn unless it is configured otherwise.
+Friends who join after you start will adopt your genesis and current chain. The starter wallet begins with 1 spendable coin after the bootstrap burn, which can be configured as the block 1 burn from the UI, creating the ticket for block 2. After the starter mines the first block reward, send friends coins from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-coin automatic burn unless it is configured otherwise.
The genesis block bootstraps the chain with a 1-coin burn from the starter wallet. Burns included in a block create one-shot tickets for a future height through a deterministic ticket lottery. The selected leader creates the next block content, signs a proof for the selected ticket, and runs a hash-chain VDF before gossiping the block.
@@ -70,11 +79,7 @@ The protocol targets 60-second blocks by retargeting the expected VDF rounds aft
The block reward is fixed at 100 coins. The default burn is 0 coins per block, so new nodes can join before they own coins. After a wallet has coins, raise the burn from the Configuration screen.
-The default VDF round count is only the initial delay. After the first blocks, the protocol steers rounds toward the 60-second target. For fast local demos and tests, pass a smaller initial value:
-
-```sh
-cargo run -- --vdf-rounds 10000
-```
+The measured VDF round count is only the initial delay. After the first blocks, the protocol steers rounds toward the 60-second target.
## Wallet Storage
@@ -82,6 +87,10 @@ Mivora creates a new wallet file the first time a node starts or joins a chain.
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.
+## Node Config
+
+Mivora stores UI setup state and configured peers in `<data-dir>/config.json`. If `setup_complete` is false, the management UI opens the initial setup screen for wallet and peer setup. Completing setup writes the file through the HTTP API, so the choice follows the node data directory instead of a browser session.
+
## Chain Storage
Mivora stores the latest validated `ChainSnapshot` in SQLite at `<data-dir>/chain.sqlite3`. The database is updated by a small background persistence task when the tip changes, so web requests, P2P sessions, and VDF work do not perform chain database writes on their main async paths.
@@ -93,6 +102,7 @@ Mivora stores the latest validated `ChainSnapshot` in SQLite at `<data-dir>/chai
- `src/adapters/http.rs`: HTTP management UI and status endpoint.
- `src/adapters/p2p.rs`: line-delimited JSON gossip, block-range catch-up, and chain snapshots over one TCP port.
- `src/adapters/chain_store.rs`: SQLite chain snapshot persistence.
+- `src/adapters/config_store.rs`: node-local UI setup config persistence.
- `src/adapters/wallet_store.rs`: local wallet file creation and loading.
- `assets/`: vendored browser assets for the management UI.
diff --git a/assets/mivora-ui.js b/assets/mivora-ui.js
@@ -8,6 +8,7 @@ window.mivoraApp = function mivoraApp() {
hasMoreBlocks: true,
mempool: [],
peers: [],
+ config: { setup_complete: false },
burnAmount: 0,
transferTo: "",
transferAmount: 25,
@@ -22,11 +23,16 @@ window.mivoraApp = function mivoraApp() {
blockPageSize: 20,
init() {
+ this.bootstrap();
+ },
+
+ async bootstrap() {
+ await this.refreshConfig();
this.tab = this.tabFromHash();
window.addEventListener("hashchange", () => {
this.tab = this.tabFromHash();
});
- this.refresh();
+ await this.refresh();
this.pollHandle = setInterval(() => this.refresh(), 5000);
},
@@ -53,14 +59,46 @@ window.mivoraApp = function mivoraApp() {
}[this.tab] || "Mivora";
},
+ showingSetup() {
+ return !this.config.setup_complete;
+ },
+
+ async refreshConfig() {
+ this.config = await this.fetchJson("/api/config");
+ },
+
+ async completeSetup() {
+ try {
+ const response = await fetch("/api/config", {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({ setup_complete: "true" }),
+ });
+ const payload = await response.json();
+ if (!response.ok || !payload.ok) {
+ throw new Error(payload.error || `/api/config returned ${response.status}`);
+ }
+ await this.refreshConfig();
+ this.showFlash("Setup complete", "success");
+ this.setTab("wallet");
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
async refresh() {
try {
- const [status, blocks, mempool, peers] = await Promise.all([
+ const [config, status, blocks, mempool, peers] = await Promise.all([
+ this.fetchJson("/api/config"),
this.fetchJson("/api/status"),
this.fetchJson("/api/blocks"),
this.fetchJson("/api/mempool"),
this.fetchJson("/api/peers"),
]);
+ this.config = config;
this.status = status;
this.mergeFreshBlocks(blocks, { animateHead: true });
this.mempool = mempool;
diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs
@@ -0,0 +1,125 @@
+use std::{
+ fs::{self, File, OpenOptions},
+ io::Write,
+ path::Path,
+};
+
+use anyhow::{Context, Result, bail};
+use serde::{Deserialize, Serialize};
+
+const CONFIG_FILE_VERSION: u32 = 1;
+
+#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
+pub struct UiConfig {
+ pub setup_complete: bool,
+ pub peers: Vec<String>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+struct ConfigFile {
+ version: u32,
+ setup_complete: bool,
+ #[serde(default)]
+ peers: Vec<String>,
+}
+
+pub fn load_or_create(path: &Path) -> Result<UiConfig> {
+ if path.exists() {
+ return load(path);
+ }
+
+ let config = UiConfig::default();
+ save(path, &config)?;
+ Ok(config)
+}
+
+pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("failed to create config directory {}", parent.display()))?;
+ }
+
+ let stored = ConfigFile {
+ version: CONFIG_FILE_VERSION,
+ setup_complete: config.setup_complete,
+ peers: config.peers.clone(),
+ };
+ let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?;
+ let mut file = create_config_file(path)?;
+ file.write_all(&bytes)
+ .with_context(|| format!("failed to write config file {}", path.display()))?;
+ file.write_all(b"\n")
+ .with_context(|| format!("failed to write config file {}", path.display()))?;
+ Ok(())
+}
+
+fn load(path: &Path) -> Result<UiConfig> {
+ let bytes =
+ fs::read(path).with_context(|| format!("failed to read config file {}", path.display()))?;
+ let stored: ConfigFile = serde_json::from_slice(&bytes)
+ .with_context(|| format!("failed to parse config file {}", path.display()))?;
+
+ if stored.version != CONFIG_FILE_VERSION {
+ bail!(
+ "unsupported config file version {} in {}",
+ stored.version,
+ path.display()
+ );
+ }
+
+ Ok(UiConfig {
+ setup_complete: stored.setup_complete,
+ peers: stored.peers,
+ })
+}
+
+fn create_config_file(path: &Path) -> Result<File> {
+ OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .open(path)
+ .with_context(|| format!("failed to create config file {}", path.display()))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use tempfile::tempdir;
+
+ use super::{UiConfig, load_or_create, save};
+
+ #[test]
+ fn creates_default_config_file() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("config.json");
+
+ let config = load_or_create(&path).unwrap();
+
+ assert!(!config.setup_complete);
+ let stored = fs::read_to_string(path).unwrap();
+ assert!(stored.contains("\"version\": 1"));
+ assert!(stored.contains("\"setup_complete\": false"));
+ assert!(stored.contains("\"peers\": []"));
+ }
+
+ #[test]
+ fn saves_and_loads_setup_completion() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("config.json");
+
+ save(
+ &path,
+ &UiConfig {
+ setup_complete: true,
+ peers: vec!["127.0.0.1:9444".to_string()],
+ },
+ )
+ .unwrap();
+ let config = load_or_create(&path).unwrap();
+
+ assert!(config.setup_complete);
+ assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
+ }
+}
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -1,4 +1,4 @@
-use std::net::SocketAddr;
+use std::{net::SocketAddr, path::PathBuf, sync::Arc};
use anyhow::{Context, Result};
use axum::{
@@ -9,10 +9,10 @@ use axum::{
routing::{get, post},
};
use serde::{Deserialize, Serialize};
-use tokio::net::TcpListener;
+use tokio::{net::TcpListener, sync::Mutex};
use crate::{
- adapters::p2p::GossipNetwork,
+ adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork},
app::{NodeStatus, PeerInfo, SharedNode, SharedPeerBook},
domain::{Amount, Block, Transaction},
};
@@ -25,6 +25,8 @@ struct HttpState {
node: SharedNode,
peers: SharedPeerBook,
gossip: GossipNetwork,
+ ui_config: Arc<Mutex<UiConfig>>,
+ config_path: PathBuf,
}
#[derive(Debug, Deserialize)]
@@ -44,6 +46,11 @@ struct PeerForm {
}
#[derive(Debug, Deserialize)]
+struct ConfigForm {
+ setup_complete: bool,
+}
+
+#[derive(Debug, Deserialize)]
struct BlocksQuery {
before_height: Option<u64>,
limit: Option<usize>,
@@ -59,12 +66,16 @@ pub async fn serve(
node: SharedNode,
peers: SharedPeerBook,
gossip: GossipNetwork,
+ ui_config: Arc<Mutex<UiConfig>>,
+ config_path: PathBuf,
addr: SocketAddr,
) -> Result<()> {
let state = HttpState {
node,
peers,
gossip,
+ ui_config,
+ config_path,
};
let app = Router::new()
.route("/", get(index))
@@ -72,6 +83,7 @@ pub async fn serve(
.route("/assets/mivora-ui.js", get(app_js))
.route("/api/status", get(api_status))
.route("/api/blocks", get(api_blocks))
+ .route("/api/config", get(api_config).post(api_config_form))
.route("/api/mempool", get(api_mempool))
.route("/api/peers", get(api_peers).post(api_peer_form))
.route(
@@ -136,6 +148,10 @@ async fn api_blocks(
Json(blocks)
}
+async fn api_config(State(state): State<HttpState>) -> Json<UiConfig> {
+ Json(state.ui_config.lock().await.clone())
+}
+
async fn api_mempool(State(state): State<HttpState>) -> Json<Vec<Transaction>> {
Json(state.node.lock().await.pending_transactions())
}
@@ -144,6 +160,15 @@ async fn api_peers(State(state): State<HttpState>) -> Json<Vec<PeerInfo>> {
Json(state.peers.lock().await.list())
}
+async fn api_config_form(
+ State(state): State<HttpState>,
+ Form(form): Form<ConfigForm>,
+) -> Json<ActionResponse> {
+ let mut config = state.ui_config.lock().await;
+ config.setup_complete = form.setup_complete;
+ action_json(config_store::save(&state.config_path, &config))
+}
+
async fn api_burn_per_block_form(
State(state): State<HttpState>,
Form(form): Form<AmountForm>,
@@ -181,13 +206,15 @@ async fn api_peer_form(
State(state): State<HttpState>,
Form(form): Form<PeerForm>,
) -> Json<ActionResponse> {
- state.peers.lock().await.add_peer(form.peer);
- action_json(Ok(()))
+ let result = add_peer(&state, form.peer).await;
+ action_json(result)
}
-async fn peer_form(State(state): State<HttpState>, Form(form): Form<PeerForm>) -> Redirect {
- state.peers.lock().await.add_peer(form.peer);
- Redirect::to("/")
+async fn peer_form(State(state): State<HttpState>, Form(form): Form<PeerForm>) -> Response {
+ match add_peer(&state, form.peer).await {
+ Ok(()) => Redirect::to("/").into_response(),
+ Err(error) => api_error(error).into_response(),
+ }
}
async fn set_burn_per_block(state: &HttpState, amount: Amount) -> Result<()> {
@@ -204,6 +231,17 @@ async fn set_burn_per_block(state: &HttpState, amount: Amount) -> Result<()> {
}
}
+async fn add_peer(state: &HttpState, peer: String) -> Result<()> {
+ let addresses = {
+ let mut peers = state.peers.lock().await;
+ peers.add_peer(peer);
+ peers.addresses()
+ };
+ let mut config = state.ui_config.lock().await;
+ config.peers = addresses;
+ config_store::save(&state.config_path, &config)
+}
+
async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> {
let result = {
let mut node = state.node.lock().await;
@@ -296,6 +334,17 @@ const INDEX_HTML: &str = r#"<!doctype html>
.flash.error { color: #ffb1a8; background: #2a1717; border-color: #713434; }
.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-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-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; }
.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; }
@@ -341,7 +390,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.pill.transfer { background: #17312a; color: #8de9cd; }
.mempool-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.mempool-item { flex: 0 0 200px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; }
- @media (max-width: 920px) { .wallet-grid, .mining-grid, .config-grid, .detail-grid { grid-template-columns: 1fr; } }
+ @media (max-width: 920px) { .setup-grid, .wallet-grid, .mining-grid, .config-grid, .detail-grid { grid-template-columns: 1fr; } }
@media (max-width: 760px) {
.app-shell { grid-template-columns: 1fr; }
.sidebar { position: sticky; z-index: 5; bottom: 0; top: auto; height: auto; flex-direction: row; justify-content: space-between; padding: 8px; border-right: 0; border-bottom: 1px solid #262b2f; }
@@ -350,14 +399,14 @@ const INDEX_HTML: &str = r#"<!doctype html>
.nav-button { width: 52px; min-height: 48px; }
.nav-button span { font-size: 10px; }
.content { padding: 16px 12px 36px; }
- header, .split, .wallet-grid, .mining-grid, .config-grid, .detail-grid, .wallet-tx-row { grid-template-columns: 1fr; }
+ header, .split, .setup-grid, .wallet-grid, .mining-grid, .config-grid, .detail-grid, .wallet-tx-row { grid-template-columns: 1fr; }
header { display: grid; }
input { min-width: 0; width: 100%; }
.switch input { width: auto; }
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/mivora-ui.js?v=15"></script>
+ <script defer src="/assets/mivora-ui.js?v=18"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="mivoraApp()" x-init="init()" x-cloak>
@@ -578,5 +627,46 @@ const INDEX_HTML: &str = r#"<!doctype html>
</section>
</main>
</div>
+ <div class="setup-overlay" x-show="showingSetup()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="setup-title">
+ <section class="setup-modal">
+ <div class="setup-modal-head">
+ <div class="setup-welcome">Welcome to Mivora</div>
+ <h2 id="setup-title">Initial Setup</h2>
+ <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="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>
+ </div>
+ <div class="setup-section">
+ <h3>Peers</h3>
+ <form @submit.prevent="addPeer">
+ <label>Peer address<input x-model="peerAddress" placeholder="127.0.0.1:9445"></label>
+ <button class="primary" type="submit">Add</button>
+ </form>
+ <div class="setup-peer-list">
+ <template x-for="peer in peers" :key="peer.address">
+ <div class="setup-peer-row">
+ <code x-text="peer.address"></code>
+ <span class="muted" x-text="peer.direction"></span>
+ </div>
+ </template>
+ <div class="muted" x-show="peers.length === 0">No peers</div>
+ </div>
+ </div>
+ </div>
+ <div class="setup-actions">
+ <button class="primary" type="button" @click="completeSetup">Continue</button>
+ </div>
+ </section>
+ </div>
</body>
</html>"#;
diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs
@@ -1,4 +1,5 @@
pub mod chain_store;
+pub mod config_store;
pub mod http;
pub mod p2p;
pub mod wallet_store;
diff --git a/src/main.rs b/src/main.rs
@@ -1,39 +1,56 @@
use std::{
- collections::BTreeMap, net::SocketAddr, path::PathBuf, str::FromStr, sync::Arc, time::Duration,
+ collections::BTreeMap,
+ net::SocketAddr,
+ path::{Path, PathBuf},
+ str::FromStr,
+ sync::Arc,
+ time::{Duration, Instant},
};
use anyhow::{Context, Result, bail};
use mivora::{
- adapters::{chain_store::SqliteChainStore, http, p2p, wallet_store},
- app::{
- DEFAULT_BURN_PER_BLOCK, DEFAULT_VDF_ROUNDS, NodeCore, PeerBook, SharedNode, SharedPeerBook,
- now_ms,
- },
- domain::{Amount, ChainSnapshot, GenesisBurn, Ledger, run_vdf},
+ adapters::{chain_store::SqliteChainStore, config_store, http, p2p, wallet_store},
+ app::{DEFAULT_BURN_PER_BLOCK, NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms},
+ domain::{ChainSnapshot, GenesisBurn, Ledger, run_vdf},
};
use tokio::sync::Mutex;
+const GENESIS_SPENDABLE_AMOUNT: u64 = 1;
+const VDF_MEASUREMENT_ROUNDS: u32 = 10_000;
+
#[tokio::main]
async fn main() -> Result<()> {
let Some(opts) = CliOptions::parse()? else {
return Ok(());
};
let wallet_path = opts.wallet_path();
+ let config_path = opts.config_path();
+ let wallet_file_exists = wallet_path.exists();
+ validate_wallet_for_mode(&opts, &wallet_path, wallet_file_exists)?;
let wallet = wallet_store::load_or_create(&wallet_path)?;
+ let ui_config = config_store::load_or_create(&config_path)?;
let chain_store = SqliteChainStore::open(opts.chain_db_path())?;
+ let persisted_chain_exists = chain_store.load()?.is_some();
let ledger = initialize_ledger(&opts, wallet.address(), &chain_store).await?;
+ let has_chain = opts.has_chain() || persisted_chain_exists;
let node: SharedNode = Arc::new(Mutex::new(NodeCore::from_ledger(
wallet,
ledger,
DEFAULT_BURN_PER_BLOCK,
)));
- let peers: SharedPeerBook = Arc::new(Mutex::new(PeerBook::from_addresses(opts.peers)));
- let initial_snapshot = { node.lock().await.chain_snapshot() };
- persist_chain_snapshot(&chain_store, initial_snapshot).await?;
+ let ui_config = Arc::new(Mutex::new(ui_config));
+ let mut peers = ui_config.lock().await.peers.clone();
+ peers.extend(opts.peers);
+ let peers: SharedPeerBook = Arc::new(Mutex::new(PeerBook::from_addresses(peers)));
+ if has_chain {
+ let initial_snapshot = { node.lock().await.chain_snapshot() };
+ persist_chain_snapshot(&chain_store, initial_snapshot).await?;
+ }
println!("mivora wallet: {}", node.lock().await.wallet_address());
println!("wallet file: {}", wallet_path.display());
+ println!("config file: {}", config_path.display());
println!("chain database: {}", chain_store.path().display());
println!("management UI: http://{}", opts.http_addr);
println!("p2p listener: {}", opts.p2p_addr);
@@ -42,28 +59,32 @@ async fn main() -> Result<()> {
DEFAULT_BURN_PER_BLOCK
);
- let persistence_node = Arc::clone(&node);
- let persistence_store = chain_store.clone();
- tokio::spawn(async move {
- run_chain_persistence(persistence_node, persistence_store).await;
- });
-
let gossip =
p2p::GossipNetwork::start(Arc::clone(&node), Arc::clone(&peers), opts.p2p_addr).await?;
- let miner_node = Arc::clone(&node);
- let miner_gossip = gossip.clone();
- tokio::spawn(async move {
- run_automatic_miner(miner_node, miner_gossip).await;
- });
-
- let sync_node = Arc::clone(&node);
- let sync_gossip = gossip.clone();
- tokio::spawn(async move {
- run_peer_sync(sync_node, sync_gossip).await;
- });
+ if has_chain {
+ let persistence_node = Arc::clone(&node);
+ let persistence_store = chain_store.clone();
+ tokio::spawn(async move {
+ run_chain_persistence(persistence_node, persistence_store).await;
+ });
+
+ let miner_node = Arc::clone(&node);
+ let miner_gossip = gossip.clone();
+ tokio::spawn(async move {
+ run_automatic_miner(miner_node, miner_gossip).await;
+ });
+
+ let sync_node = Arc::clone(&node);
+ let sync_gossip = gossip.clone();
+ tokio::spawn(async move {
+ run_peer_sync(sync_node, sync_gossip).await;
+ });
+ } else {
+ println!("setup mode: no chain selected; skipping mining and chain persistence");
+ }
- http::serve(node, peers, gossip, opts.http_addr).await
+ http::serve(node, peers, gossip, ui_config, config_path, opts.http_addr).await
}
async fn initialize_ledger(
@@ -84,13 +105,22 @@ async fn initialize_ledger(
chain_store.path().display()
);
Ok(ledger)
- } else if opts.start_new_chain {
- start_new_chain_ledger(wallet_address, opts.genesis_amount, opts.vdf_rounds)
} else {
- join_chain_ledger(&opts.join_peers, opts.p2p_addr).await
+ match opts.chain_mode {
+ ChainMode::Setup => Ok(setup_ledger()),
+ ChainMode::Genesis => start_genesis_ledger(wallet_address),
+ ChainMode::Join => join_chain_ledger(&opts.join_peers, opts.p2p_addr).await,
+ }
}
}
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum ChainMode {
+ Setup,
+ Genesis,
+ Join,
+}
+
#[derive(Debug)]
struct CliOptions {
wallet_path: Option<PathBuf>,
@@ -99,9 +129,7 @@ struct CliOptions {
p2p_addr: SocketAddr,
peers: Vec<String>,
join_peers: Vec<String>,
- start_new_chain: bool,
- genesis_amount: Amount,
- vdf_rounds: u32,
+ chain_mode: ChainMode,
data_dir: PathBuf,
}
@@ -118,22 +146,20 @@ impl CliOptions {
p2p_addr: SocketAddr::from_str("127.0.0.1:9444")?,
peers: Vec::new(),
join_peers: Vec::new(),
- start_new_chain: false,
- genesis_amount: 1,
- vdf_rounds: DEFAULT_VDF_ROUNDS,
+ chain_mode: ChainMode::Setup,
data_dir: PathBuf::from(".mivora"),
};
let raw_args = args.into_iter().collect::<Vec<_>>();
- if raw_args.is_empty() {
- print_help();
- return Ok(None);
- }
-
let mut args = raw_args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
- "--start" => opts.start_new_chain = true,
+ "--genesis" => {
+ if opts.chain_mode == ChainMode::Join {
+ bail!("choose either --genesis or --join, not both");
+ }
+ opts.chain_mode = ChainMode::Genesis;
+ }
"--wallet" => {
opts.wallet_path = Some(PathBuf::from(next_value(&mut args, "--wallet")?))
}
@@ -156,23 +182,14 @@ impl CliOptions {
.context("invalid --p2p address")?;
}
"--join" => {
+ if opts.chain_mode == ChainMode::Genesis {
+ bail!("choose either --genesis or --join, not both");
+ }
let peer = next_value(&mut args, "--join")?;
+ opts.chain_mode = ChainMode::Join;
opts.peers.push(peer.clone());
opts.join_peers.push(peer);
}
- "--genesis-amount" => {
- opts.genesis_amount = next_value(&mut args, "--genesis-amount")?
- .parse()
- .context("invalid --genesis-amount")?;
- if opts.genesis_amount < 1 {
- bail!("--genesis-amount must be at least 1 for the genesis ticket");
- }
- }
- "--vdf-rounds" => {
- opts.vdf_rounds = next_value(&mut args, "--vdf-rounds")?
- .parse()
- .context("invalid --vdf-rounds")?;
- }
"--data-dir" => opts.data_dir = PathBuf::from(next_value(&mut args, "--data-dir")?),
"--help" | "-h" => {
print_help();
@@ -182,11 +199,8 @@ impl CliOptions {
}
}
- if opts.start_new_chain && !opts.join_peers.is_empty() {
- bail!("choose either --start or --join, not both");
- }
- if !opts.start_new_chain && opts.join_peers.is_empty() {
- bail!("pass --start to create a new chain, or --join <addr:port> to join one");
+ if opts.chain_mode == ChainMode::Genesis && !opts.join_peers.is_empty() {
+ bail!("choose either --genesis or --join, not both");
}
Ok(Some(opts))
@@ -203,6 +217,14 @@ impl CliOptions {
.clone()
.unwrap_or_else(|| self.data_dir.join("chain.sqlite3"))
}
+
+ fn config_path(&self) -> PathBuf {
+ self.data_dir.join("config.json")
+ }
+
+ fn has_chain(&self) -> bool {
+ self.chain_mode != ChainMode::Setup
+ }
}
fn next_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
@@ -210,21 +232,34 @@ fn next_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<Str
.with_context(|| format!("missing value after {flag}"))
}
+fn validate_wallet_for_mode(
+ opts: &CliOptions,
+ wallet_path: &Path,
+ wallet_file_exists: bool,
+) -> Result<()> {
+ if opts.chain_mode == ChainMode::Genesis && !wallet_file_exists {
+ bail!(
+ "--genesis requires an existing wallet file at {}; start once without --genesis and complete UI setup first",
+ wallet_path.display()
+ );
+ }
+ Ok(())
+}
+
fn print_help() {
println!(
"mivora\n\n\
Usage:\n\
- mivora --start [options]\n\
+ mivora [options]\n\
+ mivora --genesis [options]\n\
mivora --join <addr:port> [options]\n\n\
Options:\n\
- --start Create a new chain with a genesis burn\n\
+ --genesis Create a new chain from an existing setup wallet\n\
--wallet <path> Wallet file (default <data-dir>/wallet.json)\n\
--chain-db <path> Chain SQLite database (default <data-dir>/chain.sqlite3)\n\
--http <addr:port> HTTP management UI address (default 127.0.0.1:8443)\n\
--p2p <addr:port> P2P TCP listener address (default 127.0.0.1:9444)\n\
--join <addr:port> Fetch chain snapshot from this peer before mining\n\
- --genesis-amount <amount> Genesis allocation before the 1-coin bootstrap ticket (default 1)\n\
- --vdf-rounds <rounds> Initial VDF delay rounds; protocol retargets toward 60s blocks\n\
--data-dir <path> Local wallet directory\n"
);
}
@@ -237,13 +272,14 @@ fn snapshot_height(snapshot: &ChainSnapshot) -> u64 {
.unwrap_or(0)
}
-fn start_new_chain_ledger(
- wallet_address: &str,
- genesis_amount: Amount,
- vdf_rounds: u32,
-) -> Result<Ledger> {
+fn setup_ledger() -> Ledger {
+ Ledger::new(BTreeMap::new(), 1)
+}
+
+fn start_genesis_ledger(wallet_address: &str) -> Result<Ledger> {
+ let vdf_rounds = measure_initial_vdf_rounds();
let mut genesis = BTreeMap::new();
- genesis.insert(wallet_address.to_string(), genesis_amount);
+ genesis.insert(wallet_address.to_string(), GENESIS_SPENDABLE_AMOUNT + 1);
Ledger::new_with_genesis_burns(
genesis,
vec![GenesisBurn::new(wallet_address, 1)],
@@ -251,6 +287,29 @@ fn start_new_chain_ledger(
)
}
+fn measure_initial_vdf_rounds() -> u32 {
+ let seed = "mivora-vdf-calibration";
+ let started = Instant::now();
+ let _ = run_vdf(seed, VDF_MEASUREMENT_ROUNDS);
+ let elapsed = started.elapsed();
+ let rounds = extrapolate_vdf_rounds(VDF_MEASUREMENT_ROUNDS, elapsed, Duration::from_secs(60));
+ println!(
+ "measured {VDF_MEASUREMENT_ROUNDS} VDF rounds in {}ms; initial VDF rounds: {rounds}",
+ elapsed.as_millis()
+ );
+ rounds
+}
+
+fn extrapolate_vdf_rounds(measured_rounds: u32, elapsed: Duration, target: Duration) -> u32 {
+ let elapsed_ns = elapsed.as_nanos().max(1);
+ let target_ns = target.as_nanos().max(1);
+ let rounds = u128::from(measured_rounds)
+ .saturating_mul(target_ns)
+ .saturating_div(elapsed_ns)
+ .max(1);
+ rounds.min(u128::from(u32::MAX)) as u32
+}
+
async fn join_chain_ledger(join_peers: &[String], advertised_addr: SocketAddr) -> Result<Ledger> {
let mut errors = Vec::new();
for peer in join_peers {
@@ -409,7 +468,8 @@ mod tests {
use tokio::sync::Mutex;
use super::{
- CliOptions, initialize_ledger, persist_chain_snapshot, run_chain_persistence_with_interval,
+ ChainMode, CliOptions, extrapolate_vdf_rounds, initialize_ledger, persist_chain_snapshot,
+ run_chain_persistence_with_interval, validate_wallet_for_mode,
};
fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> {
@@ -434,20 +494,29 @@ mod tests {
}
#[test]
- fn no_args_prints_help_without_starting() {
- assert!(parse(&[]).unwrap().is_none());
+ fn no_args_starts_setup_mode() {
+ let opts = parse(&[]).unwrap().unwrap();
+ assert_eq!(opts.chain_mode, ChainMode::Setup);
+ assert!(opts.join_peers.is_empty());
}
#[test]
fn removed_wallet_seed_is_rejected() {
- let error = parse(&["--wallet-seed", "alice", "--start"]).unwrap_err();
+ let error = parse(&["--wallet-seed", "alice", "--genesis"]).unwrap_err();
assert!(error.to_string().contains("--wallet-seed was removed"));
}
#[test]
fn runtime_configuration_flags_are_rejected() {
- for flag in ["--name", "--burn-per-block", "--peer"] {
- let error = parse(&["--start", flag, "value"]).unwrap_err();
+ for flag in [
+ "--start",
+ "--name",
+ "--burn-per-block",
+ "--peer",
+ "--genesis-amount",
+ "--vdf-rounds",
+ ] {
+ let error = parse(&["--genesis", flag, "value"]).unwrap_err();
assert!(
error.to_string().contains("unknown argument"),
"{flag} should not be accepted"
@@ -456,22 +525,39 @@ mod tests {
}
#[test]
- fn start_mode_is_explicit() {
- let opts = parse(&["--start"]).unwrap().unwrap();
- assert!(opts.start_new_chain);
+ fn genesis_mode_is_explicit() {
+ let opts = parse(&["--genesis"]).unwrap().unwrap();
+ assert_eq!(opts.chain_mode, ChainMode::Genesis);
assert!(opts.join_peers.is_empty());
}
#[test]
fn join_mode_does_not_start_new_chain() {
let opts = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap();
- assert!(!opts.start_new_chain);
+ assert_eq!(opts.chain_mode, ChainMode::Join);
assert_eq!(opts.join_peers, vec!["127.0.0.1:9444"]);
}
#[test]
+ fn genesis_and_join_are_exclusive() {
+ let error = parse(&["--genesis", "--join", "127.0.0.1:9444"]).unwrap_err();
+ assert!(
+ error
+ .to_string()
+ .contains("choose either --genesis or --join")
+ );
+
+ let error = parse(&["--join", "127.0.0.1:9444", "--genesis"]).unwrap_err();
+ assert!(
+ error
+ .to_string()
+ .contains("choose either --genesis or --join")
+ );
+ }
+
+ #[test]
fn http_management_port_can_be_configured() {
- let opts = parse(&["--start", "--http", "127.0.0.1:18443"])
+ let opts = parse(&["--genesis", "--http", "127.0.0.1:18443"])
.unwrap()
.unwrap();
assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18443");
@@ -479,7 +565,7 @@ mod tests {
#[test]
fn wallet_defaults_under_data_dir() {
- let opts = parse(&["--start", "--data-dir", "tmp-node"])
+ let opts = parse(&["--genesis", "--data-dir", "tmp-node"])
.unwrap()
.unwrap();
assert_eq!(
@@ -490,7 +576,7 @@ mod tests {
#[test]
fn chain_db_defaults_under_data_dir() {
- let opts = parse(&["--start", "--data-dir", "tmp-node"])
+ let opts = parse(&["--genesis", "--data-dir", "tmp-node"])
.unwrap()
.unwrap();
assert_eq!(
@@ -500,8 +586,19 @@ mod tests {
}
#[test]
+ fn config_defaults_under_data_dir() {
+ let opts = parse(&["--genesis", "--data-dir", "tmp-node"])
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ opts.config_path(),
+ std::path::PathBuf::from("tmp-node/config.json")
+ );
+ }
+
+ #[test]
fn wallet_path_can_be_explicit() {
- let opts = parse(&["--start", "--wallet", "alice-wallet.json"])
+ let opts = parse(&["--genesis", "--wallet", "alice-wallet.json"])
.unwrap()
.unwrap();
assert_eq!(
@@ -512,7 +609,7 @@ mod tests {
#[test]
fn chain_db_path_can_be_explicit() {
- let opts = parse(&["--start", "--chain-db", "alice-chain.sqlite3"])
+ let opts = parse(&["--genesis", "--chain-db", "alice-chain.sqlite3"])
.unwrap()
.unwrap();
assert_eq!(
@@ -521,6 +618,34 @@ mod tests {
);
}
+ #[test]
+ fn genesis_requires_existing_wallet_file() {
+ let opts = parse(&["--genesis"]).unwrap().unwrap();
+ let missing = std::path::Path::new("missing-wallet.json");
+ let error = validate_wallet_for_mode(&opts, missing, false).unwrap_err();
+ assert!(
+ error
+ .to_string()
+ .contains("requires an existing wallet file")
+ );
+
+ validate_wallet_for_mode(&opts, missing, true).unwrap();
+ let setup = parse(&[]).unwrap().unwrap();
+ validate_wallet_for_mode(&setup, missing, false).unwrap();
+ }
+
+ #[test]
+ fn vdf_measurement_extrapolates_to_target() {
+ assert_eq!(
+ extrapolate_vdf_rounds(10_000, Duration::from_secs(1), Duration::from_secs(60)),
+ 600_000
+ );
+ assert_eq!(
+ extrapolate_vdf_rounds(10_000, Duration::from_secs(0), Duration::from_secs(60)),
+ u32::MAX
+ );
+ }
+
#[tokio::test]
async fn startup_resumes_persisted_chain_instead_of_creating_new_genesis() {
let dir = tempdir().unwrap();
@@ -530,15 +655,9 @@ mod tests {
let persisted = ledger_with_one_mined_block(&persisted_wallet);
store.save(&persisted.snapshot()).unwrap();
let fresh_wallet = Wallet::from_seed("fresh-start-wallet");
- let opts = parse(&[
- "--start",
- "--genesis-amount",
- "1",
- "--chain-db",
- chain_path.to_str().unwrap(),
- ])
- .unwrap()
- .unwrap();
+ let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()])
+ .unwrap()
+ .unwrap();
let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store)
.await
@@ -592,15 +711,9 @@ VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0)
)
.unwrap();
let wallet = Wallet::from_seed("bad-db-wallet");
- let opts = parse(&[
- "--start",
- "--genesis-amount",
- "2",
- "--chain-db",
- chain_path.to_str().unwrap(),
- ])
- .unwrap()
- .unwrap();
+ let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()])
+ .unwrap()
+ .unwrap();
let error = initialize_ledger(&opts, wallet.address(), &store)
.await