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:
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"),