commit 940cf741724b016a96744e01ce0cbeda89371a7a
parent 7b70793052e54e4b0c8a611236ba2e491b5f1649
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 22 Jul 2026 14:44:56 +0200
Persist burn rate configuration
Diffstat:
5 files changed, 139 insertions(+), 18 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:18661 --p2p 127.0.0.1:9444
```
-Open `http://127.0.0.1:18661` 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.
+Open `http://127.0.0.1:18661` 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 runtime config in `.mivora/config.json`, and does not create a chain yet.
For fast local development, set `MIVORA_DEV_SKIP_SEED_VERIFY=1` before starting the node to show a setup-only skip button for the recovery phrase check.
@@ -20,7 +20,7 @@ After setup, restart with genesis mode:
cargo run -- --genesis --http 127.0.0.1:18661 --p2p 127.0.0.1:9444
```
-`--genesis` only works when the wallet file already exists. It creates the starter chain, adaptively measures VDF throughput 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.
+`--genesis` only works when the wallet file already exists and `.mivora/chain.sqlite3` does not already contain a blockchain. It creates the starter chain, adaptively measures VDF throughput locally, extrapolates that measurement to a 60-second initial round count, and persists the validated chain. The same data directory resumes automatically on later runs without `--genesis`.
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.
@@ -90,7 +90,7 @@ There is no default wallet seed in the binary. Keep the wallet file private; it
## 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.
+Mivora stores UI setup state, configured peers, and the configured automatic burn rate 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 and later runtime changes write the file through the HTTP API, so the choices follow the node data directory instead of a browser session.
## Chain Storage
diff --git a/assets/mivora-ui.js b/assets/mivora-ui.js
@@ -24,7 +24,7 @@ window.mivoraApp = function mivoraApp() {
transferTo: "",
transferAmount: 25,
peerAddress: "",
- showBurnTransactions: true,
+ showBurnTransactions: false,
flash: null,
flashTimer: null,
lastUpdated: null,
diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs
@@ -7,11 +7,14 @@ use std::{
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
+use crate::domain::Amount;
+
const CONFIG_FILE_VERSION: u32 = 1;
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct UiConfig {
pub setup_complete: bool,
+ pub burn_per_block: Amount,
pub peers: Vec<String>,
}
@@ -20,6 +23,8 @@ struct ConfigFile {
version: u32,
setup_complete: bool,
#[serde(default)]
+ burn_per_block: Amount,
+ #[serde(default)]
peers: Vec<String>,
}
@@ -42,6 +47,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
let stored = ConfigFile {
version: CONFIG_FILE_VERSION,
setup_complete: config.setup_complete,
+ burn_per_block: config.burn_per_block,
peers: config.peers.clone(),
};
let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?;
@@ -69,6 +75,7 @@ fn load(path: &Path) -> Result<UiConfig> {
Ok(UiConfig {
setup_complete: stored.setup_complete,
+ burn_per_block: stored.burn_per_block,
peers: stored.peers,
})
}
@@ -101,6 +108,7 @@ mod tests {
let stored = fs::read_to_string(path).unwrap();
assert!(stored.contains("\"version\": 1"));
assert!(stored.contains("\"setup_complete\": false"));
+ assert!(stored.contains("\"burn_per_block\": 0"));
assert!(stored.contains("\"peers\": []"));
}
@@ -113,6 +121,7 @@ mod tests {
&path,
&UiConfig {
setup_complete: true,
+ burn_per_block: 50,
peers: vec!["127.0.0.1:9444".to_string()],
},
)
@@ -120,6 +129,29 @@ mod tests {
let config = load_or_create(&path).unwrap();
assert!(config.setup_complete);
+ assert_eq!(config.burn_per_block, 50);
+ assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
+ }
+
+ #[test]
+ fn loads_old_config_without_burn_rate_as_zero() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("config.json");
+ fs::write(
+ &path,
+ r#"{
+ "version": 1,
+ "setup_complete": true,
+ "peers": ["127.0.0.1:9444"]
+}
+"#,
+ )
+ .unwrap();
+
+ let config = load_or_create(&path).unwrap();
+
+ assert!(config.setup_complete);
+ assert_eq!(config.burn_per_block, 0);
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,8 @@
-use std::{net::SocketAddr, path::PathBuf, sync::Arc};
+use std::{
+ net::SocketAddr,
+ path::{Path, PathBuf},
+ sync::Arc,
+};
use anyhow::{Context, Result, bail};
use axum::{
@@ -261,11 +265,24 @@ async fn set_burn_per_block(state: &HttpState, amount: Amount) -> Result<()> {
};
match result.0 {
- Ok(_) => state.gossip.broadcast(result.1).await,
+ Ok(_) => {
+ persist_burn_per_block_config(&state.ui_config, &state.config_path, amount).await?;
+ state.gossip.broadcast(result.1).await
+ }
Err(error) => Err(error),
}
}
+async fn persist_burn_per_block_config(
+ ui_config: &Arc<Mutex<UiConfig>>,
+ config_path: &Path,
+ amount: Amount,
+) -> Result<()> {
+ let mut config = ui_config.lock().await;
+ config.burn_per_block = amount;
+ config_store::save(config_path, &config)
+}
+
async fn add_peer(state: &HttpState, peer: String) -> Result<()> {
let addresses = {
let mut peers = state.peers.lock().await;
@@ -487,6 +504,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.switch input { width: auto; min-width: 0; accent-color: #d5f55f; }
.wallet-tx-list { display: grid; gap: 8px; }
.wallet-tx-row { display: grid; grid-template-columns: minmax(88px, .35fr) minmax(0, 1fr) auto; gap: 12px; align-items: center; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; }
+ .wallet-tx-row.pending { border-color: #3a4147; background: #191c20; box-shadow: inset 3px 0 0 #6f7880; }
.wallet-tx-main { display: grid; gap: 4px; min-width: 0; }
.wallet-tx-amount { font-weight: 900; }
.panel .grid + form { margin-top: 12px; }
@@ -536,7 +554,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/mivora-ui.js?v=23"></script>
+ <script defer src="/assets/mivora-ui.js?v=24"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="mivoraApp()" x-init="init()" x-cloak>
@@ -609,7 +627,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
<div class="wallet-tx-list">
<template x-for="tx in walletTransactions()" :key="tx.status + '-' + tx.signature">
- <div class="wallet-tx-row">
+ <div class="wallet-tx-row" :class="{ pending: tx.status === 'pending' }">
<span class="pill" :class="tx.kind" x-text="tx.direction"></span>
<div class="wallet-tx-main">
<div><span class="wallet-tx-amount" x-text="tx.amount"></span> coin(s)</div>
@@ -857,11 +875,36 @@ const INDEX_HTML: &str = r#"<!doctype html>
#[cfg(test)]
mod tests {
- use super::dev_seed_verify_bypass_allowed;
+ use std::sync::Arc;
+
+ use tokio::sync::Mutex;
+
+ use crate::adapters::{config_store, config_store::UiConfig};
+
+ use super::{dev_seed_verify_bypass_allowed, persist_burn_per_block_config};
#[test]
fn dev_seed_verify_bypass_requires_env_flag() {
assert!(dev_seed_verify_bypass_allowed(true));
assert!(!dev_seed_verify_bypass_allowed(false));
}
+
+ #[tokio::test]
+ async fn burn_rate_config_persistence_updates_config_file() {
+ let dir = tempfile::tempdir().unwrap();
+ let config_path = dir.path().join("config.json");
+ let ui_config = Arc::new(Mutex::new(UiConfig {
+ setup_complete: true,
+ ..UiConfig::default()
+ }));
+ let initial_config = ui_config.lock().await.clone();
+ config_store::save(&config_path, &initial_config).expect("initial config should save");
+
+ persist_burn_per_block_config(&ui_config, &config_path, 50)
+ .await
+ .unwrap();
+ let config = config_store::load_or_create(&config_path).unwrap();
+
+ assert_eq!(config.burn_per_block, 50);
+ }
}
diff --git a/src/main.rs b/src/main.rs
@@ -10,7 +10,7 @@ use std::{
use anyhow::{Context, Result, bail};
use mivora::{
adapters::{chain_store::SqliteChainStore, config_store, http, p2p, wallet_store},
- app::{DEFAULT_BURN_PER_BLOCK, NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms},
+ app::{NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms},
domain::{Amount, ChainSnapshot, GenesisBurn, Ledger, run_vdf},
};
use tokio::sync::Mutex;
@@ -36,7 +36,7 @@ async fn main() -> Result<()> {
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 initial_burn_per_block = initial_burn_per_block(&opts);
+ let initial_burn_per_block = initial_burn_per_block(&opts, &ui_config);
let node: SharedNode = Arc::new(Mutex::new(NodeCore::from_ledger(
wallet,
@@ -106,6 +106,12 @@ async fn initialize_ledger(
chain_store: &SqliteChainStore,
) -> Result<Ledger> {
if let Some(snapshot) = chain_store.load()? {
+ if opts.chain_mode == ChainMode::Genesis {
+ bail!(
+ "--genesis refuses to run because chain database already contains a blockchain at {}; start without --genesis to resume it",
+ chain_store.path().display()
+ );
+ }
let height = snapshot_height(&snapshot);
let ledger = Ledger::from_snapshot(snapshot).with_context(|| {
format!(
@@ -259,10 +265,10 @@ fn validate_wallet_for_mode(
Ok(())
}
-fn initial_burn_per_block(opts: &CliOptions) -> Amount {
+fn initial_burn_per_block(opts: &CliOptions, ui_config: &config_store::UiConfig) -> Amount {
match opts.chain_mode {
ChainMode::Genesis => GENESIS_INITIAL_BURN_PER_BLOCK,
- ChainMode::Setup | ChainMode::Join => DEFAULT_BURN_PER_BLOCK,
+ ChainMode::Setup | ChainMode::Join => ui_config.burn_per_block,
}
}
@@ -512,7 +518,7 @@ mod tests {
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use mivora::{
- adapters::chain_store::SqliteChainStore,
+ adapters::{chain_store::SqliteChainStore, config_store::UiConfig},
app::{DEFAULT_BURN_PER_BLOCK, NodeCore},
domain::{GenesisBurn, Ledger, Wallet},
};
@@ -601,13 +607,30 @@ mod tests {
#[test]
fn genesis_mode_starts_with_full_reward_burn_rate() {
let genesis = parse(&["--genesis"]).unwrap().unwrap();
- assert_eq!(initial_burn_per_block(&genesis), 100);
+ let configured = UiConfig {
+ burn_per_block: 50,
+ ..UiConfig::default()
+ };
+ assert_eq!(initial_burn_per_block(&genesis, &configured), 100);
+ }
+
+ #[test]
+ fn non_genesis_modes_start_with_configured_burn_rate() {
+ let configured = UiConfig {
+ burn_per_block: 50,
+ ..UiConfig::default()
+ };
let setup = parse(&[]).unwrap().unwrap();
- assert_eq!(initial_burn_per_block(&setup), DEFAULT_BURN_PER_BLOCK);
+ assert_eq!(initial_burn_per_block(&setup, &configured), 50);
let join = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap();
- assert_eq!(initial_burn_per_block(&join), DEFAULT_BURN_PER_BLOCK);
+ assert_eq!(initial_burn_per_block(&join, &configured), 50);
+
+ assert_eq!(
+ initial_burn_per_block(&setup, &UiConfig::default()),
+ DEFAULT_BURN_PER_BLOCK
+ );
}
#[test]
@@ -738,7 +761,7 @@ mod tests {
}
#[tokio::test]
- async fn startup_resumes_persisted_chain_instead_of_creating_new_genesis() {
+ async fn genesis_refuses_to_start_when_chain_database_exists() {
let dir = tempdir().unwrap();
let chain_path = dir.path().join("chain.sqlite3");
let store = SqliteChainStore::open(&chain_path).unwrap();
@@ -750,6 +773,29 @@ mod tests {
.unwrap()
.unwrap();
+ let error = initialize_ledger(&opts, fresh_wallet.address(), &store)
+ .await
+ .unwrap_err();
+
+ assert!(
+ error.to_string().contains("already contains a blockchain"),
+ "{error:#}"
+ );
+ }
+
+ #[tokio::test]
+ async fn startup_resumes_persisted_chain_without_genesis_flag() {
+ let dir = tempdir().unwrap();
+ let chain_path = dir.path().join("chain.sqlite3");
+ let store = SqliteChainStore::open(&chain_path).unwrap();
+ let persisted_wallet = Wallet::from_seed("persisted-chain-owner");
+ 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(&["--chain-db", chain_path.to_str().unwrap()])
+ .unwrap()
+ .unwrap();
+
let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store)
.await
.unwrap();