iuna

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

commit 6b8bdc1bc5accc636238c7609326eb53631780f7
parent cef24ab982068d129428a00c46d102b597852421
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sat,  1 Aug 2026 23:13:37 +0200

Add recovery finalization path

Diffstat:
Msrc/adapters/http.rs | 9++++++---
Msrc/adapters/p2p.rs | 1+
Msrc/app.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/domain.rs | 268++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mtests/iuna.rs | 172++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mwww/assets/iuna-ui.js | 3++-
6 files changed, 484 insertions(+), 53 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -352,6 +352,7 @@ struct UiBlock { prev_hash: String, timestamp_ms: u64, miner: String, + finalizer_mode: crate::domain::FinalizerMode, finalizer_rank: u32, reward: Amount, total_fees: Amount, @@ -1684,6 +1685,7 @@ fn ui_block(block: Block, outputs: &BTreeMap<OutPoint, TxOutput>) -> UiBlock { prev_hash: block.prev_hash, timestamp_ms: block.timestamp_ms, miner: block.miner, + finalizer_mode: block.finalizer_mode, finalizer_rank: block.finalizer_rank, reward: block.reward, total_fees: block.reward, @@ -2838,7 +2840,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=69"></script> + <script defer src="/assets/iuna-ui.js?v=70"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -3270,7 +3272,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="detail-kv"><div class="key">Hash</div><code x-text="selectedBlock.hash"></code></div> <div class="detail-kv"><div class="key">Previous</div><code x-text="short(selectedBlock.prev_hash)"></code></div> <div class="detail-kv"><div class="key">Finalizer</div><code x-text="short(selectedBlock.miner)"></code></div> - <div class="detail-kv"><div class="key">Rank</div><div x-text="selectedBlock.finalizer_rank ?? 0"></div></div> + <div class="detail-kv"><div class="key">Mode</div><div x-text="selectedBlock.finalizer_mode === 'recovery' ? 'Recovery' : `Rank ${selectedBlock.finalizer_rank ?? 0}`"></div></div> <div class="detail-kv"><div class="key">Reward</div><div>IUNA <span x-text="amountLabel(selectedBlock.reward)"></span></div></div> <div class="detail-kv"><div class="key">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div> <div class="detail-kv"><div class="key">Transfers</div><div x-text="blockTransferCount(selectedBlock)"></div></div> @@ -4624,6 +4626,7 @@ mod tests { prev_hash: format!("prev-{height}"), timestamp_ms: height, miner: "miner".to_string(), + finalizer_mode: crate::domain::FinalizerMode::Ticket, finalizer_rank: 0, reward: 100, vdf_rounds: 0, @@ -4702,7 +4705,7 @@ mod tests { #[test] fn metrics_screen_includes_block_range_filter() { - assert!(super::INDEX_HTML.contains("iuna-ui.js?v=69")); + assert!(super::INDEX_HTML.contains("iuna-ui.js?v=70")); assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\"")); assert!(super::INDEX_HTML.contains("setMetricsRange(100)")); assert!(super::INDEX_HTML.contains("setMetricsRange(1000)")); diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -3180,6 +3180,7 @@ mod tests { prev_hash: "prev".to_string(), timestamp_ms: 1, miner: "miner".to_string(), + finalizer_mode: crate::domain::FinalizerMode::Ticket, finalizer_rank: 0, reward: 100, vdf_rounds: 1, diff --git a/src/app.rs b/src/app.rs @@ -958,10 +958,24 @@ impl NodeCore { .ledger .finalizer_rank_for_next_block(self.wallet.address()); if wallet_rank.is_none() { - let selected_leader = self.ledger.expected_leader_for_next_block(); - plan.skipped_reason = selected_leader.map(|leader| { - format!("wallet is waiting for selected finalizer {leader} to finish the VDF") - }); + if self.ledger.recovery_block_available_at(timestamp_ms) { + match self + .ledger + .prepare_recovery_block(self.wallet.address(), timestamp_ms) + { + Ok(work) => { + plan.work = Some(work); + } + Err(error) => { + plan.skipped_reason = Some(format!("{error:#}")); + } + } + } else { + let selected_leader = self.ledger.expected_leader_for_next_block(); + plan.skipped_reason = selected_leader.map(|leader| { + format!("wallet is waiting for selected finalizer {leader} to finish the VDF") + }); + } return plan; } @@ -1027,10 +1041,24 @@ impl NodeCore { .ledger .finalizer_rank_for_next_block(self.wallet.address()); if wallet_rank.is_none() { - let selected_leader = self.ledger.expected_leader_for_next_block(); - plan.skipped_reason = selected_leader.map(|leader| { - format!("wallet is waiting for selected finalizer {leader} to finish the VDF") - }); + if self.ledger.recovery_block_available_at(timestamp_ms) { + match self + .ledger + .prepare_recovery_block(self.wallet.address(), timestamp_ms) + { + Ok(work) => { + plan.work = Some(work); + } + Err(error) => { + plan.skipped_reason = Some(format!("{error:#}")); + } + } + } else { + let selected_leader = self.ledger.expected_leader_for_next_block(); + plan.skipped_reason = selected_leader.map(|leader| { + format!("wallet is waiting for selected finalizer {leader} to finish the VDF") + }); + } return plan; } @@ -1891,8 +1919,8 @@ mod tests { use std::collections::BTreeMap; use crate::domain::{ - DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, - Transaction, Wallet, + DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, + MINE_FINALIZER_FEE, RECOVERY_BLOCK_DELAY_MS, Transaction, Wallet, }; use super::{NodeConfig, NodeCore}; @@ -2035,6 +2063,42 @@ mod tests { } #[test] + fn automatic_finalization_prepares_recovery_after_ticket_timeout() { + let alice = Wallet::from_seed("automatic-recovery-alice"); + let bob = Wallet::from_seed("automatic-recovery-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(alice.address(), 1)], + 10, + ) + .unwrap(); + let mut node = NodeCore::from_ledger(bob, ledger, 1); + + let early = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS - 1); + assert!(early.work.is_none()); + assert!( + early + .skipped_reason + .as_deref() + .unwrap_or_default() + .contains("waiting for selected finalizer") + ); + + let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); + let work = recovery.work.expect("recovery work should be prepared"); + let block = work.finish( + node.wallet.unlocked().unwrap(), + "preverified-vdf".to_string(), + ); + + assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); + assert!(block.leader_proof.is_none()); + } + + #[test] fn automatic_pow_mining_uses_protocol_finalizer_fee() { let wallet = Wallet::from_seed("automatic-pow-mining-fee-wallet"); let mut node = NodeCore::new(NodeConfig { diff --git a/src/domain.rs b/src/domain.rs @@ -20,6 +20,7 @@ pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_IUNA; pub const DEFAULT_FEE_PER_BYTE: Amount = 1; pub const MAX_BLOCK_BYTES: usize = 100_000; pub const VDF_TARGET_BLOCK_MS: u64 = 10 * 60 * 1_000; +pub const RECOVERY_BLOCK_DELAY_MS: u64 = VDF_TARGET_BLOCK_MS * 6; pub const MAX_VDF_ROUNDS: u64 = i64::MAX as u64; pub const MINE_DIFFICULTY_BITS: u32 = 12; const MINE_RETARGET_WINDOW_BLOCKS: u64 = 10; @@ -783,6 +784,8 @@ pub struct Block { pub timestamp_ms: u64, pub miner: String, #[serde(default)] + pub finalizer_mode: FinalizerMode, + #[serde(default)] pub finalizer_rank: u32, pub reward: Amount, pub vdf_rounds: u64, @@ -792,6 +795,14 @@ pub struct Block { pub hash: String, } +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FinalizerMode { + #[default] + Ticket, + Recovery, +} + impl Block { fn new(draft: BlockDraft) -> Self { let mut block = Self { @@ -799,6 +810,7 @@ impl Block { prev_hash: draft.prev_hash, timestamp_ms: draft.timestamp_ms, miner: draft.miner, + finalizer_mode: draft.finalizer_mode, finalizer_rank: draft.finalizer_rank, reward: draft.reward, vdf_rounds: draft.vdf_rounds, @@ -821,7 +833,12 @@ impl Block { } pub fn vdf_seed(&self) -> String { - vdf_seed_for_child(&self.prev_hash, self.height) + match self.finalizer_mode { + FinalizerMode::Ticket => vdf_seed_for_child(&self.prev_hash, self.height), + FinalizerMode::Recovery => { + recovery_vdf_seed_for_child(&self.prev_hash, self.height, self.timestamp_ms) + } + } } fn content_hash(&self) -> String { @@ -849,7 +866,18 @@ impl Block { } fn legacy_content_hash_prefix(&self, leader_proof: &str) -> String { - if self.finalizer_rank == 0 { + if self.finalizer_mode == FinalizerMode::Recovery { + format!( + "block-content-recovery-v1:{}:{}:{}:{}:{}:{}:{}", + self.height, + self.prev_hash, + self.timestamp_ms, + self.miner, + self.reward, + self.vdf_rounds, + leader_proof + ) + } else if self.finalizer_rank == 0 { format!( "block-content:{}:{}:{}:{}:{}:{}:{}", self.height, @@ -877,6 +905,7 @@ impl Block { fn leader_score(&self) -> LeaderScore { LeaderScore { + finalizer_mode_rank: self.finalizer_mode.fork_choice_rank(), finalizer_rank: self.finalizer_rank, proof_rank: self .leader_proof @@ -893,6 +922,15 @@ impl Block { } } +impl FinalizerMode { + fn fork_choice_rank(self) -> u8 { + match self { + Self::Ticket => 0, + Self::Recovery => 1, + } + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct LeaderProof { pub ticket_id: String, @@ -962,11 +1000,12 @@ pub struct PreparedBlock { prev_hash: String, timestamp_ms: u64, miner: String, + finalizer_mode: FinalizerMode, finalizer_rank: u32, reward: Amount, vdf_rounds: u64, vdf_seed: String, - leader_ticket: BurnTicket, + leader_ticket: Option<BurnTicket>, transactions: Vec<Transaction>, } @@ -984,25 +1023,29 @@ 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(), - finalizer_rank: self.finalizer_rank, - 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(), + finalizer_rank: self.finalizer_rank, + 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, timestamp_ms: self.timestamp_ms, miner: self.miner, + finalizer_mode: self.finalizer_mode, finalizer_rank: self.finalizer_rank, reward: self.reward, vdf_rounds: self.vdf_rounds, vdf_output, - leader_proof: Some(wallet.leader_proof(&proof_payload)), + leader_proof, transactions: self.transactions, }) } @@ -1014,6 +1057,7 @@ struct BlockDraft { prev_hash: String, timestamp_ms: u64, miner: String, + finalizer_mode: FinalizerMode, finalizer_rank: u32, reward: Amount, vdf_rounds: u64, @@ -1125,14 +1169,16 @@ impl ForkPoint { #[derive(Clone, Debug, Eq, PartialEq)] struct LeaderScore { + finalizer_mode_rank: u8, finalizer_rank: u32, proof_rank: String, } impl Ord for LeaderScore { fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.finalizer_rank - .cmp(&other.finalizer_rank) + self.finalizer_mode_rank + .cmp(&other.finalizer_mode_rank) + .then_with(|| self.finalizer_rank.cmp(&other.finalizer_rank)) .then_with(|| self.proof_rank.cmp(&other.proof_rank)) } } @@ -1962,6 +2008,12 @@ impl Ledger { Ok(prepared.finish(wallet, vdf_output)) } + pub fn mine_recovery_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<Block> { + let prepared = self.prepare_recovery_block(wallet.address(), timestamp_ms)?; + let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); + Ok(prepared.finish(wallet, vdf_output)) + } + pub fn prepare_next_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> { let height = self.tip().height + 1; let Some((finalizer_rank, leader_ticket)) = self.finalizer_ticket_for_miner(height, miner) @@ -1985,11 +2037,53 @@ impl Ledger { prev_hash, timestamp_ms, miner: miner.to_string(), + finalizer_mode: FinalizerMode::Ticket, reward: fee_reward(&transactions)?, vdf_rounds: self.vdf_rounds_for_finalizer_rank(finalizer_rank)?, vdf_seed, finalizer_rank, - leader_ticket, + leader_ticket: Some(leader_ticket), + transactions, + }) + } + + pub fn recovery_block_available_at(&self, timestamp_ms: u64) -> bool { + timestamp_ms >= self.recovery_block_min_timestamp() + } + + pub fn recovery_block_min_timestamp(&self) -> u64 { + self.tip() + .timestamp_ms + .saturating_add(RECOVERY_BLOCK_DELAY_MS) + } + + pub fn prepare_recovery_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> { + let height = self.tip().height + 1; + let min_timestamp = self.recovery_block_min_timestamp(); + if timestamp_ms < min_timestamp { + bail!("recovery block is not available before timestamp {min_timestamp}"); + } + + let transactions = self.select_recovery_block_transactions(miner)?; + ensure_block_has_burn(&transactions)?; + ensure_block_has_burn_from(&transactions, miner)?; + ensure_mine_actions_have_required_burns(&transactions)?; + + let tip = self.tip(); + let prev_hash = tip.hash.clone(); + let timestamp_ms = timestamp_ms.max(tip.timestamp_ms + 1); + let vdf_seed = recovery_vdf_seed_for_child(&prev_hash, height, timestamp_ms); + Ok(PreparedBlock { + height, + prev_hash, + timestamp_ms, + miner: miner.to_string(), + finalizer_mode: FinalizerMode::Recovery, + finalizer_rank: 0, + reward: fee_reward(&transactions)?, + vdf_rounds: self.recovery_vdf_rounds()?, + vdf_seed, + leader_ticket: None, transactions, }) } @@ -2050,7 +2144,7 @@ impl Ledger { bail!("block reward is invalid"); } let mut tickets = self.tickets.clone(); - consume_leader_ticket(&block, &mut tickets)?; + apply_finalizer_ticket_effects(&block, &mut tickets)?; credit_reward_output(&mut utxos, &block)?; tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?); @@ -2116,7 +2210,7 @@ impl Ledger { if block.reward != fee_reward(&block.transactions)? { bail!("block reward is invalid"); } - let expected_vdf_rounds = self.vdf_rounds_for_finalizer_rank(block.finalizer_rank)?; + let expected_vdf_rounds = self.expected_vdf_rounds_for_block(block)?; if block.vdf_rounds != expected_vdf_rounds { bail!("block VDF rounds are invalid"); } @@ -2139,24 +2233,31 @@ impl Ledger { } ensure_block_has_burn(&block.transactions)?; ensure_mine_actions_have_required_burns(&block.transactions)?; - let selected_ticket = self - .ticket_for_finalizer_rank(block.height, block.finalizer_rank) - .context("no selected ticket for block finalizer rank")?; - if selected_ticket.owner != block.miner { - bail!( - "block finalizer {} is not selected for rank {}", - block.miner, - block.finalizer_rank - ); - } - if block - .leader_proof - .as_ref() - .is_none_or(|proof| proof.ticket_id != selected_ticket.id) - { - bail!("block does not prove the selected leader ticket"); + match block.finalizer_mode { + FinalizerMode::Ticket => { + let selected_ticket = self + .ticket_for_finalizer_rank(block.height, block.finalizer_rank) + .context("no selected ticket for block finalizer rank")?; + if selected_ticket.owner != block.miner { + bail!( + "block finalizer {} is not selected for rank {}", + block.miner, + block.finalizer_rank + ); + } + if block + .leader_proof + .as_ref() + .is_none_or(|proof| proof.ticket_id != selected_ticket.id) + { + bail!("block does not prove the selected leader ticket"); + } + verify_leader_proof(block, &self.tickets)?; + } + FinalizerMode::Recovery => { + ensure_valid_recovery_block(block, self.tip())?; + } } - verify_leader_proof(block, &self.tickets)?; Ok(true) } @@ -2245,14 +2346,28 @@ impl Ledger { } fn select_block_transactions(&self) -> Result<Vec<Transaction>> { + self.select_block_transactions_with_required_burn_owner(None) + } + + fn select_recovery_block_transactions(&self, miner: &str) -> Result<Vec<Transaction>> { + self.select_block_transactions_with_required_burn_owner(Some(miner)) + } + + fn select_block_transactions_with_required_burn_owner( + &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(); let mut selected_burn_amount = 0_u64; - if let Some(index) = + let first_burn_index = if let Some(owner) = required_burn_owner { + best_selectable_burn_from_index(&remaining, &utxos, owner) + } else { best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) - { + }; + if let Some(index) = first_burn_index { let tx = remaining.remove(index); let mut candidate = selected.clone(); candidate.push(tx.clone()); @@ -2490,6 +2605,17 @@ impl Ledger { vdf_rounds_for_finalizer_rank(self.vdf_rounds, rank) } + fn recovery_vdf_rounds(&self) -> Result<u64> { + vdf_rounds_for_finalizer_rank(self.vdf_rounds, 0) + } + + fn expected_vdf_rounds_for_block(&self, block: &Block) -> Result<u64> { + match block.finalizer_mode { + FinalizerMode::Ticket => self.vdf_rounds_for_finalizer_rank(block.finalizer_rank), + FinalizerMode::Recovery => self.recovery_vdf_rounds(), + } + } + fn mine_difficulty_bits_for_anchor_height(&self, anchor_height: u64) -> u32 { let mut difficulty = self.launch_profile.mine_difficulty_bits; let mut window_end = MINE_RETARGET_WINDOW_BLOCKS; @@ -2710,6 +2836,19 @@ fn genesis_bootstrap_tickets( Ok(tickets) } +fn apply_finalizer_ticket_effects(block: &Block, tickets: &mut Vec<BurnTicket>) -> Result<()> { + match block.finalizer_mode { + FinalizerMode::Ticket => consume_leader_ticket(block, tickets), + FinalizerMode::Recovery => { + tickets.retain(|ticket| { + !ticket_is_eligible_for_height(ticket, block.height) + && ticket.eligible_until_height > block.height + }); + Ok(()) + } + } +} + fn consume_leader_ticket(block: &Block, tickets: &mut Vec<BurnTicket>) -> Result<()> { let Some(proof) = &block.leader_proof else { bail!("block is missing leader proof"); @@ -2784,6 +2923,16 @@ fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> { Ok(()) } +fn ensure_block_has_burn_from(transactions: &[Transaction], miner: &str) -> Result<()> { + if !transactions + .iter() + .any(|transaction| transaction.is_burn() && transaction.sender() == miner) + { + bail!("recovery block must include a burn from the finalizer"); + } + Ok(()) +} + fn block_burn_amount(block: &Block) -> Amount { transactions_burn_amount(&block.transactions).unwrap_or(Amount::MAX) } @@ -2816,6 +2965,20 @@ fn ensure_mine_actions_have_required_burns(transactions: &[Transaction]) -> Resu Ok(()) } +fn ensure_valid_recovery_block(block: &Block, parent: &Block) -> Result<()> { + if block.finalizer_rank != 0 { + bail!("recovery block finalizer rank must be 0"); + } + if block.leader_proof.is_some() { + bail!("recovery block must not carry a leader proof"); + } + let min_timestamp = parent.timestamp_ms.saturating_add(RECOVERY_BLOCK_DELAY_MS); + if block.timestamp_ms < min_timestamp { + bail!("recovery block is not available before timestamp {min_timestamp}"); + } + ensure_block_has_burn_from(&block.transactions, &block.miner) +} + fn fee_rate_key(transaction: &Transaction) -> u128 { let size = transaction.economic_size_bytes(); if size == 0 { @@ -2837,6 +3000,28 @@ fn best_selectable_transaction_index( ) } +fn best_selectable_burn_from_index( + transactions: &[Transaction], + utxos: &BTreeMap<OutPoint, TxOutput>, + owner: &str, +) -> Option<usize> { + transactions + .iter() + .enumerate() + .filter(|(_, tx)| tx.is_burn() && tx.sender() == 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 best_selectable_transaction_index_for_burn_amount( transactions: &[Transaction], utxos: &BTreeMap<OutPoint, TxOutput>, @@ -3081,6 +3266,7 @@ fn estimated_block_size_bytes(transactions: &[Transaction]) -> Result<usize> { prev_hash: "f".repeat(64), timestamp_ms: u64::MAX, miner: "f".repeat(64), + finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: u64::MAX, vdf_rounds: u64::MAX, @@ -3146,6 +3332,12 @@ fn vdf_seed_for_child(prev_hash: &str, height: u64) -> String { hex_hash(format!("iuna-vdf-child:{prev_hash}:{height}")) } +fn recovery_vdf_seed_for_child(prev_hash: &str, height: u64, timestamp_ms: u64) -> String { + hex_hash(format!( + "iuna-recovery-vdf-child:{prev_hash}:{height}:{timestamp_ms}" + )) +} + fn apply_transaction( transaction: &Transaction, utxos: &mut BTreeMap<OutPoint, TxOutput>, @@ -3309,6 +3501,7 @@ fn build_genesis_block( prev_hash: "0".repeat(64), timestamp_ms: 0, miner, + finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward, vdf_rounds: 0, @@ -4300,6 +4493,7 @@ mod tests { prev_hash: "0".repeat(64), timestamp_ms: 1, miner: "alice".to_string(), + finalizer_mode: FinalizerMode::Ticket, finalizer_rank: 0, reward: BLOCK_REWARD, vdf_rounds: 1, diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -11,8 +11,9 @@ use iuna::{ }, domain::{ Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, - GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA, MIN_MINE_REQUIRED_BURN, - TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, + FinalizerMode, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA, MIN_MINE_REQUIRED_BURN, + RECOVERY_BLOCK_DELAY_MS, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + verify_vdf, }, }; use tempfile::tempdir; @@ -998,6 +999,173 @@ fn fallback_finalizer_unblocks_network_when_primary_does_not_publish() { } #[test] +fn recovery_block_is_rejected_before_timeout() { + let alice = Wallet::from_seed("recovery-before-timeout-alice"); + let bob = Wallet::from_seed("recovery-before-timeout-bob"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), iuna(10)); + genesis.insert(bob.address().to_string(), iuna(10)); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25) + .unwrap(); + + submit_burn(&mut ledger, &bob, 1); + let error = ledger + .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS - 1) + .unwrap_err(); + + assert!(format!("{error:#}").contains("recovery block is not available")); +} + +#[test] +fn recovery_block_unblocks_chain_when_only_ticket_holder_stops() { + let alice = Wallet::from_seed("recovery-unblocks-alice"); + let bob = Wallet::from_seed("recovery-unblocks-bob"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), iuna(10)); + genesis.insert(bob.address().to_string(), iuna(10)); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25) + .unwrap(); + + assert_eq!( + ledger.expected_leader_for_next_block().as_deref(), + Some(alice.address()) + ); + submit_burn(&mut ledger, &bob, 1); + assert!(ledger.mine_next_block(&bob, 1).is_err()); + + let block = ledger + .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) + .unwrap(); + + assert_eq!(block.miner, bob.address()); + assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); + assert_eq!(block.finalizer_rank, 0); + assert_eq!(block.vdf_rounds, 25); + assert!(block.leader_proof.is_none()); + ledger.apply_block(block).unwrap(); + assert_eq!(ledger.status().height, 1); +} + +#[test] +fn recovery_block_requires_finalizer_own_burn() { + let alice = Wallet::from_seed("recovery-own-burn-alice"); + let bob = Wallet::from_seed("recovery-own-burn-bob"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), iuna(10)); + genesis.insert(bob.address().to_string(), iuna(10)); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25) + .unwrap(); + + submit_burn(&mut ledger, &alice, 1); + let error = ledger + .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) + .unwrap_err(); + + assert!(format!("{error:#}").contains("burn from the finalizer")); +} + +#[test] +fn recovery_block_prioritizes_finalizer_own_burn() { + let alice = Wallet::from_seed("recovery-prioritizes-alice"); + let bob = Wallet::from_seed("recovery-prioritizes-bob"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), iuna(10)); + genesis.insert(bob.address().to_string(), iuna(10)); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25) + .unwrap(); + + let alice_burn = ledger.build_burn(&alice, 1, 1).unwrap(); + ledger.submit_transaction(alice_burn).unwrap(); + let bob_burn = ledger.build_burn(&bob, 1, 0).unwrap(); + let bob_burn_signature = bob_burn.signature().to_string(); + ledger.submit_transaction(bob_burn).unwrap(); + + let block = ledger + .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) + .unwrap(); + + assert!( + block + .transactions + .iter() + .any(|tx| tx.signature() == bob_burn_signature) + ); +} + +#[test] +fn recovery_vdf_seed_is_bound_to_timestamp() { + let alice = Wallet::from_seed("recovery-vdf-seed-alice"); + let bob = Wallet::from_seed("recovery-vdf-seed-bob"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), iuna(10)); + genesis.insert(bob.address().to_string(), iuna(10)); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25) + .unwrap(); + + submit_burn(&mut ledger, &bob, 1); + let mut block = ledger + .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) + .unwrap(); + block.timestamp_ms += 1; + block.hash = block.compute_hash(); + + let error = ledger.apply_block(block).unwrap_err(); + assert!(format!("{error:#}").contains("block VDF output is invalid")); +} + +#[test] +fn fork_choice_prefers_ticket_block_over_recovery_block_at_same_height() { + let alice = Wallet::from_seed("recovery-fork-alice"); + let bob = Wallet::from_seed("recovery-fork-bob"); + let wallets = [&alice, &bob]; + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), iuna(10)); + genesis.insert(bob.address().to_string(), iuna(10)); + let common = Ledger::new_with_genesis_burns( + genesis, + vec![ + GenesisBurn::new(alice.address(), 1), + GenesisBurn::new(bob.address(), 1), + ], + 25, + ) + .unwrap(); + let leader_address = common.expected_leader_for_next_block().unwrap(); + let leader = wallets + .iter() + .copied() + .find(|wallet| wallet.address() == leader_address) + .unwrap(); + let recovery = wallets + .into_iter() + .find(|wallet| wallet.address() != leader_address) + .unwrap(); + + let mut local = common.clone(); + submit_burn(&mut local, recovery, 1); + let recovery_block = local + .mine_recovery_block(recovery, RECOVERY_BLOCK_DELAY_MS) + .unwrap(); + assert_eq!(recovery_block.finalizer_mode, FinalizerMode::Recovery); + local.apply_block(recovery_block).unwrap(); + + let mut remote = common; + submit_burn(&mut remote, leader, 1); + let leader_block = remote.mine_next_block(leader, 1).unwrap(); + assert_eq!(leader_block.finalizer_mode, FinalizerMode::Ticket); + let leader_hash = leader_block.hash.clone(); + remote.apply_block(leader_block).unwrap(); + + assert!(local.extend_from_snapshot(remote.snapshot()).unwrap()); + assert_eq!(local.status().tip_hash, leader_hash); +} + +#[test] fn fork_choice_prefers_primary_finalizer_over_fallback_rank() { let alice = Wallet::from_seed("fallback-fork-alice"); let bob = Wallet::from_seed("fallback-fork-bob"); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -1773,7 +1773,8 @@ window.iunaApp = function iunaApp() { blockFinalizerLabel(block) { const finalizer = this.short(block.miner); - return block.miner === this.status.wallet_address ? `${finalizer} (me)` : finalizer; + const owner = block.miner === this.status.wallet_address ? `${finalizer} (me)` : finalizer; + return block.finalizer_mode === "recovery" ? `${owner} ยท Recovery` : owner; }, walletTransactions() {