iuna

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

commit afa0a773558f0ce4821c46f7371e56326527f24e
parent b98f76e7a1acb6659dfdd5bf1fd543cacf0cf26b
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 22 Jul 2026 14:20:52 +0200

Reward genesis starter and show block miners


Diffstat:
MREADME.md | 11+++++------
Massets/mivora-ui.js | 5+++++
Msrc/adapters/http.rs | 6+++---
Msrc/domain.rs | 46++++++++++++++++++++++++++++++++++++++++------
Msrc/main.rs | 42+++++++++++++++++++++++++++++++++---------
Mtests/coin.rs | 8+++++---
6 files changed, 91 insertions(+), 27 deletions(-)

diff --git a/README.md b/README.md @@ -24,7 +24,7 @@ cargo run -- --genesis --http 127.0.0.1:18661 --p2p 127.0.0.1:9444 Mining is automatic. There is no "mine block" button and no exact sleep. Each node can burn its configured amount once per chain height. Those burns become one-shot leader tickets for a future height after the launch profile's maturity delay. Only the selected ticket owner builds the next block, signs a leader proof, performs the VDF work, and gossips the finished block. The VDF is the clock. -Genesis leaves the starter wallet with 1 spendable coin after the 1-coin bootstrap burn creates the first leader ticket. Because non-genesis blocks must include at least one burn, the default 0-coin burn rate will wait instead of starting VDF work until the burn rate is raised in the Configuration tab. +Genesis leaves the starter wallet with 100 spendable coins after the 1-coin bootstrap burn creates the first leader ticket and the genesis block pays its 100-coin reward. `--genesis` starts the automatic burn rate at 100 coins per block, so the starter can immediately create the block 1 burn ticket. The management UI is a small AlpineJS app served from local vendored assets. It polls JSON endpoints every few seconds and includes: @@ -63,15 +63,14 @@ cargo run -- --p2p 0.0.0.0:9444 --http 127.0.0.1:18661 cargo run -- --genesis --p2p 0.0.0.0:9444 --http 127.0.0.1:18661 ``` -3. Open the Configuration tab and set the starter burn rate so block 1 has a burn. -4. Give friends your public `host:9444`. -5. Friends join your chain: +3. Give friends your public `host:9444`. +4. Friends join your chain: ```sh cargo run -- --data-dir .mivora-friend --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host:9444 ``` -Friends who join after you start will adopt your genesis and current chain. The starter wallet begins with 1 spendable coin after the bootstrap burn, which can be configured as the block 1 burn from the UI, creating the ticket for block 2. After the starter mines the first block reward, send friends coins from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-coin automatic burn unless it is configured otherwise. +Friends who join after you start will adopt your genesis and current chain. The starter wallet begins with 100 spendable coins after the bootstrap burn and genesis reward, and `--genesis` starts it with a 100-coin automatic burn rate. After the starter mines additional block rewards, send friends coins from the UI; then they can choose a burn amount and compete for future blocks. Every joining node starts with a 0-coin automatic burn unless it is configured otherwise. The genesis block bootstraps the chain with a 1-coin burn from the starter wallet. Burns included in a block create one-shot tickets for a future height through a deterministic ticket lottery. The selected leader creates the next block content, signs a proof for the selected ticket, and runs a hash-chain VDF before gossiping the block. @@ -79,7 +78,7 @@ Every non-genesis block must consume the selected mature ticket and include at l The protocol targets 60-second blocks by retargeting the expected VDF rounds after each block. It uses a rolling average of recent block intervals and only moves the next round count by about 10% per block, so short bursts do not make the delay swing wildly. Every node derives the same next-round count from the validated chain. -The block reward is fixed at 100 coins. The default burn is 0 coins per block, so new nodes can join before they own coins. After a wallet has coins, raise the burn from the Configuration screen. +The block reward is fixed at 100 coins. The default burn is 0 coins per block, so new nodes can join before they own coins. Genesis starters begin at 100 coins per block; after another wallet has coins, raise its burn from the Configuration screen. The measured VDF round count is only the initial delay. After the first blocks, the protocol steers rounds toward the 60-second target. diff --git a/assets/mivora-ui.js b/assets/mivora-ui.js @@ -487,6 +487,11 @@ window.mivoraApp = function mivoraApp() { return `${count} transfer${count === 1 ? "" : "s"}`; }, + blockMinerLabel(block) { + const miner = this.short(block.miner); + return block.miner === this.status.wallet_address ? `${miner} (me)` : miner; + }, + walletTransactions() { const wallet = this.status.wallet_address; if (!wallet) return []; diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -501,7 +501,7 @@ const INDEX_HTML: &str = r#"<!doctype html> @keyframes block-arrive { from { opacity: .2; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } } .block-height { font-size: 18px; font-weight: 900; } .block-meta { display: flex; gap: 8px; color: #8d989f; font-size: 12px; } - .block-hash { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; color: #9eb3bc; } + .block-miner { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; color: #9eb3bc; } .skeleton-card { pointer-events: none; position: relative; overflow: hidden; } .skeleton-card::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(213, 245, 95, .12), transparent); animation: skeleton-sweep 1.15s ease-in-out infinite; } @keyframes skeleton-sweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } } @@ -536,7 +536,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/mivora-ui.js?v=21"></script> + <script defer src="/assets/mivora-ui.js?v=22"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="mivoraApp()" x-init="init()" x-cloak> @@ -694,7 +694,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <span x-text="burnCountLabel(block)"></span> <span x-text="transferCountLabel(block)"></span> </div> - <div class="block-hash" x-text="short(block.hash)"></div> + <div class="block-miner" x-text="blockMinerLabel(block)"></div> </button> </template> <template x-if="loadingOlder"> diff --git a/src/domain.rs b/src/domain.rs @@ -1416,6 +1416,8 @@ fn build_genesis_block( genesis_allocations: &BTreeMap<String, Amount>, transactions: Vec<Transaction>, ) -> Block { + let miner = genesis_miner(genesis_allocations, &transactions); + let reward = genesis_reward(genesis_allocations, &transactions); let txs = transactions .iter() .map(Transaction::canonical) @@ -1426,8 +1428,8 @@ fn build_genesis_block( height: 0, prev_hash: "0".repeat(64), timestamp_ms: 0, - miner: "genesis".to_string(), - reward: 0, + miner, + reward, vdf_rounds: 0, vdf_output, leader_proof: None, @@ -1448,11 +1450,11 @@ fn validate_genesis_block(block: &Block) -> Result<()> { if block.timestamp_ms != 0 { bail!("genesis block timestamp must be 0"); } - if block.miner != "genesis" { - bail!("genesis block miner must be genesis"); + if block.miner == "genesis" && block.reward != 0 { + bail!("genesis placeholder miner must not receive a reward"); } - if block.reward != 0 { - bail!("genesis block reward must be 0"); + if block.miner != "genesis" && block.reward != 0 && block.reward != BLOCK_REWARD { + bail!("genesis block reward is invalid"); } if block.vdf_rounds != 0 { bail!("genesis block VDF rounds must be 0"); @@ -1466,6 +1468,33 @@ fn validate_genesis_block(block: &Block) -> Result<()> { Ok(()) } +fn genesis_miner( + genesis_allocations: &BTreeMap<String, Amount>, + transactions: &[Transaction], +) -> String { + transactions + .iter() + .filter_map(|transaction| match transaction { + Transaction::Burn { from, .. } => Some(from.as_str()), + Transaction::Transfer { .. } => None, + }) + .find(|from| genesis_allocations.contains_key(*from)) + .or_else(|| genesis_allocations.keys().next().map(String::as_str)) + .unwrap_or("genesis") + .to_string() +} + +fn genesis_reward( + genesis_allocations: &BTreeMap<String, Amount>, + transactions: &[Transaction], +) -> Amount { + if genesis_allocations.is_empty() || transactions.is_empty() { + 0 + } else { + BLOCK_REWARD + } +} + fn balances_after_genesis( genesis_allocations: &BTreeMap<String, Amount>, transactions: &[Transaction], @@ -1483,6 +1512,11 @@ fn balances_after_genesis( Transaction::Transfer { .. } => bail!("genesis only supports burn transactions"), } } + let reward = genesis_reward(genesis_allocations, transactions); + if reward > 0 { + let miner = genesis_miner(genesis_allocations, transactions); + credit_balance(&mut balances, &miner, reward)?; + } Ok(balances) } diff --git a/src/main.rs b/src/main.rs @@ -11,11 +11,12 @@ use anyhow::{Context, Result, bail}; use mivora::{ adapters::{chain_store::SqliteChainStore, config_store, http, p2p, wallet_store}, app::{DEFAULT_BURN_PER_BLOCK, NodeCore, PeerBook, SharedNode, SharedPeerBook, now_ms}, - domain::{ChainSnapshot, GenesisBurn, Ledger, run_vdf}, + domain::{Amount, ChainSnapshot, GenesisBurn, Ledger, run_vdf}, }; use tokio::sync::Mutex; -const GENESIS_SPENDABLE_AMOUNT: u64 = 1; +const GENESIS_BOOTSTRAP_BURN_AMOUNT: u64 = 1; +const GENESIS_INITIAL_BURN_PER_BLOCK: Amount = 100; 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); @@ -35,11 +36,12 @@ async fn main() -> Result<()> { let persisted_chain_exists = chain_store.load()?.is_some(); let ledger = initialize_ledger(&opts, wallet.address(), &chain_store).await?; let has_chain = opts.has_chain() || persisted_chain_exists; + let initial_burn_per_block = initial_burn_per_block(&opts); let node: SharedNode = Arc::new(Mutex::new(NodeCore::from_ledger( wallet, ledger, - DEFAULT_BURN_PER_BLOCK, + initial_burn_per_block, ))); let ui_config = Arc::new(Mutex::new(ui_config)); let mut peers = ui_config.lock().await.peers.clone(); @@ -58,7 +60,7 @@ async fn main() -> Result<()> { println!("p2p listener: {}", opts.p2p_addr); println!( "automatic mining: VDF-driven, burning {} coins per block", - DEFAULT_BURN_PER_BLOCK + initial_burn_per_block ); let gossip = @@ -257,6 +259,13 @@ fn validate_wallet_for_mode( Ok(()) } +fn initial_burn_per_block(opts: &CliOptions) -> Amount { + match opts.chain_mode { + ChainMode::Genesis => GENESIS_INITIAL_BURN_PER_BLOCK, + ChainMode::Setup | ChainMode::Join => DEFAULT_BURN_PER_BLOCK, + } +} + fn print_help() { println!("{}", help_text()); } @@ -294,10 +303,13 @@ fn setup_ledger() -> Ledger { fn start_genesis_ledger(wallet_address: &str) -> Result<Ledger> { let vdf_rounds = measure_initial_vdf_rounds(); let mut genesis = BTreeMap::new(); - genesis.insert(wallet_address.to_string(), GENESIS_SPENDABLE_AMOUNT + 1); + genesis.insert(wallet_address.to_string(), GENESIS_BOOTSTRAP_BURN_AMOUNT); Ledger::new_with_genesis_burns( genesis, - vec![GenesisBurn::new(wallet_address, 1)], + vec![GenesisBurn::new( + wallet_address, + GENESIS_BOOTSTRAP_BURN_AMOUNT, + )], vdf_rounds, ) } @@ -509,9 +521,9 @@ mod tests { use tokio::sync::Mutex; use super::{ - ChainMode, CliOptions, extrapolate_vdf_rounds, help_text, initialize_ledger, - measure_vdf_rounds, persist_chain_snapshot, run_chain_persistence_with_interval, - validate_wallet_for_mode, + ChainMode, CliOptions, extrapolate_vdf_rounds, help_text, initial_burn_per_block, + initialize_ledger, measure_vdf_rounds, persist_chain_snapshot, + run_chain_persistence_with_interval, validate_wallet_for_mode, }; fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { @@ -587,6 +599,18 @@ mod tests { } #[test] + fn genesis_mode_starts_with_full_reward_burn_rate() { + let genesis = parse(&["--genesis"]).unwrap().unwrap(); + assert_eq!(initial_burn_per_block(&genesis), 100); + + let setup = parse(&[]).unwrap().unwrap(); + assert_eq!(initial_burn_per_block(&setup), DEFAULT_BURN_PER_BLOCK); + + let join = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap(); + assert_eq!(initial_burn_per_block(&join), DEFAULT_BURN_PER_BLOCK); + } + + #[test] fn genesis_and_join_are_exclusive() { let error = parse(&["--genesis", "--join", "127.0.0.1:9444"]).unwrap_err(); assert!( diff --git a/tests/coin.rs b/tests/coin.rs @@ -81,13 +81,15 @@ fn starter_node(wallet: Wallet) -> NodeCore { } #[test] -fn genesis_burn_starts_chain_with_zero_balance_and_first_leader() { +fn genesis_burn_starts_chain_with_reward_and_first_leader() { let alice = Wallet::from_seed("alice"); let node = starter_node(alice.clone()); let genesis = &node.ledger().chain()[0]; - assert_eq!(node.ledger().balance_of(alice.address()), 0); + assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD); assert_eq!(genesis.height, 0); + assert_eq!(genesis.miner, alice.address()); + assert_eq!(genesis.reward, BLOCK_REWARD); assert_eq!(genesis.transactions.len(), 1); assert!(genesis.transactions[0].is_burn()); assert_eq!(genesis.transactions[0].amount(), 1); @@ -112,7 +114,7 @@ fn starter_node_waits_for_a_burn_before_vdf_work() { .is_some_and(|reason| reason.contains("at least one burn")) ); assert_eq!(node.ledger().status().height, 0); - assert_eq!(node.ledger().balance_of(alice.address()), 0); + assert_eq!(node.ledger().balance_of(alice.address()), BLOCK_REWARD); } #[test]