iuna

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

commit 7ebaf78221b37d44fab30a68a663094adcb28111
parent 81221c7a43784310cb6219653e573363cca8f5be
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 31 Jul 2026 10:15:46 +0200

Dampen VDF retarget oscillation

Diffstat:
Msrc/domain.rs | 42++++++++++++++++++++++++++++++++++++++----
Mtests/iuna.rs | 6+++---
Mtests/properties.rs | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 120 insertions(+), 8 deletions(-)

diff --git a/src/domain.rs b/src/domain.rs @@ -32,8 +32,9 @@ const MAX_BLOCK_TRANSACTIONS: usize = 1_000; const DEFAULT_TICKET_MATURITY_DELAY: u64 = 3; const DEFAULT_TICKET_EXPIRY_WINDOW: u64 = 3; const MIN_VDF_ROUNDS: u64 = 1; -const VDF_RETARGET_WINDOW_BLOCKS: usize = 10; -const MAX_VDF_RETARGET_STEP_PERCENT: u128 = 10; +const VDF_RETARGET_WINDOW_BLOCKS: usize = 20; +const MAX_VDF_RETARGET_STEP_PERCENT: u128 = 2; +const VDF_RETARGET_DEADBAND_PERCENT: u128 = 10; const MIN_VDF_RETARGET_OBSERVED_BLOCK_MS: u64 = VDF_TARGET_BLOCK_MS / 4; const MAX_VDF_RETARGET_OBSERVED_BLOCK_MS: u64 = VDF_TARGET_BLOCK_MS * 4; const MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS: u64 = 2 * 60 * 1_000; @@ -3431,7 +3432,13 @@ fn is_odd_prime(candidate: u64) -> bool { fn retarget_vdf_rounds(current_rounds: u64, observed_block_ms: u64) -> u64 { let current = u128::from(current_rounds); let observed = u128::from(observed_block_ms.max(1)); - let raw_adjusted = current * u128::from(VDF_TARGET_BLOCK_MS) / observed; + let target = u128::from(VDF_TARGET_BLOCK_MS); + let deadband = target * VDF_RETARGET_DEADBAND_PERCENT / 100; + if observed >= target.saturating_sub(deadband) && observed <= target.saturating_add(deadband) { + return current_rounds; + } + + let raw_adjusted = current * target / observed; let max_step = (current * MAX_VDF_RETARGET_STEP_PERCENT / 100).max(1); let min_next = current .saturating_sub(max_step) @@ -3868,6 +3875,33 @@ mod tests { } #[test] + fn vdf_retarget_keeps_rounds_inside_deadband() { + let current = 1_000; + let low_deadband_edge = + VDF_TARGET_BLOCK_MS - VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100; + let high_deadband_edge = + VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100; + + assert_eq!(retarget_vdf_rounds(current, low_deadband_edge), current); + assert_eq!(retarget_vdf_rounds(current, VDF_TARGET_BLOCK_MS), current); + assert_eq!(retarget_vdf_rounds(current, high_deadband_edge), current); + } + + #[test] + fn vdf_retarget_limits_each_step_to_two_percent() { + let current = 1_000; + + assert_eq!( + retarget_vdf_rounds(current, MIN_VDF_RETARGET_OBSERVED_BLOCK_MS), + 1_020 + ); + assert_eq!( + retarget_vdf_rounds(current, MAX_VDF_RETARGET_OBSERVED_BLOCK_MS), + 980 + ); + } + + #[test] fn vdf_rounds_retarget_above_legacy_u32_limit_after_fast_blocks() { let wallet = Wallet::from_seed("vdf-rounds-above-u32"); let initial_rounds = u64::from(u32::MAX); @@ -3901,7 +3935,7 @@ mod tests { MIN_VDF_RETARGET_OBSERVED_BLOCK_MS, 300_000, 415_000, - VDF_TARGET_BLOCK_MS - 1, + VDF_TARGET_BLOCK_MS * 4 / 5, ]; for seed in 0..16_u64 { diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -1066,7 +1066,7 @@ fn vdf_rounds_retarget_toward_target_block_time() { .unwrap(); assert_eq!(block2.vdf_rounds, 100); ledger.apply_block(block2).unwrap(); - assert_eq!(ledger.vdf_rounds(), 110); + assert_eq!(ledger.vdf_rounds(), 102); submit_burn(&mut ledger, &wallet, 1); let block3 = ledger @@ -1075,9 +1075,9 @@ fn vdf_rounds_retarget_toward_target_block_time() { VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS / 2 + VDF_TARGET_BLOCK_MS * 2, ) .unwrap(); - assert_eq!(block3.vdf_rounds, 110); + assert_eq!(block3.vdf_rounds, 102); ledger.apply_block(block3).unwrap(); - assert_eq!(ledger.vdf_rounds(), 99); + assert_eq!(ledger.vdf_rounds(), 100); } #[test] diff --git a/tests/properties.rs b/tests/properties.rs @@ -4,7 +4,7 @@ use iuna::{ app::{InMemoryNetwork, NodeCore}, domain::{ Amount, ChainSnapshot, GenesisBurn, Ledger, MICRO_IUNA, MINE_REWARD, OutPoint, Transaction, - TxInput, TxOutput, Wallet, hex_hash, verify_vdf, + TxInput, TxOutput, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, verify_vdf, }, }; @@ -16,6 +16,9 @@ const TAMPER_PROPERTY_SEEDS: std::ops::Range<u64> = 200..208; const FORK_PROPERTY_SEEDS: std::ops::Range<u64> = 300..306; const NETWORK_CHAOS_SEEDS: std::ops::Range<u64> = 400..405; const NETWORK_CHAOS_ROUNDS: usize = 10; +const VDF_STABILITY_SEEDS: std::ops::Range<u64> = 500..516; +const VDF_STABILITY_BLOCKS: usize = 128; +const VDF_STABILITY_INITIAL_ROUNDS: u64 = 1_000_000; #[derive(Clone, Debug)] struct TestRng { @@ -89,6 +92,19 @@ fn single_finalizer_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledg (wallets, ledger) } +fn vdf_stability_ledger(seed: u64) -> (Wallet, Ledger) { + let wallet = Wallet::from_seed(&format!("vdf-stability-{seed}")); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 250 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], + VDF_STABILITY_INITIAL_ROUNDS, + ) + .expect("vdf stability genesis is valid"); + (wallet, ledger) +} + fn assert_chain_properties(snapshot: ChainSnapshot) { let replayed = Ledger::from_snapshot(snapshot.clone()).expect("snapshot replays as valid chain"); @@ -416,6 +432,22 @@ fn finalize_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) ledger.apply_block(block).expect("finalizer block applies"); } +fn finalize_preverified_with_wallet(ledger: &mut Ledger, wallet: &Wallet, timestamp_ms: u64) { + let burn = ledger + .build_burn(wallet, MICRO_IUNA, 0) + .expect("finalizer can build burn"); + ledger + .submit_transaction(burn) + .expect("finalizer burn enters mempool"); + let work = ledger + .prepare_next_block(wallet.address(), timestamp_ms) + .expect("finalizer can prepare next block"); + let block = work.finish(wallet, "property-vdf".to_string()); + ledger + .apply_locally_mined_block(block) + .expect("locally mined block applies"); +} + fn finalize_many(ledger: &mut Ledger, wallet: &Wallet, count: usize, start_timestamp_ms: u64) { for offset in 0..count { finalize_with_wallet(ledger, wallet, start_timestamp_ms + offset as u64); @@ -423,6 +455,52 @@ fn finalize_many(ledger: &mut Ledger, wallet: &Wallet, count: usize, start_times } #[test] +fn generated_vdf_retarget_stays_stable_under_noisy_block_times() { + for seed in VDF_STABILITY_SEEDS { + let (wallet, mut ledger) = vdf_stability_ledger(seed); + let mut rng = TestRng::new(seed); + let mut timestamp_ms = 0_u64; + let mut min_rounds = ledger.vdf_rounds(); + let mut max_rounds = ledger.vdf_rounds(); + let mut previous_rounds = ledger.vdf_rounds(); + let mut pair_jitter_ms = 0_u64; + + for block_index in 0..VDF_STABILITY_BLOCKS { + let interval_ms = if block_index == 0 { + VDF_TARGET_BLOCK_MS + } else if block_index % 2 == 1 { + pair_jitter_ms = rng.next_u64() % (VDF_TARGET_BLOCK_MS / 4 + 1); + VDF_TARGET_BLOCK_MS.saturating_sub(pair_jitter_ms) + } else { + VDF_TARGET_BLOCK_MS + pair_jitter_ms + }; + timestamp_ms = timestamp_ms + .checked_add(interval_ms) + .expect("property timestamp does not overflow"); + + finalize_preverified_with_wallet(&mut ledger, &wallet, timestamp_ms); + + let rounds = ledger.vdf_rounds(); + let max_step = (previous_rounds * 2 / 100).max(1); + assert!( + rounds.abs_diff(previous_rounds) <= max_step, + "seed {seed} block {block_index}: VDF rounds changed from {previous_rounds} to {rounds}, above max step {max_step}" + ); + min_rounds = min_rounds.min(rounds); + max_rounds = max_rounds.max(rounds); + previous_rounds = rounds; + } + + let lower_bound = VDF_STABILITY_INITIAL_ROUNDS * 95 / 100; + let upper_bound = VDF_STABILITY_INITIAL_ROUNDS * 105 / 100; + assert!( + min_rounds >= lower_bound && max_rounds <= upper_bound, + "seed {seed}: VDF rounds drifted outside stability band: min {min_rounds}, max {max_rounds}" + ); + } +} + +#[test] fn generated_chain_snapshots_preserve_core_invariants() { for seed in LEDGER_PROPERTY_SEEDS { let (wallets, mut ledger) = property_ledger(seed, 4);