iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit a7756116ffbcdef8500eb6b404403bc56182607f
parent 91524b15e8c89a4591b2764ae42ad96e7cf27a27
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sun, 16 Aug 2026 20:28:16 +0200

Fix recovery finalization liveness

Diffstat:
Msrc/app/automatic_mining.rs | 91+++++++++++++++++++++++++++++++++++++++----------------------------------------
Msrc/app/automatic_mining/tests.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/wallet.rs | 15+++++++++++++++
Msrc/domain.rs | 2+-
Msrc/domain/ledger_ops.rs | 13+++++++++----
Msrc/domain/ledger_pending.rs | 28+++++++++++++++++++++-------
Msrc/domain/ledger_prepare.rs | 12++++++++++--
Msrc/domain/tests.rs | 54+++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/domain/vdf.rs | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/main.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
10 files changed, 413 insertions(+), 76 deletions(-)

diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -91,9 +91,9 @@ impl NodeCore { let wallet_rank = self .ledger .finalizer_rank_for_next_block(self.wallet.address()); - let will_run_ticket_vdf = wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank)); - let will_run_recovery_vdf = - wallet_rank.is_none() && self.should_prepare_recovery_vdf(timestamp_ms); + let will_run_recovery_vdf = self.should_prepare_recovery_vdf(timestamp_ms); + let will_run_ticket_vdf = !will_run_recovery_vdf + && wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank)); if let Some(wait_ms) = self.reveal_bundle_collection_wait_ms( timestamp_ms, will_run_ticket_vdf || will_run_recovery_vdf, @@ -109,6 +109,18 @@ impl NodeCore { return plan; } + if will_run_recovery_vdf { + match self.prepare_recovery_block_with_local_anchor(timestamp_ms) { + Ok(work) => { + plan.work = Some(work); + } + Err(error) => { + plan.skipped_reason = Some(format!("{error:#}")); + } + } + return plan; + } + if let Some(rank) = wallet_rank { if !self.wallet_rank_runs_vdf(rank) { plan.skipped_reason = Some(format!( @@ -118,21 +130,10 @@ impl NodeCore { return plan; } } else { - if self.should_prepare_recovery_vdf(timestamp_ms) { - match self.prepare_recovery_block_with_local_anchor(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") - }); - } + 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; } @@ -180,9 +181,9 @@ impl NodeCore { let wallet_rank = self .ledger .finalizer_rank_for_next_block(self.wallet.address()); - let will_run_ticket_vdf = wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank)); - let will_run_recovery_vdf = - wallet_rank.is_none() && self.should_prepare_recovery_vdf(timestamp_ms); + let will_run_recovery_vdf = self.should_prepare_recovery_vdf(timestamp_ms); + let will_run_ticket_vdf = !will_run_recovery_vdf + && wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank)); if let Some(wait_ms) = self.reveal_bundle_collection_wait_ms( timestamp_ms, will_run_ticket_vdf || will_run_recovery_vdf, @@ -200,6 +201,24 @@ impl NodeCore { return plan; } + if will_run_recovery_vdf { + match self.prepare_recovery_block_with_local_anchor(timestamp_ms) { + Ok(work) => { + self.last_auto_finalization_status = Some(format!( + "running recovery VDF for candidate block {} ({} rounds)", + work.height(), + work.vdf_rounds() + )); + plan.work = Some(work); + } + Err(error) => { + plan.skipped_reason = Some(format!("{error:#}")); + self.last_auto_finalization_status = plan.skipped_reason.clone(); + } + } + return plan; + } + if let Some(rank) = wallet_rank { if !self.wallet_rank_runs_vdf(rank) { plan.skipped_reason = Some(format!( @@ -210,28 +229,11 @@ impl NodeCore { return plan; } } else { - if self.should_prepare_recovery_vdf(timestamp_ms) { - match self.prepare_recovery_block_with_local_anchor(timestamp_ms) { - Ok(work) => { - self.last_auto_finalization_status = Some(format!( - "running recovery VDF for candidate block {} ({} rounds)", - work.height(), - work.vdf_rounds() - )); - plan.work = Some(work); - } - Err(error) => { - plan.skipped_reason = Some(format!("{error:#}")); - self.last_auto_finalization_status = plan.skipped_reason.clone(); - } - } - } 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") - }); - self.last_auto_finalization_status = plan.skipped_reason.clone(); - } + 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") + }); + self.last_auto_finalization_status = plan.skipped_reason.clone(); return plan; } @@ -422,9 +424,6 @@ impl NodeCore { if self.recovery_vdf_top_rank_percent == 0 { return false; } - if self.ledger.finalizer_rank_count_for_next_block() > 0 { - return false; - } recovery_vdf_sample_percent(self.wallet.address(), self.ledger.tip_hash()) < self.recovery_vdf_top_rank_percent } diff --git a/src/app/automatic_mining/tests.rs b/src/app/automatic_mining/tests.rs @@ -312,6 +312,39 @@ fn automatic_finalization_prepares_recovery_after_ticket_timeout() { } #[test] +fn automatic_finalization_prepares_recovery_even_when_ticket_ranks_remain() { + let alice = Wallet::from_seed("automatic-recovery-with-ranks-alice"); + let bob = Wallet::from_seed("automatic-recovery-with-ranks-bob"); + let carol = Wallet::from_seed("automatic-recovery-with-ranks-carol"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![ + GenesisBurn::new(alice.address(), 1), + GenesisBurn::new(bob.address(), 1), + ], + 10, + ) + .unwrap(); + assert!(ledger.finalizer_rank_count_for_next_block() > 0); + assert_eq!(ledger.finalizer_rank_for_next_block(carol.address()), None); + let mut node = NodeCore::from_ledger(carol, ledger, 1); + + 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_finalization_respects_zero_recovery_vdf_threshold() { let alice = Wallet::from_seed("automatic-recovery-zero-alice"); let bob = Wallet::from_seed("automatic-recovery-zero-bob"); @@ -456,6 +489,43 @@ fn automatic_fallback_finalizer_prepares_anchor_and_blinded_burn() { } #[test] +fn automatic_ranked_finalizer_prefers_recovery_after_timeout() { + let alice = Wallet::from_seed("auto-ranked-recovery-alice"); + let bob = Wallet::from_seed("auto-ranked-recovery-bob"); + let finalizers = [alice.clone(), bob.clone()]; + 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, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 10, + ) + .unwrap(); + let fallback = finalizers + .iter() + .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) + .unwrap() + .clone(); + let mut node = NodeCore::from_ledger(fallback, ledger, 1); + + 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_eq!(block.finalizer_rank, 0); + assert_eq!(block.vdf_rounds, 10); + assert!(block.leader_proof.is_none()); +} + +#[test] fn automatic_leader_prepares_anchor_and_blinded_burn() { let alice = Wallet::from_seed("auto-plaintext-burn-alice"); let bob = Wallet::from_seed("auto-plaintext-burn-bob"); diff --git a/src/app/wallet.rs b/src/app/wallet.rs @@ -430,6 +430,21 @@ impl NodeCore { self.publish_owned_reveals_for_block(&block)?; Ok(block) } + + pub fn precheck_prepared_block_without_vdf_at( + &self, + work: &PreparedBlock, + timestamp_ms: u64, + ) -> Result<()> { + let block = work.clone().finish_at( + self.wallet.unlocked()?, + "precheck-vdf-output".to_string(), + timestamp_ms, + ); + self.ledger + .block_requires_vdf_verification_at(&block, timestamp_ms)?; + Ok(()) + } } #[cfg(test)] diff --git a/src/domain.rs b/src/domain.rs @@ -128,7 +128,7 @@ use vdf::{ VDF_RETARGET_DEADBAND_PERCENT, clamped_vdf_retarget_observed_block_ms, retarget_vdf_rounds, vdf_retarget_observed_block_ms, }; -pub use vdf::{run_vdf, verify_vdf}; +pub use vdf::{VdfProgress, VdfProgressPhase, run_vdf, run_vdf_with_progress, verify_vdf}; pub use wallet::Wallet; pub fn reveal_committee_slot_count(eligible_rank_count: usize) -> usize { diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs @@ -70,6 +70,7 @@ pub(super) fn validate_genesis_burn_transaction(transaction: &Transaction) -> Re pub(super) fn estimated_block_selection_size_bytes( selection: &BlockSelection, recovery: bool, + reveal_bundle_section: &RevealBundleSection, ) -> Result<usize> { let block = Block { height: u64::MAX, @@ -84,14 +85,14 @@ pub(super) fn estimated_block_selection_size_bytes( finalizer_rank: 0, reward: u64::MAX, vdf_rounds: u64::MAX, - vdf_output: "f".repeat(64), + vdf_output: format!("{}:{}", "f".repeat(512), "f".repeat(512)), leader_proof: (!recovery).then(|| LeaderProof { ticket_id: "f".repeat(64), public_key: "f".repeat(64), signature: "f".repeat(128), }), blinded_transactions: selection.blinded_transactions.clone(), - reveal_bundle_section: RevealBundleSection::default(), + reveal_bundle_section: reveal_bundle_section.clone(), transactions: selection.transactions.clone(), hash: "f".repeat(64), }; @@ -106,7 +107,9 @@ pub(super) fn ensure_transaction_fits_empty_block( transactions: vec![transaction.clone()], blinded_transactions: Vec::new(), }; - if estimated_block_selection_size_bytes(&selection, false)? > max_block_bytes { + if estimated_block_selection_size_bytes(&selection, false, &RevealBundleSection::default())? + > max_block_bytes + { bail!("transaction exceeds max block size"); } Ok(()) @@ -120,7 +123,9 @@ pub(super) fn ensure_blinded_transaction_fits_empty_block( transactions: Vec::new(), blinded_transactions: vec![transaction.clone()], }; - if estimated_block_selection_size_bytes(&selection, false)? > max_block_bytes { + if estimated_block_selection_size_bytes(&selection, false, &RevealBundleSection::default())? + > max_block_bytes + { bail!("blinded transaction exceeds max block size"); } Ok(()) diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs @@ -32,7 +32,8 @@ use super::{ Amount, BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BLINDED_VISIBLE_INPUTS_REQUIRED_HEIGHT, BLOCK_ITEM_FEES_REQUIRED_HEIGHT, BlindedReveal, BlindedTransaction, Ledger, MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MAX_PENDING_TRANSACTIONS, - MINE_ACTIONS_PER_ANCHOR_LIMIT, OutPoint, Transaction, TxOutput, decode_hex, decode_hex_array, + MINE_ACTIONS_PER_ANCHOR_LIMIT, OutPoint, RevealBundleSection, Transaction, TxOutput, + decode_hex, decode_hex_array, }; impl Ledger { @@ -86,25 +87,29 @@ impl Ledger { valid } - pub(super) fn select_block_transactions( + pub(super) fn select_block_transactions_with_reveal_section( &self, miner: &str, required_burn_signature: Option<&str>, + reveal_bundle_section: &RevealBundleSection, ) -> Result<BlockSelection> { self.select_block_transactions_with_required_burn_owner( Some(miner), required_burn_signature, + reveal_bundle_section, ) } - pub(super) fn select_recovery_block_transactions( + pub(super) fn select_recovery_block_transactions_with_reveal_section( &self, miner: &str, required_burn_signature: Option<&str>, + reveal_bundle_section: &RevealBundleSection, ) -> Result<BlockSelection> { self.select_block_transactions_with_required_burn_owner( Some(miner), required_burn_signature, + reveal_bundle_section, ) } @@ -112,6 +117,7 @@ impl Ledger { &self, required_burn_owner: Option<&str>, required_burn_signature: Option<&str>, + reveal_bundle_section: &RevealBundleSection, ) -> Result<BlockSelection> { let mut utxos = self.utxos.clone(); let mut remaining = self.valid_pending_transactions(); @@ -138,8 +144,11 @@ impl Ledger { transactions: vec![tx.clone()], blinded_transactions: selected_blinded.clone(), }; - if estimated_block_selection_size_bytes(&candidate, required_burn_owner.is_some())? - > self.launch_profile.max_block_bytes + if estimated_block_selection_size_bytes( + &candidate, + required_burn_owner.is_some(), + reveal_bundle_section, + )? > self.launch_profile.max_block_bytes { bail!("required block anchor burn does not fit in the block"); } @@ -172,8 +181,11 @@ impl Ledger { blinded_transactions: selected_blinded.clone(), }; candidate.transactions.push(tx.clone()); - if estimated_block_selection_size_bytes(&candidate, required_burn_owner.is_some())? - <= self.launch_profile.max_block_bytes + if estimated_block_selection_size_bytes( + &candidate, + required_burn_owner.is_some(), + reveal_bundle_section, + )? <= self.launch_profile.max_block_bytes { apply_transaction(&tx, &mut utxos)?; selected.push(tx); @@ -221,6 +233,7 @@ impl Ledger { if estimated_block_selection_size_bytes( &candidate, required_burn_owner.is_some(), + reveal_bundle_section, )? <= self.launch_profile.max_block_bytes { apply_transaction(&tx, &mut utxos)?; @@ -240,6 +253,7 @@ impl Ledger { if estimated_block_selection_size_bytes( &candidate, required_burn_owner.is_some(), + reveal_bundle_section, )? <= self.launch_profile.max_block_bytes { spend_blinded_inputs(&transaction, &mut utxos)?; diff --git a/src/domain/ledger_prepare.rs b/src/domain/ledger_prepare.rs @@ -55,7 +55,11 @@ impl Ledger { let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?; let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles); - let selection = self.select_block_transactions(miner, required_burn_signature)?; + let selection = self.select_block_transactions_with_reveal_section( + miner, + required_burn_signature, + &reveal_bundle_section, + )?; ensure_block_has_burn(&selection.transactions)?; let tip = self.tip(); @@ -124,7 +128,11 @@ impl Ledger { let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?; let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles); - let selection = self.select_recovery_block_transactions(miner, required_burn_signature)?; + let selection = self.select_recovery_block_transactions_with_reveal_section( + miner, + required_burn_signature, + &reveal_bundle_section, + )?; ensure_block_has_burn(&selection.transactions)?; ensure_block_has_burn_from(&selection.transactions, miner)?; diff --git a/src/domain/tests.rs b/src/domain/tests.rs @@ -2586,9 +2586,18 @@ fn recovery_block_size_selection_uses_recovery_skeleton() { transactions: vec![recovery_burn], blinded_transactions: vec![blinded.transaction.clone()], }; - let recovery_estimate = - estimated_block_selection_size_bytes(&recovery_selection, true).unwrap(); - let ticket_estimate = estimated_block_selection_size_bytes(&recovery_selection, false).unwrap(); + let recovery_estimate = estimated_block_selection_size_bytes( + &recovery_selection, + true, + &RevealBundleSection::default(), + ) + .unwrap(); + let ticket_estimate = estimated_block_selection_size_bytes( + &recovery_selection, + false, + &RevealBundleSection::default(), + ) + .unwrap(); assert!(recovery_estimate < ticket_estimate); ledger.launch_profile.max_block_bytes = recovery_estimate; @@ -2652,6 +2661,45 @@ fn recovery_block_includes_pending_blinded_reveals_when_space_allows() { } #[test] +fn block_size_estimate_includes_reveal_bundle_section() { + let alice = Wallet::from_seed("size-estimate-reveal-alice"); + let bob = Wallet::from_seed("size-estimate-reveal-bob"); + let carol = Wallet::from_seed("size-estimate-reveal-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + ledger + .submit_blinded_reveal(blinded.reveal.clone()) + .unwrap(); + + let bundles = ledger + .reveal_committee_for_next_block() + .into_iter() + .filter_map(|member| { + let wallet = wallet_for_address(&finalizers, &member.owner); + ledger.build_reveal_bundle(wallet).unwrap() + }) + .collect::<Vec<_>>(); + let reveal_bundle_section = ledger.reveal_bundle_section_from_bundles(bundles); + let selection = BlockSelection::default(); + + let empty_estimate = + estimated_block_selection_size_bytes(&selection, true, &RevealBundleSection::default()) + .unwrap(); + let reveal_estimate = + estimated_block_selection_size_bytes(&selection, true, &reveal_bundle_section).unwrap(); + + assert!(reveal_estimate > empty_estimate); +} + +#[test] fn blinded_transaction_expiring_at_next_height_is_not_selected() { let alice = Wallet::from_seed("blinded-next-expire-finalizer-alice"); let bob = Wallet::from_seed("blinded-next-expire-finalizer-bob"); diff --git a/src/domain/vdf.rs b/src/domain/vdf.rs @@ -1,4 +1,7 @@ -use std::sync::OnceLock; +use std::{ + sync::OnceLock, + time::{Duration, Instant}, +}; use num_bigint::BigUint; use num_traits::{One, Zero}; @@ -26,15 +29,61 @@ pub(super) const VDF_RETARGET_DEADBAND_PERCENT: u128 = 10; pub(super) const MIN_VDF_RETARGET_OBSERVED_BLOCK_MS: u64 = VDF_TARGET_BLOCK_MS / 4; pub(super) const MAX_VDF_RETARGET_OBSERVED_BLOCK_MS: u64 = VDF_TARGET_BLOCK_MS * 4; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VdfProgress { + pub completed_steps: u64, + pub total_steps: u64, + pub completed_phase_rounds: u64, + pub phase_rounds: u64, + pub phase: VdfProgressPhase, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VdfProgressPhase { + Output, + Proof, +} + pub fn run_vdf(seed: &str, rounds: u64) -> String { + run_vdf_with_progress(seed, rounds, Duration::MAX, |_| {}) +} + +pub fn run_vdf_with_progress( + seed: &str, + rounds: u64, + progress_interval: Duration, + mut progress: impl FnMut(VdfProgress), +) -> String { let x = vdf_seed_element(seed); let mut y = x.clone(); - for _ in 0..rounds { + let total_steps = rounds.saturating_mul(2); + let mut last_progress = Instant::now(); + for completed_rounds in 0..rounds { y = square_mod(&y); + maybe_report_vdf_progress( + &mut last_progress, + progress_interval, + VdfProgress { + completed_steps: completed_rounds + 1, + total_steps, + completed_phase_rounds: completed_rounds + 1, + phase_rounds: rounds, + phase: VdfProgressPhase::Output, + }, + &mut progress, + ); } let challenge = vdf_challenge_prime(seed, rounds, &y); - let proof = vdf_proof(&x, rounds, challenge); + let proof = vdf_proof_with_progress( + &x, + rounds, + challenge, + total_steps, + &mut last_progress, + progress_interval, + &mut progress, + ); encode_vdf_solution(y, proof) } @@ -141,10 +190,18 @@ fn vdf_challenge_prime(seed: &str, rounds: u64, output: &BigUint) -> u64 { next_odd_prime(candidate | 1) } -fn vdf_proof(x: &BigUint, rounds: u64, challenge: u64) -> BigUint { +fn vdf_proof_with_progress( + x: &BigUint, + rounds: u64, + challenge: u64, + total_steps: u64, + last_progress: &mut Instant, + progress_interval: Duration, + progress: &mut impl FnMut(VdfProgress), +) -> BigUint { let mut proof = BigUint::one(); let mut remainder = 1_u64 % challenge; - for _ in 0..rounds { + for completed_rounds in 0..rounds { let doubled = remainder * 2; let carry = doubled >= challenge; proof = square_mod(&proof); @@ -152,10 +209,36 @@ fn vdf_proof(x: &BigUint, rounds: u64, challenge: u64) -> BigUint { proof = mul_mod(&proof, x); } remainder = doubled % challenge; + maybe_report_vdf_progress( + last_progress, + progress_interval, + VdfProgress { + completed_steps: rounds.saturating_add(completed_rounds + 1), + total_steps, + completed_phase_rounds: completed_rounds + 1, + phase_rounds: rounds, + phase: VdfProgressPhase::Proof, + }, + progress, + ); } proof } +fn maybe_report_vdf_progress( + last_progress: &mut Instant, + progress_interval: Duration, + snapshot: VdfProgress, + progress: &mut impl FnMut(VdfProgress), +) { + if snapshot.completed_steps == snapshot.total_steps + || last_progress.elapsed() >= progress_interval + { + progress(snapshot); + *last_progress = Instant::now(); + } +} + fn encode_vdf_solution(output: BigUint, proof: BigUint) -> String { format!( "{output:0>width$x}:{proof:0>width$x}", @@ -220,7 +303,12 @@ fn is_odd_prime(candidate: u64) -> bool { #[cfg(test)] mod tests { - use super::{VDF_ELEMENT_HEX_LEN, pow_mod_small, run_vdf, vdf_modulus, verify_vdf}; + use std::time::Duration; + + use super::{ + VDF_ELEMENT_HEX_LEN, VdfProgressPhase, pow_mod_small, run_vdf, run_vdf_with_progress, + vdf_modulus, verify_vdf, + }; #[test] fn vdf_solution_verifies_and_is_bound_to_seed_and_rounds() { @@ -233,6 +321,34 @@ mod tests { } #[test] + fn vdf_progress_reports_output_and_proof_steps() { + let mut progress = Vec::new(); + let solution = run_vdf_with_progress("progress-seed", 4, Duration::ZERO, |snapshot| { + progress.push(snapshot); + }); + + assert!(verify_vdf("progress-seed", 4, &solution)); + assert!( + progress + .iter() + .any(|snapshot| snapshot.phase == VdfProgressPhase::Output) + ); + assert!( + progress + .iter() + .any(|snapshot| snapshot.phase == VdfProgressPhase::Proof) + ); + assert_eq!( + progress.last().map(|snapshot| snapshot.completed_steps), + Some(8) + ); + assert_eq!( + progress.last().map(|snapshot| snapshot.total_steps), + Some(8) + ); + } + + #[test] fn vdf_solution_uses_2048_bit_elements() { let solution = run_vdf("test-seed", 16); let (output, proof) = solution.split_once(':').unwrap(); diff --git a/src/main.rs b/src/main.rs @@ -18,7 +18,7 @@ use iuna::{ }, domain::{ Amount, ChainSnapshot, GenesisBurn, Ledger, MAX_VDF_ROUNDS, MICRO_IUNA, - VDF_TARGET_BLOCK_MS, run_vdf, + VDF_TARGET_BLOCK_MS, VdfProgress, VdfProgressPhase, run_vdf, run_vdf_with_progress, }, }; use tokio::sync::Mutex; @@ -37,6 +37,7 @@ const GENESIS_INITIAL_BURN_FEE: Amount = config_store::DEFAULT_BURN_FEE; const VDF_MEASUREMENT_INITIAL_ROUNDS: u64 = 1_000; const VDF_MEASUREMENT_MAX_ROUNDS: u64 = 10_000_000; const VDF_MEASUREMENT_MIN_ELAPSED: Duration = Duration::from_millis(150); +const VDF_PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(10); #[tokio::main] async fn main() -> Result<()> { @@ -475,16 +476,53 @@ async fn run_automatic_finalizer(node: SharedNode, gossip: p2p::GossipNetwork, d ); } + let candidate_height = work.height(); let seed = work.vdf_seed().to_string(); let rounds = work.vdf_rounds(); let publish_at_ms = work.timestamp_ms(); - let vdf_output = match tokio::task::spawn_blocking(move || run_vdf(&seed, rounds)).await { - Ok(output) => output, - Err(error) => { - if debug { - eprintln!("VDF worker failed: {error:#}"); + let precheck = { + let node = node.lock().await; + node.precheck_prepared_block_without_vdf_at(&work, now_ms()) + }; + if let Err(error) = precheck { + let message = format!("skipped before VDF: {error:#}"); + if debug { + println!("auto-finalization {message}"); + } + node.lock() + .await + .record_automatic_finalization_status(message); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + } + let (progress_tx, progress_rx) = std::sync::mpsc::channel(); + let mut vdf_worker = tokio::task::spawn_blocking(move || { + run_vdf_with_progress(&seed, rounds, VDF_PROGRESS_LOG_INTERVAL, |progress| { + let _ = progress_tx.send(progress); + }) + }); + let vdf_output = loop { + tokio::select! { + result = &mut vdf_worker => { + break match result { + Ok(output) => output, + Err(error) => { + if debug { + eprintln!("VDF worker failed: {error:#}"); + } + continue; + } + }; + } + _ = tokio::time::sleep(std::time::Duration::from_millis(500)) => { + while let Ok(progress) = progress_rx.try_recv() { + let message = format_vdf_progress(candidate_height, progress); + if debug { + println!("{message}"); + } + node.lock().await.record_automatic_finalization_status(message); + } } - continue; } }; @@ -517,6 +555,7 @@ async fn run_automatic_finalizer(node: SharedNode, gossip: p2p::GossipNetwork, d (finalized, outbox) }; + let failed_after_vdf = finalized.is_err(); match finalized { Ok(block) if debug => { println!("auto-finalized block {} ({})", block.height, block.hash); @@ -532,10 +571,33 @@ async fn run_automatic_finalizer(node: SharedNode, gossip: p2p::GossipNetwork, d } } + if failed_after_vdf { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + tokio::task::yield_now().await; } } +fn format_vdf_progress(candidate_height: u64, progress: VdfProgress) -> String { + let phase = match progress.phase { + VdfProgressPhase::Output => "output", + VdfProgressPhase::Proof => "proof", + }; + let percent = if progress.total_steps == 0 { + 100.0 + } else { + progress.completed_steps as f64 * 100.0 / progress.total_steps as f64 + }; + format!( + "running VDF for candidate block {candidate_height}: {phase} {}/{} rounds, total {}/{} steps ({percent:.1}%)", + progress.completed_phase_rounds, + progress.phase_rounds, + progress.completed_steps, + progress.total_steps + ) +} + async fn run_automatic_pow_miner(node: SharedNode, gossip: p2p::GossipNetwork, debug: bool) { loop { tokio::time::sleep(std::time::Duration::from_secs(1)).await;