commit 7ebaf78221b37d44fab30a68a663094adcb28111
parent 81221c7a43784310cb6219653e573363cca8f5be
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 31 Jul 2026 10:15:46 +0200
Dampen VDF retarget oscillation
Diffstat:
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);