iuna

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

commit bf84c8b537216c43e14f1d0c26f854715cb9abaa
parent 2c1dcedf8fd9f7e3e9f8923ebadcf4f542b7e7f2
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 24 Jul 2026 13:55:27 +0200

Move genesis creation into chain UI

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, 329 insertions(+), 336 deletions(-)

diff --git a/assets/luun-ui.js b/assets/luun-ui.js @@ -481,6 +481,18 @@ 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, OutPoint, Transaction, TxInput, TxOutput, hex_hash}, + domain::{Amount, Block, MINE_REWARD, OutPoint, Transaction, TxInput, TxOutput, hex_hash}, }; const EXPLORER_LIMIT: usize = 50; @@ -189,6 +189,7 @@ 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)) @@ -356,6 +357,11 @@ 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>, @@ -814,6 +820,24 @@ 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() { @@ -1290,6 +1314,15 @@ 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, - OutPoint, PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + MINE_REWARD, OutPoint, PreparedBlock, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -218,6 +218,10 @@ 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) } @@ -329,9 +333,10 @@ 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 = current_leader - .as_deref() - .is_none_or(|leader| leader == self.wallet.address()); + let wallet_is_current_leader = chain.started + && current_leader + .as_deref() + .is_none_or(|leader| leader == self.wallet.address()); NodeStatus { wallet_address: self.wallet.address().to_string(), @@ -457,6 +462,21 @@ 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 { @@ -501,6 +521,11 @@ 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) => { @@ -640,6 +665,13 @@ 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: BurnTicket, + leader_ticket: Option<BurnTicket>, transactions: Vec<Transaction>, } @@ -695,14 +695,17 @@ impl PreparedBlock { } pub fn finish(self, wallet: &Wallet, vdf_output: String) -> Block { - 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(), - }; + 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) + }); Block::new(BlockDraft { height: self.height, prev_hash: self.prev_hash, @@ -711,7 +714,7 @@ impl PreparedBlock { reward: self.reward, vdf_rounds: self.vdf_rounds, vdf_output, - leader_proof: Some(wallet.leader_proof(&proof_payload)), + leader_proof, transactions: self.transactions, }) } @@ -732,6 +735,7 @@ 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>, @@ -1176,6 +1180,7 @@ 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(), @@ -1190,6 +1195,12 @@ 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 } @@ -1457,19 +1468,25 @@ impl Ledger { pub fn prepare_next_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> { let height = self.tip().height + 1; - 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 { + 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; bail!("wallet {miner} is not the selected leader; expected {leader}"); } - } else { - bail!("no selected leader for block {height}"); - } + Some(leader_ticket) + }; - let transactions = self.select_block_transactions()?; + let transactions = self.select_block_transactions(is_bootstrap.then_some(miner))?; 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(); @@ -1527,9 +1544,24 @@ impl Ledger { bail!("block reward is invalid"); } let mut tickets = self.tickets.clone(); - consume_leader_ticket(&block, &mut tickets)?; + 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)?; + } credit_reward_output(&mut utxos, &block)?; - tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?); + 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 + }, + )?); let mined_signatures = block .transactions @@ -1592,6 +1624,13 @@ 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); }; @@ -1673,14 +1712,21 @@ impl Ledger { valid } - fn select_block_transactions(&self) -> Result<Vec<Transaction>> { + fn select_block_transactions( + &self, + required_burn_owner: Option<&str>, + ) -> Result<Vec<Transaction>> { let mut utxos = self.utxos.clone(); let mut remaining = self.valid_pending_transactions(); let mut selected = Vec::new(); - if let Some(index) = - best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) - { + 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 { let tx = remaining.remove(index); let mut candidate = selected.clone(); candidate.push(tx.clone()); @@ -1807,6 +1853,19 @@ 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() @@ -1855,6 +1914,14 @@ 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"); } @@ -1877,7 +1944,7 @@ fn tickets_created_by_block(block: &Block, profile: &LaunchProfile) -> Result<Ve } let target_height = block .height - .checked_add(profile.ticket_maturity_delay_heights) + .checked_add(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) @@ -1993,6 +2060,16 @@ 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 { @@ -2027,6 +2104,36 @@ 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()) @@ -2849,6 +2956,36 @@ 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,26 +1,15 @@ use std::{ - collections::BTreeMap, - net::SocketAddr, - path::{Path, PathBuf}, - str::FromStr, - sync::Arc, - time::{Duration, Instant}, + collections::BTreeMap, net::SocketAddr, path::PathBuf, str::FromStr, sync::Arc, time::Duration, }; use anyhow::{Context, Result, bail}; use luun::{ adapters::{chain_store::SqliteChainStore, config_store, http, p2p, wallet_store}, - app::{NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms}, - domain::{Amount, ChainSnapshot, Ledger, MICRO_LUUN, MINE_REWARD, run_vdf}, + app::{DEFAULT_VDF_ROUNDS, NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms}, + domain::{Amount, ChainSnapshot, Ledger, MICRO_LUUN, 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 { @@ -28,26 +17,14 @@ 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 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 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 node: SharedNode = Arc::new(Mutex::new(NodeCore::from_ledger_with_burn_fee( wallet, @@ -61,7 +38,9 @@ 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() }; - persist_chain_snapshot(&chain_store, initial_snapshot).await?; + if snapshot_chain_started(&initial_snapshot) { + persist_chain_snapshot(&chain_store, initial_snapshot).await?; + } } println!("luun wallet: {}", node.lock().await.wallet_address()); @@ -79,27 +58,23 @@ async fn main() -> Result<()> { let gossip = p2p::GossipNetwork::start(Arc::clone(&node), Arc::clone(&peers), opts.p2p_addr).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"); - } + 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; + }); http::serve( node, @@ -127,18 +102,8 @@ fn format_luun(amount: Amount) -> String { } } -async fn initialize_ledger( - opts: &CliOptions, - wallet_address: &str, - chain_store: &SqliteChainStore, -) -> Result<Ledger> { +async fn initialize_ledger(opts: &CliOptions, 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!( @@ -154,7 +119,6 @@ async fn initialize_ledger( } 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, } } @@ -163,7 +127,6 @@ async fn initialize_ledger( #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ChainMode { Setup, - Genesis, Join, } @@ -200,12 +163,6 @@ 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")?)) } @@ -228,9 +185,6 @@ 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()); @@ -245,10 +199,6 @@ impl CliOptions { } } - if opts.chain_mode == ChainMode::Genesis && !opts.join_peers.is_empty() { - bail!("choose either --genesis or --join, not both"); - } - Ok(Some(opts)) } @@ -267,10 +217,6 @@ 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> { @@ -278,32 +224,12 @@ 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 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_per_block(ui_config: &config_store::UiConfig) -> Amount { + ui_config.burn_per_block } -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 initial_burn_fee(ui_config: &config_store::UiConfig) -> Amount { + ui_config.burn_fee } fn print_help() { @@ -314,10 +240,8 @@ 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\ @@ -336,62 +260,14 @@ fn snapshot_height(snapshot: &ChainSnapshot) -> u64 { .unwrap_or(0) } -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(); - 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 snapshot_chain_started(snapshot: &ChainSnapshot) -> bool { + snapshot.blocks.first().is_some_and(|genesis| { + !snapshot.genesis_allocations.is_empty() || !genesis.transactions.is_empty() + }) } -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 +fn setup_ledger() -> Ledger { + Ledger::new(BTreeMap::new(), DEFAULT_VDF_ROUNDS) } async fn join_chain_ledger(join_peers: &[String], advertised_addr: SocketAddr) -> Result<Ledger> { @@ -490,6 +366,9 @@ 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()); @@ -516,6 +395,9 @@ 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; }; @@ -552,9 +434,9 @@ mod tests { use tokio::sync::Mutex; use super::{ - 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, + 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, }; fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { @@ -592,7 +474,7 @@ mod tests { #[test] fn removed_wallet_seed_is_rejected() { - let error = parse(&["--wallet-seed", "alice", "--genesis"]).unwrap_err(); + let error = parse(&["--wallet-seed", "alice"]).unwrap_err(); assert!(error.to_string().contains("--wallet-seed was removed")); } @@ -606,7 +488,7 @@ mod tests { "--genesis-amount", "--vdf-rounds", ] { - let error = parse(&["--genesis", flag, "value"]).unwrap_err(); + let error = parse(&[flag, "value"]).unwrap_err(); assert!( error.to_string().contains("unknown argument"), "{flag} should not be accepted" @@ -615,10 +497,9 @@ mod tests { } #[test] - fn genesis_mode_is_explicit() { - let opts = parse(&["--genesis"]).unwrap().unwrap(); - assert_eq!(opts.chain_mode, ChainMode::Genesis); - assert!(opts.join_peers.is_empty()); + fn genesis_flag_is_removed() { + let error = parse(&["--genesis"]).unwrap_err(); + assert!(error.to_string().contains("unknown argument --genesis")); } #[test] @@ -629,22 +510,28 @@ mod tests { } #[test] - fn genesis_mode_starts_with_mine_reward_burn_rate_and_zero_fee() { - let genesis = parse(&["--genesis"]).unwrap().unwrap(); + fn setup_mode_starts_with_configured_burn_rate() { let configured = UiConfig { burn_per_block: 50, burn_fee: 3, ..UiConfig::default() }; - assert_eq!(initial_burn_per_block(&genesis, &configured), MINE_REWARD); - assert_eq!(initial_burn_fee(&genesis, &configured), 0); + 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 + ); } #[test] - fn genesis_ledger_starts_with_single_mine_action() { + fn node_can_mine_genesis_from_setup_ledger() { let wallet = Wallet::from_seed("genesis-mine-wallet"); - let ledger = start_genesis_ledger(wallet.address()).unwrap(); - let genesis = &ledger.chain()[0]; + 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(); assert_eq!(genesis.height, 0); assert_eq!(genesis.miner, "genesis"); @@ -652,54 +539,22 @@ 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!(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 - ); + 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); } #[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") - ); + fn setup_snapshot_is_not_a_started_chain() { + let setup = setup_ledger(); + assert!(!setup.is_started()); + assert!(!snapshot_chain_started(&setup.snapshot())); } #[test] fn http_management_port_can_be_configured() { - let opts = parse(&["--genesis", "--http", "127.0.0.1:18443"]) - .unwrap() - .unwrap(); + let opts = parse(&["--http", "127.0.0.1:18443"]).unwrap().unwrap(); assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18443"); } @@ -711,9 +566,7 @@ mod tests { #[test] fn wallet_defaults_under_data_dir() { - let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) - .unwrap() - .unwrap(); + let opts = parse(&["--data-dir", "tmp-node"]).unwrap().unwrap(); assert_eq!( opts.wallet_path(), std::path::PathBuf::from("tmp-node/wallet.json") @@ -722,9 +575,7 @@ mod tests { #[test] fn chain_db_defaults_under_data_dir() { - let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) - .unwrap() - .unwrap(); + let opts = parse(&["--data-dir", "tmp-node"]).unwrap().unwrap(); assert_eq!( opts.chain_db_path(), std::path::PathBuf::from("tmp-node/chain.sqlite3") @@ -733,9 +584,7 @@ mod tests { #[test] fn config_defaults_under_data_dir() { - let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) - .unwrap() - .unwrap(); + let opts = parse(&["--data-dir", "tmp-node"]).unwrap().unwrap(); assert_eq!( opts.config_path(), std::path::PathBuf::from("tmp-node/config.json") @@ -744,9 +593,7 @@ mod tests { #[test] fn wallet_path_can_be_explicit() { - let opts = parse(&["--genesis", "--wallet", "alice-wallet.json"]) - .unwrap() - .unwrap(); + let opts = parse(&["--wallet", "alice-wallet.json"]).unwrap().unwrap(); assert_eq!( opts.wallet_path(), std::path::PathBuf::from("alice-wallet.json") @@ -755,7 +602,7 @@ mod tests { #[test] fn chain_db_path_can_be_explicit() { - let opts = parse(&["--genesis", "--chain-db", "alice-chain.sqlite3"]) + let opts = parse(&["--chain-db", "alice-chain.sqlite3"]) .unwrap() .unwrap(); assert_eq!( @@ -764,66 +611,6 @@ 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(); @@ -837,9 +624,7 @@ mod tests { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store) - .await - .unwrap(); + let resumed = initialize_ledger(&opts, &store).await.unwrap(); assert_eq!(resumed.status().height, 1); assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash); @@ -855,7 +640,6 @@ 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", @@ -865,9 +649,7 @@ mod tests { .unwrap() .unwrap(); - let resumed = initialize_ledger(&opts, bob.address(), &store) - .await - .unwrap(); + let resumed = initialize_ledger(&opts, &store).await.unwrap(); assert_eq!(resumed.status().height, 1); assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash); @@ -888,14 +670,11 @@ VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0) [], ) .unwrap(); - let wallet = Wallet::from_seed("bad-db-wallet"); - let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()]) + let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) .unwrap() .unwrap(); - let error = initialize_ledger(&opts, wallet.address(), &store) - .await - .unwrap_err(); + let error = initialize_ledger(&opts, &store).await.unwrap_err(); assert!( format!("{error:#}").contains("failed to parse chain snapshot from database"),