iuna

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

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:
Massets/luun-ui.js | 12------------
Msrc/adapters/http.rs | 35+----------------------------------
Msrc/app.rs | 40++++------------------------------------
Msrc/domain.rs | 189+++++++++++--------------------------------------------------------------------
Msrc/main.rs | 389++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
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"),