commit e336832e1b30ad7e9aaf16be67bea0a12313ed14
parent bf84c8b537216c43e14f1d0c26f854715cb9abaa
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 24 Jul 2026 13:59:50 +0200
Revert "Move genesis creation into chain UI"
This reverts commit bf84c8b537216c43e14f1d0c26f854715cb9abaa.
Diffstat:
5 files changed, 336 insertions(+), 329 deletions(-)
diff --git a/assets/luun-ui.js b/assets/luun-ui.js
@@ -481,18 +481,6 @@ window.luunApp = function luunApp() {
}
},
- async mineGenesis() {
- try {
- await this.postForm(
- "/api/genesis/mine",
- {},
- `Mined genesis with ${this.amountLabel(this.status.chain?.mine_reward ?? 0)} LUUN`
- );
- } catch (error) {
- this.showFlash(error.message, "error");
- }
- },
-
automaticBurnFeeDraft() {
return this.parseLuunAmount(this.burnFeeDraft);
},
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -24,7 +24,7 @@ use crate::{
wallet_store,
},
app::{NodeStatus, PeerInfo, SharedNode, SharedPeerBook},
- domain::{Amount, Block, MINE_REWARD, OutPoint, Transaction, TxInput, TxOutput, hex_hash},
+ domain::{Amount, Block, OutPoint, Transaction, TxInput, TxOutput, hex_hash},
};
const EXPLORER_LIMIT: usize = 50;
@@ -189,7 +189,6 @@ pub async fn serve(
"/api/settings/burn-per-block",
post(api_burn_per_block_form),
)
- .route("/api/genesis/mine", post(api_mine_genesis_form))
.route("/api/mine", post(api_mine_form))
.route("/api/transfer", post(api_transfer_form))
.route("/settings/burn-per-block", post(burn_per_block_form))
@@ -357,11 +356,6 @@ async fn api_mine_form(State(state): State<HttpState>) -> Json<ActionResponse> {
action_json(result)
}
-async fn api_mine_genesis_form(State(state): State<HttpState>) -> Json<ActionResponse> {
- let result = mine_genesis(&state).await;
- action_json(result)
-}
-
async fn burn_per_block_form(
State(state): State<HttpState>,
Form(form): Form<BurnSettingsForm>,
@@ -820,24 +814,6 @@ async fn mine_pow_reward(state: &HttpState) -> Result<()> {
}
}
-async fn mine_genesis(state: &HttpState) -> Result<()> {
- let result = {
- let mut node = state.node.lock().await;
- let result = node.mine_genesis();
- let outbox = node.drain_outbox();
- (result, outbox)
- };
-
- match result.0 {
- Ok(_) => {
- persist_burn_settings_config(&state.ui_config, &state.config_path, MINE_REWARD, 0)
- .await?;
- state.gossip.broadcast(result.1).await
- }
- Err(error) => Err(error),
- }
-}
-
fn validate_transfer_form(form: TransferForm) -> Result<(String, Amount, Amount, Vec<OutPoint>)> {
let to = form.to.trim();
if to.is_empty() {
@@ -1314,15 +1290,6 @@ const INDEX_HTML: &str = r#"<!doctype html>
</section>
<section x-show="tab === 'chain'">
- <div class="panel" x-show="status.chain && !status.chain.started">
- <div class="mine-action-row">
- <div>
- <h2>Start Chain</h2>
- <div class="muted">Mine genesis to create the first LUUN and enable burn-based blocks.</div>
- </div>
- <button class="primary" type="button" @click="mineGenesis">Mine genesis</button>
- </div>
- </div>
<div class="explorer-shell">
<div class="block-rail-wrap">
<div class="block-rail-head">
diff --git a/src/app.rs b/src/app.rs
@@ -10,7 +10,7 @@ use tokio::sync::Mutex;
use crate::domain::{
Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_TRANSACTION_FEE, LaunchProfile, Ledger,
- MINE_REWARD, OutPoint, PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
+ OutPoint, PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
};
pub type SharedNode = Arc<Mutex<NodeCore>>;
@@ -218,10 +218,6 @@ impl NodeCore {
self.ledger.height()
}
- pub fn chain_started(&self) -> bool {
- self.ledger.is_started()
- }
-
pub fn recent_blocks(&self, limit: usize) -> Vec<Block> {
self.ledger.recent_blocks(limit)
}
@@ -333,10 +329,9 @@ impl NodeCore {
let chain = self.ledger.status();
let launch_profile = self.ledger.launch_profile();
let current_leader = self.ledger.expected_leader_for_next_block();
- let wallet_is_current_leader = chain.started
- && current_leader
- .as_deref()
- .is_none_or(|leader| leader == self.wallet.address());
+ let wallet_is_current_leader = current_leader
+ .as_deref()
+ .is_none_or(|leader| leader == self.wallet.address());
NodeStatus {
wallet_address: self.wallet.address().to_string(),
@@ -462,21 +457,6 @@ impl NodeCore {
Ok(tx)
}
- pub fn mine_genesis(&mut self) -> Result<Block> {
- if self.ledger.is_started() {
- anyhow::bail!("chain has already started");
- }
- let ledger =
- Ledger::new_with_genesis_mine(self.wallet.address(), self.ledger.vdf_rounds())?;
- let block = ledger.chain()[0].clone();
- self.ledger = ledger;
- self.burn_per_block = MINE_REWARD;
- self.burn_fee = 0;
- self.last_auto_burn_height = None;
- self.outbox.push(GossipEnvelope::Block(block.clone()));
- Ok(block)
- }
-
pub fn receive_transaction(&mut self, tx: Transaction) -> Result<bool> {
let accepted = self.ledger.submit_transaction(tx.clone())?;
if accepted {
@@ -521,11 +501,6 @@ impl NodeCore {
skipped_reason: None,
};
- if !self.ledger.is_started() {
- plan.skipped_reason = Some("mine genesis first".to_string());
- return plan;
- }
-
match self.prepare_automatic_burn() {
Ok(tx) => plan.burned = tx,
Err(error) => {
@@ -665,13 +640,6 @@ impl NodeCore {
}
pub fn import_chain_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<()> {
- if !self.ledger.is_started() {
- let ledger = Ledger::from_snapshot(snapshot)?;
- self.ledger = ledger;
- self.last_auto_burn_height = None;
- self.enqueue_imported_blocks(0);
- return Ok(());
- }
let previous_height = self.ledger.height();
let imported = self.ledger.extend_from_snapshot(snapshot)?;
if imported {
diff --git a/src/domain.rs b/src/domain.rs
@@ -677,7 +677,7 @@ pub struct PreparedBlock {
reward: Amount,
vdf_rounds: u32,
vdf_seed: String,
- leader_ticket: Option<BurnTicket>,
+ leader_ticket: BurnTicket,
transactions: Vec<Transaction>,
}
@@ -695,17 +695,14 @@ impl PreparedBlock {
}
pub fn finish(self, wallet: &Wallet, vdf_output: String) -> Block {
- let leader_proof = self.leader_ticket.as_ref().map(|leader_ticket| {
- let proof_payload = LeaderProofPayload {
- height: self.height,
- prev_hash: self.prev_hash.clone(),
- vdf_output: vdf_output.clone(),
- ticket_id: leader_ticket.id.clone(),
- ticket_amount: leader_ticket.amount,
- ticket_owner: leader_ticket.owner.clone(),
- };
- wallet.leader_proof(&proof_payload)
- });
+ let proof_payload = LeaderProofPayload {
+ height: self.height,
+ prev_hash: self.prev_hash.clone(),
+ vdf_output: vdf_output.clone(),
+ ticket_id: self.leader_ticket.id.clone(),
+ ticket_amount: self.leader_ticket.amount,
+ ticket_owner: self.leader_ticket.owner.clone(),
+ };
Block::new(BlockDraft {
height: self.height,
prev_hash: self.prev_hash,
@@ -714,7 +711,7 @@ impl PreparedBlock {
reward: self.reward,
vdf_rounds: self.vdf_rounds,
vdf_output,
- leader_proof,
+ leader_proof: Some(wallet.leader_proof(&proof_payload)),
transactions: self.transactions,
})
}
@@ -735,7 +732,6 @@ struct BlockDraft {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ChainStatus {
- pub started: bool,
pub height: u64,
pub tip_hash: String,
pub next_leader: Option<String>,
@@ -1180,7 +1176,6 @@ impl Ledger {
pub fn status(&self) -> ChainStatus {
ChainStatus {
- started: self.is_started(),
height: self.tip().height,
tip_hash: self.tip().hash.clone(),
next_leader: self.expected_leader_for_next_block(),
@@ -1195,12 +1190,6 @@ impl Ledger {
&self.chain
}
- pub fn is_started(&self) -> bool {
- self.chain.first().is_some_and(|genesis| {
- !genesis.transactions.is_empty() || !self.genesis_allocations.is_empty()
- })
- }
-
pub fn genesis_hash(&self) -> &str {
&self.chain[0].hash
}
@@ -1468,25 +1457,19 @@ impl Ledger {
pub fn prepare_next_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> {
let height = self.tip().height + 1;
- let is_bootstrap = self.needs_post_genesis_bootstrap_block(height);
- let leader_ticket = if is_bootstrap {
- None
- } else {
- let Some(leader_ticket) = self.selected_ticket_for_height(height) else {
- bail!("cannot mine block without a mature burn ticket");
- };
- if leader_ticket.owner != miner {
- let leader = &leader_ticket.owner;
+ let Some(leader_ticket) = self.selected_ticket_for_height(height) else {
+ bail!("cannot mine block without a mature burn ticket");
+ };
+ if let Some(leader) = self.expected_leader_for_next_block() {
+ if leader != miner {
bail!("wallet {miner} is not the selected leader; expected {leader}");
}
- Some(leader_ticket)
- };
+ } else {
+ bail!("no selected leader for block {height}");
+ }
- let transactions = self.select_block_transactions(is_bootstrap.then_some(miner))?;
+ let transactions = self.select_block_transactions()?;
ensure_block_has_burn(&transactions)?;
- if is_bootstrap {
- ensure_bootstrap_block_burn_owner(miner, &transactions)?;
- }
let tip = self.tip();
let prev_hash = tip.hash.clone();
@@ -1544,24 +1527,9 @@ impl Ledger {
bail!("block reward is invalid");
}
let mut tickets = self.tickets.clone();
- if self.needs_post_genesis_bootstrap_block(block.height) {
- ensure_bootstrap_block_burn_owner(&block.miner, &block.transactions)?;
- if block.leader_proof.is_some() {
- bail!("post-genesis bootstrap block must not carry a leader proof");
- }
- } else {
- consume_leader_ticket(&block, &mut tickets)?;
- }
+ consume_leader_ticket(&block, &mut tickets)?;
credit_reward_output(&mut utxos, &block)?;
- tickets.extend(tickets_created_by_block_with_delay(
- &block,
- &self.launch_profile,
- if self.needs_post_genesis_bootstrap_block(block.height) {
- 1
- } else {
- self.launch_profile.ticket_maturity_delay_heights
- },
- )?);
+ tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?);
let mined_signatures = block
.transactions
@@ -1624,13 +1592,6 @@ impl Ledger {
bail!("block exceeds max block size");
}
ensure_block_has_burn(&block.transactions)?;
- if self.needs_post_genesis_bootstrap_block(block.height) {
- if block.leader_proof.is_some() {
- bail!("post-genesis bootstrap block must not carry a leader proof");
- }
- ensure_bootstrap_block_burn_owner(&block.miner, &block.transactions)?;
- return Ok(true);
- }
let Some(leader) = self.expected_leader_for_next_block() else {
bail!("no selected leader for block {}", block.height);
};
@@ -1712,21 +1673,14 @@ impl Ledger {
valid
}
- fn select_block_transactions(
- &self,
- required_burn_owner: Option<&str>,
- ) -> Result<Vec<Transaction>> {
+ fn select_block_transactions(&self) -> Result<Vec<Transaction>> {
let mut utxos = self.utxos.clone();
let mut remaining = self.valid_pending_transactions();
let mut selected = Vec::new();
- let burn_index = match required_burn_owner {
- Some(owner) => best_selectable_burn_index_for_owner(&remaining, &utxos, owner),
- None => {
- best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn))
- }
- };
- if let Some(index) = burn_index {
+ if let Some(index) =
+ best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn))
+ {
let tx = remaining.remove(index);
let mut candidate = selected.clone();
candidate.push(tx.clone());
@@ -1853,19 +1807,6 @@ impl Ledger {
select_weighted_ticket(self.tip(), height, &self.tickets)
}
- fn needs_post_genesis_bootstrap_block(&self, height: u64) -> bool {
- height == 1
- && self.chain.len() == 1
- && self.genesis_allocations.is_empty()
- && self.tickets.is_empty()
- && self.chain.first().is_some_and(|genesis| {
- genesis
- .transactions
- .iter()
- .any(|tx| matches!(tx, Transaction::Mine { .. }))
- })
- }
-
fn tip(&self) -> &Block {
self.chain
.last()
@@ -1914,14 +1855,6 @@ fn weighted_ticket_draw(parent: &Block, target_height: u64, total_weight: u128)
}
fn tickets_created_by_block(block: &Block, profile: &LaunchProfile) -> Result<Vec<BurnTicket>> {
- tickets_created_by_block_with_delay(block, profile, profile.ticket_maturity_delay_heights)
-}
-
-fn tickets_created_by_block_with_delay(
- block: &Block,
- profile: &LaunchProfile,
- maturity_delay_heights: u64,
-) -> Result<Vec<BurnTicket>> {
if profile.ticket_expiry_window_heights == 0 {
bail!("ticket expiry window must be at least one height");
}
@@ -1944,7 +1877,7 @@ fn tickets_created_by_block_with_delay(
}
let target_height = block
.height
- .checked_add(maturity_delay_heights)
+ .checked_add(profile.ticket_maturity_delay_heights)
.with_context(|| format!("ticket target height overflow at block {}", block.height))?;
let eligible_until_height = target_height
.checked_add(profile.ticket_expiry_window_heights - 1)
@@ -2060,16 +1993,6 @@ fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> {
Ok(())
}
-fn ensure_bootstrap_block_burn_owner(miner: &str, transactions: &[Transaction]) -> Result<()> {
- let Some(owner) = transactions.iter().find_map(transaction_burn_owner) else {
- bail!("post-genesis bootstrap block must include a burn transaction");
- };
- if owner != miner {
- bail!("post-genesis bootstrap block miner must own its burn transaction");
- }
- Ok(())
-}
-
fn fee_rate_key(transaction: &Transaction) -> u128 {
let size = serialized_transaction_size_bytes(transaction).unwrap_or(usize::MAX);
if size == 0 || size == usize::MAX {
@@ -2104,36 +2027,6 @@ fn best_selectable_transaction_index(
.map(|(index, _)| index)
}
-fn best_selectable_burn_index_for_owner(
- transactions: &[Transaction],
- utxos: &BTreeMap<OutPoint, TxOutput>,
- owner: &str,
-) -> Option<usize> {
- transactions
- .iter()
- .enumerate()
- .filter(|(_, tx)| tx.is_burn())
- .filter(|(_, tx)| transaction_burn_owner(tx) == Some(owner))
- .filter(|(_, tx)| {
- let mut utxos = utxos.clone();
- apply_transaction(tx, &mut utxos).is_ok()
- })
- .max_by(|(_, left), (_, right)| {
- fee_rate_key(left)
- .cmp(&fee_rate_key(right))
- .then_with(|| left.fee().cmp(&right.fee()))
- .then_with(|| right.signature().cmp(left.signature()))
- })
- .map(|(index, _)| index)
-}
-
-fn transaction_burn_owner(transaction: &Transaction) -> Option<&str> {
- let Transaction::Burn { inputs, .. } = transaction else {
- return None;
- };
- inputs.first().map(|input| input.owner.as_str())
-}
-
fn serialized_transaction_size_bytes(transaction: &Transaction) -> Result<usize> {
serde_json::to_vec(transaction)
.map(|bytes| bytes.len())
@@ -2956,36 +2849,6 @@ mod tests {
}
#[test]
- fn mined_genesis_bootstraps_first_burn_block_without_leader_ticket() {
- let alice = Wallet::from_seed("mined-genesis-bootstrap-alice");
- let mut ledger = Ledger::new_with_genesis_mine(alice.address(), 1).unwrap();
-
- assert!(ledger.is_started());
- assert_eq!(ledger.expected_leader_for_next_block(), None);
-
- let burn = ledger.build_burn(&alice, MINE_REWARD, 0).unwrap();
- ledger.submit_transaction(burn.clone()).unwrap();
- let block = ledger.mine_next_block(&alice, 1_000).unwrap();
-
- assert_eq!(block.height, 1);
- assert_eq!(block.miner, alice.address());
- assert_eq!(block.leader_proof, None);
- assert!(
- block
- .transactions
- .iter()
- .any(|tx| tx.signature() == burn.signature())
- );
-
- ledger.apply_locally_mined_block(block).unwrap();
-
- assert_eq!(
- ledger.expected_leader_for_next_block(),
- Some(alice.address().to_string())
- );
- }
-
- #[test]
fn winning_burn_ticket_is_consumed_even_when_window_remains() {
let mut tickets = vec![
BurnTicket {
diff --git a/src/main.rs b/src/main.rs
@@ -1,15 +1,26 @@
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 luun::{
adapters::{chain_store::SqliteChainStore, config_store, http, p2p, wallet_store},
- app::{DEFAULT_VDF_ROUNDS, NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms},
- domain::{Amount, ChainSnapshot, Ledger, MICRO_LUUN, run_vdf},
+ app::{NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms},
+ domain::{Amount, ChainSnapshot, Ledger, MICRO_LUUN, MINE_REWARD, run_vdf},
};
use tokio::sync::Mutex;
+const GENESIS_INITIAL_BURN_PER_BLOCK: Amount = MINE_REWARD;
+const GENESIS_INITIAL_BURN_FEE: Amount = 0;
+const VDF_MEASUREMENT_INITIAL_ROUNDS: u32 = 1_000_000;
+const VDF_MEASUREMENT_MAX_ROUNDS: u32 = 100_000_000;
+const VDF_MEASUREMENT_MIN_ELAPSED: Duration = Duration::from_millis(150);
+
#[tokio::main]
async fn main() -> Result<()> {
let Some(opts) = CliOptions::parse()? else {
@@ -17,14 +28,26 @@ async fn main() -> Result<()> {
};
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 chain_store = SqliteChainStore::open(opts.chain_db_path())?;
let persisted_chain_exists = chain_store.load()?.is_some();
+ if opts.chain_mode == ChainMode::Genesis && persisted_chain_exists {
+ bail!(
+ "--genesis refuses to run because chain database already contains a blockchain at {}; start without --genesis to resume it",
+ chain_store.path().display()
+ );
+ }
let wallet = wallet_store::load_or_create(&wallet_path)?;
- let ui_config = config_store::load_or_create(&config_path)?;
- let ledger = initialize_ledger(&opts, &chain_store).await?;
- let has_chain = ledger.is_started() || persisted_chain_exists;
- let initial_burn_per_block = initial_burn_per_block(&ui_config);
- let initial_burn_fee = initial_burn_fee(&ui_config);
+ let mut ui_config = config_store::load_or_create(&config_path)?;
+ if opts.chain_mode == ChainMode::Genesis {
+ ui_config.setup_complete = false;
+ config_store::save(&config_path, &ui_config)?;
+ }
+ 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, &ui_config);
+ let initial_burn_fee = initial_burn_fee(&opts, &ui_config);
let node: SharedNode = Arc::new(Mutex::new(NodeCore::from_ledger_with_burn_fee(
wallet,
@@ -38,9 +61,7 @@ async fn main() -> Result<()> {
let peers: SharedPeerBook = Arc::new(Mutex::new(PeerBook::from_addresses(peers)));
if has_chain {
let initial_snapshot = { node.lock().await.chain_snapshot() };
- if snapshot_chain_started(&initial_snapshot) {
- persist_chain_snapshot(&chain_store, initial_snapshot).await?;
- }
+ persist_chain_snapshot(&chain_store, initial_snapshot).await?;
}
println!("luun wallet: {}", node.lock().await.wallet_address());
@@ -58,23 +79,27 @@ async fn main() -> Result<()> {
let gossip =
p2p::GossipNetwork::start(Arc::clone(&node), Arc::clone(&peers), opts.p2p_addr).await?;
- 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;
- });
+ 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,
@@ -102,8 +127,18 @@ fn format_luun(amount: Amount) -> String {
}
}
-async fn initialize_ledger(opts: &CliOptions, chain_store: &SqliteChainStore) -> Result<Ledger> {
+async fn initialize_ledger(
+ opts: &CliOptions,
+ wallet_address: &str,
+ 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!(
@@ -119,6 +154,7 @@ async fn initialize_ledger(opts: &CliOptions, chain_store: &SqliteChainStore) ->
} else {
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,
}
}
@@ -127,6 +163,7 @@ async fn initialize_ledger(opts: &CliOptions, chain_store: &SqliteChainStore) ->
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ChainMode {
Setup,
+ Genesis,
Join,
}
@@ -163,6 +200,12 @@ impl CliOptions {
let mut args = raw_args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
+ "--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")?))
}
@@ -185,6 +228,9 @@ 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());
@@ -199,6 +245,10 @@ impl CliOptions {
}
}
+ if opts.chain_mode == ChainMode::Genesis && !opts.join_peers.is_empty() {
+ bail!("choose either --genesis or --join, not both");
+ }
+
Ok(Some(opts))
}
@@ -217,6 +267,10 @@ impl CliOptions {
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> {
@@ -224,12 +278,32 @@ fn next_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<Str
.with_context(|| format!("missing value after {flag}"))
}
-fn initial_burn_per_block(ui_config: &config_store::UiConfig) -> Amount {
- ui_config.burn_per_block
+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 a fresh wallet path, but {} already exists; start without --genesis to reuse it or choose an empty --data-dir/--wallet",
+ wallet_path.display()
+ );
+ }
+ Ok(())
}
-fn initial_burn_fee(ui_config: &config_store::UiConfig) -> Amount {
- ui_config.burn_fee
+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 => ui_config.burn_per_block,
+ }
+}
+
+fn initial_burn_fee(opts: &CliOptions, ui_config: &config_store::UiConfig) -> Amount {
+ match opts.chain_mode {
+ ChainMode::Genesis => GENESIS_INITIAL_BURN_FEE,
+ ChainMode::Setup | ChainMode::Join => ui_config.burn_fee,
+ }
}
fn print_help() {
@@ -240,8 +314,10 @@ fn help_text() -> &'static str {
"luun\n\n\
Usage:\n\
luun [options]\n\
+ luun --genesis [options]\n\
luun --join <addr:port> [options]\n\n\
Options:\n\
+ --genesis Create a new chain with a fresh 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:18661)\n\
@@ -260,14 +336,62 @@ fn snapshot_height(snapshot: &ChainSnapshot) -> u64 {
.unwrap_or(0)
}
-fn snapshot_chain_started(snapshot: &ChainSnapshot) -> bool {
- snapshot.blocks.first().is_some_and(|genesis| {
- !snapshot.genesis_allocations.is_empty() || !genesis.transactions.is_empty()
- })
+fn setup_ledger() -> Ledger {
+ Ledger::new(BTreeMap::new(), 1)
}
-fn setup_ledger() -> Ledger {
- Ledger::new(BTreeMap::new(), DEFAULT_VDF_ROUNDS)
+fn start_genesis_ledger(wallet_address: &str) -> Result<Ledger> {
+ let vdf_rounds = measure_initial_vdf_rounds();
+ Ledger::new_with_genesis_mine(wallet_address, vdf_rounds)
+}
+
+fn measure_initial_vdf_rounds() -> u32 {
+ let seed = "luun-vdf-calibration";
+ let (measured_rounds, elapsed) = measure_vdf_rounds(
+ seed,
+ VDF_MEASUREMENT_INITIAL_ROUNDS,
+ VDF_MEASUREMENT_MIN_ELAPSED,
+ VDF_MEASUREMENT_MAX_ROUNDS,
+ );
+ let rounds = extrapolate_vdf_rounds(measured_rounds, elapsed, Duration::from_secs(60));
+ println!(
+ "measured {measured_rounds} VDF rounds in {:.3}ms; initial VDF rounds: {rounds}",
+ elapsed.as_secs_f64() * 1000.0
+ );
+ rounds
+}
+
+fn measure_vdf_rounds(
+ seed: &str,
+ initial_rounds: u32,
+ min_elapsed: Duration,
+ max_rounds_per_attempt: u32,
+) -> (u32, Duration) {
+ let mut rounds = initial_rounds.max(1).min(max_rounds_per_attempt.max(1));
+ let mut measured_rounds = 0_u32;
+ let mut measured_elapsed = Duration::ZERO;
+
+ loop {
+ let started = Instant::now();
+ let _ = run_vdf(seed, rounds);
+ measured_elapsed += started.elapsed();
+ measured_rounds = measured_rounds.saturating_add(rounds);
+
+ if measured_elapsed >= min_elapsed || rounds >= max_rounds_per_attempt {
+ return (measured_rounds, measured_elapsed);
+ }
+ rounds = rounds.saturating_mul(2).min(max_rounds_per_attempt);
+ }
+}
+
+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> {
@@ -366,9 +490,6 @@ async fn run_peer_sync(node: SharedNode, gossip: p2p::GossipNetwork) {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let envelopes = {
let mut node = node.lock().await;
- if !node.chain_started() {
- continue;
- }
let mut envelopes = vec![node.peer_status()];
envelopes.extend(node.drain_outbox());
envelopes.extend(node.mempool_gossip());
@@ -395,9 +516,6 @@ async fn run_chain_persistence_with_interval(
loop {
tokio::time::sleep(interval).await;
let snapshot = { node.lock().await.chain_snapshot() };
- if !snapshot_chain_started(&snapshot) {
- continue;
- }
let Some(tip_hash) = snapshot.blocks.last().map(|block| block.hash.clone()) else {
continue;
};
@@ -434,9 +552,9 @@ mod tests {
use tokio::sync::Mutex;
use super::{
- ChainMode, CliOptions, help_text, initial_burn_fee, initial_burn_per_block,
- initialize_ledger, persist_chain_snapshot, run_chain_persistence_with_interval,
- setup_ledger, snapshot_chain_started,
+ ChainMode, CliOptions, extrapolate_vdf_rounds, help_text, initial_burn_fee,
+ initial_burn_per_block, initialize_ledger, measure_vdf_rounds, persist_chain_snapshot,
+ run_chain_persistence_with_interval, start_genesis_ledger, validate_wallet_for_mode,
};
fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> {
@@ -474,7 +592,7 @@ mod tests {
#[test]
fn removed_wallet_seed_is_rejected() {
- let error = parse(&["--wallet-seed", "alice"]).unwrap_err();
+ let error = parse(&["--wallet-seed", "alice", "--genesis"]).unwrap_err();
assert!(error.to_string().contains("--wallet-seed was removed"));
}
@@ -488,7 +606,7 @@ mod tests {
"--genesis-amount",
"--vdf-rounds",
] {
- let error = parse(&[flag, "value"]).unwrap_err();
+ let error = parse(&["--genesis", flag, "value"]).unwrap_err();
assert!(
error.to_string().contains("unknown argument"),
"{flag} should not be accepted"
@@ -497,9 +615,10 @@ mod tests {
}
#[test]
- fn genesis_flag_is_removed() {
- let error = parse(&["--genesis"]).unwrap_err();
- assert!(error.to_string().contains("unknown argument --genesis"));
+ 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]
@@ -510,28 +629,22 @@ mod tests {
}
#[test]
- fn setup_mode_starts_with_configured_burn_rate() {
+ fn genesis_mode_starts_with_mine_reward_burn_rate_and_zero_fee() {
+ let genesis = parse(&["--genesis"]).unwrap().unwrap();
let configured = UiConfig {
burn_per_block: 50,
burn_fee: 3,
..UiConfig::default()
};
- assert_eq!(initial_burn_per_block(&configured), 50);
- assert_eq!(initial_burn_fee(&configured), 3);
- assert_eq!(
- initial_burn_per_block(&UiConfig::default()),
- DEFAULT_BURN_PER_BLOCK
- );
+ assert_eq!(initial_burn_per_block(&genesis, &configured), MINE_REWARD);
+ assert_eq!(initial_burn_fee(&genesis, &configured), 0);
}
#[test]
- fn node_can_mine_genesis_from_setup_ledger() {
+ fn genesis_ledger_starts_with_single_mine_action() {
let wallet = Wallet::from_seed("genesis-mine-wallet");
- let ledger = setup_ledger();
- let mut node = NodeCore::from_ledger(wallet.clone(), ledger, DEFAULT_BURN_PER_BLOCK);
-
- assert!(!node.chain_started());
- let genesis = node.mine_genesis().unwrap();
+ let ledger = start_genesis_ledger(wallet.address()).unwrap();
+ let genesis = &ledger.chain()[0];
assert_eq!(genesis.height, 0);
assert_eq!(genesis.miner, "genesis");
@@ -539,22 +652,54 @@ mod tests {
assert_eq!(genesis.transactions.len(), 1);
assert!(matches!(genesis.transactions[0], Transaction::Mine { .. }));
assert_eq!(genesis.transactions[0].amount(), MINE_REWARD);
- assert_eq!(node.ledger().balance_of(wallet.address()), MINE_REWARD);
- assert!(node.chain_started());
- assert_eq!(node.status().mining.burn_per_block, MINE_REWARD);
- assert_eq!(node.status().mining.automatic_burn_fee, 0);
+ assert_eq!(ledger.balance_of(wallet.address()), MINE_REWARD);
+ assert_eq!(ledger.expected_leader_for_next_block(), None);
+ }
+
+ #[test]
+ fn non_genesis_modes_start_with_configured_burn_rate() {
+ let configured = UiConfig {
+ burn_per_block: 50,
+ burn_fee: 3,
+ ..UiConfig::default()
+ };
+
+ let setup = parse(&[]).unwrap().unwrap();
+ assert_eq!(initial_burn_per_block(&setup, &configured), 50);
+ assert_eq!(initial_burn_fee(&setup, &configured), 3);
+
+ let join = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap();
+ assert_eq!(initial_burn_per_block(&join, &configured), 50);
+ assert_eq!(initial_burn_fee(&join, &configured), 3);
+
+ assert_eq!(
+ initial_burn_per_block(&setup, &UiConfig::default()),
+ DEFAULT_BURN_PER_BLOCK
+ );
}
#[test]
- fn setup_snapshot_is_not_a_started_chain() {
- let setup = setup_ledger();
- assert!(!setup.is_started());
- assert!(!snapshot_chain_started(&setup.snapshot()));
+ 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(&["--http", "127.0.0.1:18443"]).unwrap().unwrap();
+ let opts = parse(&["--genesis", "--http", "127.0.0.1:18443"])
+ .unwrap()
+ .unwrap();
assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18443");
}
@@ -566,7 +711,9 @@ mod tests {
#[test]
fn wallet_defaults_under_data_dir() {
- let opts = parse(&["--data-dir", "tmp-node"]).unwrap().unwrap();
+ let opts = parse(&["--genesis", "--data-dir", "tmp-node"])
+ .unwrap()
+ .unwrap();
assert_eq!(
opts.wallet_path(),
std::path::PathBuf::from("tmp-node/wallet.json")
@@ -575,7 +722,9 @@ mod tests {
#[test]
fn chain_db_defaults_under_data_dir() {
- let opts = parse(&["--data-dir", "tmp-node"]).unwrap().unwrap();
+ let opts = parse(&["--genesis", "--data-dir", "tmp-node"])
+ .unwrap()
+ .unwrap();
assert_eq!(
opts.chain_db_path(),
std::path::PathBuf::from("tmp-node/chain.sqlite3")
@@ -584,7 +733,9 @@ mod tests {
#[test]
fn config_defaults_under_data_dir() {
- let opts = parse(&["--data-dir", "tmp-node"]).unwrap().unwrap();
+ let opts = parse(&["--genesis", "--data-dir", "tmp-node"])
+ .unwrap()
+ .unwrap();
assert_eq!(
opts.config_path(),
std::path::PathBuf::from("tmp-node/config.json")
@@ -593,7 +744,9 @@ mod tests {
#[test]
fn wallet_path_can_be_explicit() {
- let opts = parse(&["--wallet", "alice-wallet.json"]).unwrap().unwrap();
+ let opts = parse(&["--genesis", "--wallet", "alice-wallet.json"])
+ .unwrap()
+ .unwrap();
assert_eq!(
opts.wallet_path(),
std::path::PathBuf::from("alice-wallet.json")
@@ -602,7 +755,7 @@ mod tests {
#[test]
fn chain_db_path_can_be_explicit() {
- let opts = parse(&["--chain-db", "alice-chain.sqlite3"])
+ let opts = parse(&["--genesis", "--chain-db", "alice-chain.sqlite3"])
.unwrap()
.unwrap();
assert_eq!(
@@ -611,6 +764,66 @@ mod tests {
);
}
+ #[test]
+ fn genesis_requires_fresh_wallet_path() {
+ let opts = parse(&["--genesis"]).unwrap().unwrap();
+ let wallet_path = std::path::Path::new("wallet.json");
+
+ validate_wallet_for_mode(&opts, wallet_path, false).unwrap();
+ let error = validate_wallet_for_mode(&opts, wallet_path, true).unwrap_err();
+ assert!(error.to_string().contains("requires a fresh wallet path"));
+
+ let setup = parse(&[]).unwrap().unwrap();
+ validate_wallet_for_mode(&setup, wallet_path, true).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
+ );
+ }
+
+ #[test]
+ fn vdf_measurement_keeps_sampling_until_elapsed_is_useful() {
+ let min_elapsed = Duration::from_millis(1);
+ let max_rounds = 1_000_000;
+ let (rounds, elapsed) =
+ measure_vdf_rounds("luun-test-vdf-calibration", 1, min_elapsed, max_rounds);
+
+ assert!(rounds >= 1);
+ assert!(elapsed >= min_elapsed || rounds >= max_rounds);
+ assert!(elapsed > Duration::ZERO);
+ }
+
+ #[tokio::test]
+ 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();
+ 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(&["--genesis", "--chain-db", chain_path.to_str().unwrap()])
+ .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();
@@ -624,7 +837,9 @@ mod tests {
.unwrap()
.unwrap();
- let resumed = initialize_ledger(&opts, &store).await.unwrap();
+ let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store)
+ .await
+ .unwrap();
assert_eq!(resumed.status().height, 1);
assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash);
@@ -640,6 +855,7 @@ mod tests {
let alice = Wallet::from_seed("offline-join-alice");
let persisted = ledger_with_one_mined_block(&alice);
store.save(&persisted.snapshot()).unwrap();
+ let bob = Wallet::from_seed("offline-join-bob");
let opts = parse(&[
"--join",
"127.0.0.1:1",
@@ -649,7 +865,9 @@ mod tests {
.unwrap()
.unwrap();
- let resumed = initialize_ledger(&opts, &store).await.unwrap();
+ let resumed = initialize_ledger(&opts, bob.address(), &store)
+ .await
+ .unwrap();
assert_eq!(resumed.status().height, 1);
assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash);
@@ -670,11 +888,14 @@ VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0)
[],
)
.unwrap();
- let opts = parse(&["--chain-db", chain_path.to_str().unwrap()])
+ let wallet = Wallet::from_seed("bad-db-wallet");
+ let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()])
.unwrap()
.unwrap();
- let error = initialize_ledger(&opts, &store).await.unwrap_err();
+ let error = initialize_ledger(&opts, wallet.address(), &store)
+ .await
+ .unwrap_err();
assert!(
format!("{error:#}").contains("failed to parse chain snapshot from database"),