commit a7756116ffbcdef8500eb6b404403bc56182607f
parent 91524b15e8c89a4591b2764ae42ad96e7cf27a27
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sun, 16 Aug 2026 20:28:16 +0200
Fix recovery finalization liveness
Diffstat:
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;