iuna

iuna

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

commit 6b86727bfc91fd2af9fd3cd1c21f15d97515ef52
parent edbd2594b380fb49c76e8d64d63d9daf440e7a45
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu,  3 Sep 2026 21:19:19 +0200

Speed up and stabilize release soak

Diffstat:
Msrc/app/helpers.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain.rs | 2++
Msrc/domain/vdf/mod.rs | 41+++++++++++++++++++++++++++++++++++------
Mtests/properties.rs | 13++++++++-----
4 files changed, 92 insertions(+), 11 deletions(-)

diff --git a/src/app/helpers.rs b/src/app/helpers.rs @@ -40,6 +40,13 @@ pub(super) fn converge_fee_by_byte( fee = required_fee; } + // Transaction size can oscillate when a new fee selects a different UTXO. + // A previously built candidate whose fee covers its own size is already + // valid, even when the final iteration happens to underpay. + if let Some(best) = best { + return Ok(best); + } + let built = build(fee)?; let bytes = built.economic_size_bytes(); let required_fee = fee_per_byte @@ -130,3 +137,43 @@ pub(super) fn recovery_vdf_sample_percent(address: &str, tip_hash: &str) -> u8 { let digest = Sha256::digest(format!("iuna-recovery-vdf-sample:{tip_hash}:{address}")); digest[0] % 100 } + +#[cfg(test)] +mod tests { + use crate::domain::{OutPoint, Transaction, TxInput}; + + use super::converge_fee_by_byte; + + fn burn_with_inputs(fee: u64, input_count: usize) -> Transaction { + Transaction::Burn { + inputs: (0..input_count) + .map(|index| TxInput { + outpoint: OutPoint { + txid: format!("{index:064x}"), + index: index as u32, + }, + owner: "a".repeat(64), + signature: "b".repeat(128), + }) + .collect(), + change: Vec::new(), + amount: 1, + fee, + anchor: None, + signature: "c".repeat(128), + } + } + + #[test] + fn fee_convergence_keeps_valid_candidate_when_transaction_size_oscillates() { + let large_size = burn_with_inputs(1, 2).economic_size_bytes() as u64; + let (transaction, estimate) = converge_fee_by_byte(1, |fee| { + let input_count = if fee < large_size { 2 } else { 1 }; + Ok(burn_with_inputs(fee, input_count)) + }) + .unwrap(); + + assert_eq!(estimate.fee, transaction.fee()); + assert!(estimate.fee >= transaction.economic_size_bytes() as u64); + } +} diff --git a/src/domain.rs b/src/domain.rs @@ -90,6 +90,8 @@ pub use validation::validate_address; use validation::{ canonical_transaction_size_bytes, validate_hash, validate_protocol_id, validate_signature, }; +#[cfg(feature = "e2e")] +pub use vdf::configure_e2e_vdf_round_divisor_for_tests; pub use vdf::{ VdfProgress, VdfProgressPhase, run_vdf, run_vdf_cancellable_with_progress, run_vdf_with_progress, verify_vdf, diff --git a/src/domain/vdf/mod.rs b/src/domain/vdf/mod.rs @@ -1,8 +1,12 @@ +use std::borrow::Cow; use std::{ sync::atomic::AtomicBool, time::{Duration, Instant}, }; +#[cfg(feature = "e2e")] +use std::sync::atomic::{AtomicU64, Ordering}; + use super::{Block, FinalizerMode, MAX_VDF_ROUNDS, VDF_TARGET_BLOCK_MS, decode_hex, hex_encode}; #[cfg(test)] @@ -19,6 +23,8 @@ mod reference; const VDF_SOLUTION_PREFIX: &str = "classgroup-wesolowski-bqfc-v1:"; const MIN_VDF_ROUNDS: u64 = 1; +#[cfg(feature = "e2e")] +static E2E_VDF_ROUND_DIVISOR: AtomicU64 = AtomicU64::new(1); pub(super) const VDF_RETARGET_WINDOW_BLOCKS: usize = 20; pub(super) const MAX_VDF_RETARGET_STEP_PERCENT: u128 = 2; pub(super) const VDF_RETARGET_DEADBAND_PERCENT: u128 = 10; @@ -40,6 +46,13 @@ pub enum VdfProgressPhase { Proof, } +/// Shortens newly produced and verified VDFs inside an isolated e2e test +/// process. Every participant in that process must use the same divisor. +#[cfg(feature = "e2e")] +pub fn configure_e2e_vdf_round_divisor_for_tests(divisor: u64) { + E2E_VDF_ROUND_DIVISOR.store(divisor.max(1), Ordering::Relaxed); +} + pub fn run_vdf(seed: &str, rounds: u64) -> String { run_vdf_with_progress(seed, rounds, Duration::MAX, |_| {}) } @@ -62,15 +75,16 @@ pub fn run_vdf_cancellable_with_progress( cancelled: &AtomicBool, mut progress: impl FnMut(VdfProgress), ) -> Option<String> { - let total_steps = rounds.saturating_mul(2); + let (proof_seed, proof_rounds) = vdf_proof_parameters(seed, rounds); + let total_steps = proof_rounds.saturating_mul(2); let mut last_progress = Instant::now(); let solution = wesolowski::prove_cancellable( - seed.as_bytes(), - rounds, + proof_seed.as_bytes(), + proof_rounds, |phase, completed_phase_rounds| { let completed_steps = match phase { VdfProgressPhase::Output => completed_phase_rounds, - VdfProgressPhase::Proof => rounds.saturating_add(completed_phase_rounds), + VdfProgressPhase::Proof => proof_rounds.saturating_add(completed_phase_rounds), }; maybe_report_vdf_progress( &mut last_progress, @@ -79,7 +93,7 @@ pub fn run_vdf_cancellable_with_progress( completed_steps, total_steps, completed_phase_rounds, - phase_rounds: rounds, + phase_rounds: proof_rounds, phase, }, &mut progress, @@ -95,7 +109,22 @@ pub fn verify_vdf(seed: &str, rounds: u64, solution: &str) -> bool { let Some(solution) = decode_vdf_solution(solution) else { return false; }; - wesolowski::verify(seed.as_bytes(), rounds, &solution) + let (proof_seed, proof_rounds) = vdf_proof_parameters(seed, rounds); + wesolowski::verify(proof_seed.as_bytes(), proof_rounds, &solution) +} + +fn vdf_proof_parameters(seed: &str, rounds: u64) -> (Cow<'_, str>, u64) { + #[cfg(feature = "e2e")] + { + let divisor = E2E_VDF_ROUND_DIVISOR.load(Ordering::Relaxed); + if divisor > 1 { + return ( + Cow::Owned(format!("iuna-e2e-vdf:{rounds}:{seed}")), + rounds.div_ceil(divisor).max(MIN_VDF_ROUNDS), + ); + } + } + (Cow::Borrowed(seed), rounds) } pub(super) fn vdf_solution_placeholder() -> String { diff --git a/tests/properties.rs b/tests/properties.rs @@ -13,7 +13,7 @@ use iuna::{ app::{NodeCore, PeerBook, SharedNode, now_ms}, domain::{ Ledger, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, Transaction, VDF_TARGET_BLOCK_MS, Wallet, - run_vdf, + configure_e2e_vdf_round_divisor_for_tests, run_vdf, }, }; use serde_json::{Value, json}; @@ -26,6 +26,7 @@ use tokio::{ }; const SOAK_BLOCKS: u64 = 12; +const SOAK_VDF_ROUND_DIVISOR: u64 = 100; const BURN_COLLECTION_MS: u64 = VDF_TARGET_BLOCK_MS / 20 + 1; const SOAK_START_HEIGHT: u64 = OBJECTIVE_FINALITY_ACTIVATION_HEIGHT + 1; const FIXTURE_SERVICES: [&str; 6] = ["bootstrap", "node2", "node3", "node4", "node5", "node6"]; @@ -33,6 +34,7 @@ const FIXTURE_SERVICES: [&str; 6] = ["bootstrap", "node2", "node3", "node4", "no #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "long-running post-activation soak; run with cargo test --release --features e2e --test properties -- --ignored"] async fn release_soak_post_activation_auto_finalization_p2p_stratum_and_restarts() -> Result<()> { + configure_e2e_vdf_round_divisor_for_tests(SOAK_VDF_ROUND_DIVISOR); let (wallets, genesis) = post_activation_fixture()?; let p2p_addrs = reserve_loopback_addrs(wallets.len())?; let stratum_addr = reserve_loopback_addrs(1)?.remove(0); @@ -223,10 +225,11 @@ async fn finalize_one_block(nodes: &[SoakNode], target_height: u64) -> Result<() let deadline = tokio::time::Instant::now() + Duration::from_secs(8); loop { for node in nodes { - if let Some(block) = complete_if_ready(node, now_ms()).await? { - node.network - .broadcast(node.node.lock().await.drain_outbox()) - .await?; + let block = complete_if_ready(node, now_ms()).await?; + node.network + .broadcast(node.node.lock().await.drain_outbox()) + .await?; + if let Some(block) = block { assert_eq!(block.height, target_height); return Ok(()); }