diff --git a/Cargo.lock b/Cargo.lock index 41e77fc7..e76502c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6683,6 +6683,7 @@ dependencies = [ "pallet-timestamp", "parity-scale-codec", "primitive-types 0.13.1", + "proptest", "qp-poseidon-core", "qpow-math", "scale-info", diff --git a/client/consensus/qpow/src/lib.rs b/client/consensus/qpow/src/lib.rs index 9d8c9b11..bee33292 100644 --- a/client/consensus/qpow/src/lib.rs +++ b/client/consensus/qpow/src/lib.rs @@ -46,8 +46,6 @@ pub enum Error { InvalidSeal, #[error("PoW validation error: preliminary verification failed")] FailedPreliminaryVerify, - #[error("Rejecting block too far in future")] - TooFarInFuture, #[error("Fetching best header failed: {0}")] BestHeader(sp_blockchain::Error), #[error("Best header does not exist")] diff --git a/pallets/qpow/Cargo.toml b/pallets/qpow/Cargo.toml index 7d9f94fe..45177132 100644 --- a/pallets/qpow/Cargo.toml +++ b/pallets/qpow/Cargo.toml @@ -35,6 +35,7 @@ sp-runtime.workspace = true [dev-dependencies] primitive-types.workspace = true +proptest = "1" [features] default = ["std"] diff --git a/pallets/qpow/src/lib.rs b/pallets/qpow/src/lib.rs index fd62ef9a..18eaf792 100644 --- a/pallets/qpow/src/lib.rs +++ b/pallets/qpow/src/lib.rs @@ -22,12 +22,11 @@ pub mod pallet { use core::ops::Shr; use frame_support::{ pallet_prelude::*, - sp_runtime::{traits::One, SaturatedConversion, Saturating}, + sp_runtime::{traits::One, SaturatedConversion}, traits::{BuildGenesisConfig, Time}, }; use frame_system::pallet_prelude::BlockNumberFor; use qpow_math::{achieved_difficulty_from_hash, get_nonce_hash, is_valid_nonce}; - use sp_arithmetic::FixedU128; use sp_core::U512; pub type NonceType = [u8; 64]; @@ -54,17 +53,46 @@ pub mod pallet { #[pallet::config] pub trait Config: frame_system::Config + pallet_timestamp::Config { - /// Pallet's weight info + /// Genesis mining difficulty. Must satisfy + /// `InitialDifficulty >= DifficultyBoundDivisor` so the per-block step + /// `parent_difficulty / DifficultyBoundDivisor` is at least 1. #[pallet::constant] type InitialDifficulty: Get; + /// Ethereum's `DIFF_BOUND_DIVISOR` (EIP-2). The per-block unit step is + /// `parent_difficulty / DifficultyBoundDivisor`, applied additively in + /// both directions. Standard Ethereum value is `2048`. #[pallet::constant] - type DifficultyAdjustPercentClamp: Get; + type DifficultyBoundDivisor: Get; + + /// Bucket size in milliseconds for computing the signed adjustment + /// factor (EIP-2's `// 10` divisor, generalised). The factor is + /// `MaxUpAdjFactor - (block_time_ms / BlockTimeBucketMs)`, then + /// clamped to `[MaxDownAdjFactor, MaxUpAdjFactor]`. With + /// `MaxUpAdjFactor = 1` the no-change band is `[bucket, 2*bucket)`; + /// pick `bucket ≈ 2 * target / 3` to centre the band on the target. + #[pallet::constant] + type BlockTimeBucketMs: Get; + + /// Maximum upward adjustment factor (Ethereum Homestead = 1, + /// Byzantium = 2 when the parent has uncles; Quantus has no uncles, + /// so use 1). + #[pallet::constant] + type MaxUpAdjFactor: Get; + + /// Minimum (most-negative) adjustment factor cap. Ethereum uses + /// `-99`, which triggers only when a single block takes + /// `(MaxUpAdjFactor - MaxDownAdjFactor) * BlockTimeBucketMs` or + /// longer (≈13 minutes for the standard `(1, -99, 8s)` set). + #[pallet::constant] + type MaxDownAdjFactor: Get; #[pallet::constant] type TargetBlockTime: Get; - /// EMA smoothing factor (0-1000, where 1000 = 1.0) + /// EMA smoothing factor used only for the observability runtime API + /// `get_block_time_ema`. **Does not** drive the difficulty + /// controller (see EIP-2 §Rationale). Scaled by 1000. #[pallet::constant] type EmaAlpha: Get; @@ -183,118 +211,141 @@ pub mod pallet { } fn adjust_difficulty() { - // Get current time let now = pallet_timestamp::Pallet::::now().saturated_into::(); let last_time = >::get(); let current_difficulty = >::get(); let current_block_number = >::block_number(); + let target_time = T::TargetBlockTime::get(); - // Only calculate block time if we're past the genesis block - if current_block_number > One::one() { - let block_time = now.saturating_sub(last_time); - + // On the first non-genesis block we have no real previous timestamp, + // so feed the controller `target_time` (i.e. adj_factor = 0). + let block_time = if current_block_number > One::one() { + let bt = now.saturating_sub(last_time); log::debug!(target: "qpow", "Time calculation: now={}, last_time={}, diff={}ms", - now, - last_time, - block_time + now, last_time, bt ); + >::put(bt); + Self::update_block_time_ema(bt); + bt + } else { + target_time + }; - // Store the actual block duration - >::put(block_time); - - Self::update_block_time_ema(block_time); - } - - // Add last block time for the next calculations >::put(now); - let observed_block_time = >::get(); - let target_time = T::TargetBlockTime::get(); - let new_difficulty = - Self::calculate_difficulty(current_difficulty, observed_block_time, target_time); + Self::calculate_difficulty(current_difficulty, block_time, target_time); - // Save new difficulty >::put(new_difficulty); - log::debug!(target: "qpow", "Stored new difficulty: {}", - new_difficulty.low_u128()); - - // Propagate new Event Self::deposit_event(Event::DifficultyAdjusted { old_difficulty: current_difficulty, new_difficulty, - observed_block_time, + observed_block_time: block_time, }); let (pct_change, is_positive) = Self::percentage_change(current_difficulty, new_difficulty); log::debug!(target: "qpow", - "🟢 Adjusted mining difficulty {}{}%: {:x} -> {:x} (observed block time: {}ms, target: {}ms) ", + "🟢 Adjusted mining difficulty {}{}%: {:x} -> {:x} (block_time={}ms target={}ms)", if is_positive {"+"} else {"-"}, pct_change, current_difficulty.low_u64(), new_difficulty.low_u64(), - observed_block_time, + block_time, target_time ); } + /// Difficulty adjustment per Ethereum EIP-2 / EIP-100, in its additive form: + /// + /// ```text + /// adj_factor = clamp(MaxUpAdjFactor - block_time_ms / BlockTimeBucketMs, + /// MaxDownAdjFactor, MaxUpAdjFactor) + /// step = parent_difficulty / DifficultyBoundDivisor + /// new_difficulty = clamp(parent_difficulty + step * adj_factor, + /// min_difficulty, max_difficulty) + /// ``` + /// + /// Input is the **single block's** wall-clock time, not a moving average. + /// `target_block_time` is unused but kept in the signature for ABI + /// stability with callers that still pass it. pub fn calculate_difficulty( current_difficulty: U512, observed_block_time: u64, - target_block_time: u64, + _target_block_time: u64, ) -> U512 { - log::debug!(target: "qpow", "📊 Calculating new difficulty ---------------------------------------------"); - let observed_block_time = observed_block_time.max(1); - let clamp = T::DifficultyAdjustPercentClamp::get(); // 10% - let one = FixedU128::one(); - let ratio = - FixedU128::from_rational(target_block_time as u128, observed_block_time as u128) - .min(one.saturating_add(clamp)) - .max(one.saturating_sub(clamp)); - log::debug!(target: "qpow", "💧 Clamped block_time ratio as FixedU128: {} ", ratio); - - let ratio_512 = U512::from(ratio.into_inner()); - let max_difficulty = Self::get_max_difficulty(); + let bucket = T::BlockTimeBucketMs::get().max(1); + let max_up = T::MaxUpAdjFactor::get(); + let max_down = T::MaxDownAdjFactor::get(); + let divisor = T::DifficultyBoundDivisor::get(); + + if divisor.is_zero() { + log::error!( + target: "qpow", + "DifficultyBoundDivisor is zero; controller is misconfigured. Returning current difficulty." + ); + return current_difficulty; + } + if max_down > max_up { + log::error!( + target: "qpow", + "MaxDownAdjFactor ({}) > MaxUpAdjFactor ({}); controller is misconfigured. Returning current difficulty.", + max_down, max_up + ); + return current_difficulty; + } - // For Bitcoin-style difficulty adjustment: - // If observed_time > target_time (slow blocks), difficulty should decrease - // If observed_time < target_time (fast blocks), difficulty should increase - // new_difficulty = current_difficulty * target_time / observed_time - let mut adjusted = match current_difficulty.checked_mul(ratio_512) { - Some(numerator) => { - // unchecked division, we know the denominator is not zero - let result = numerator / U512::from(one.into_inner()); - log::debug!(target: "qpow", - "Difficulty calculation: current={:x}, target_time={}, observed_time={}, new={:x}", - current_difficulty.low_u32() as u16, target_block_time, observed_block_time, result.low_u32() as u16); - result - }, - None => { - log::error!("Multiplication overflow in difficulty calculation"); - return max_difficulty; - }, + let buckets_elapsed_u64 = observed_block_time / bucket; + let buckets_elapsed: i32 = if buckets_elapsed_u64 > i32::MAX as u64 { + i32::MAX + } else { + buckets_elapsed_u64 as i32 + }; + let adj_factor = max_up.saturating_sub(buckets_elapsed).max(max_down); + + let step = current_difficulty / divisor; + let abs_adj = U512::from(adj_factor.unsigned_abs()); + let delta = step.saturating_mul(abs_adj); + + let raw_adjusted = if adj_factor >= 0 { + current_difficulty.saturating_add(delta) + } else { + current_difficulty.saturating_sub(delta) }; let min_difficulty = Self::get_min_difficulty(); - if adjusted < min_difficulty { - log::warn!("Min difficulty achieved, clipping to: {:x}", min_difficulty.low_u64()); - adjusted = min_difficulty; - } else if adjusted > max_difficulty { - log::warn!("Max difficulty achieved, clipping to: {:x}", max_difficulty.low_u64()); - adjusted = max_difficulty; - } + let max_difficulty = Self::get_max_difficulty(); + let adjusted = if raw_adjusted < min_difficulty { + log::warn!( + target: "qpow", + "difficulty clipped UP to floor: raw={:x} -> min={:x} (block_time={}ms, adj={})", + raw_adjusted.low_u64(), min_difficulty.low_u64(), observed_block_time, adj_factor + ); + min_difficulty + } else if raw_adjusted > max_difficulty { + log::warn!( + target: "qpow", + "difficulty clipped DOWN to ceiling: raw_low={:x} -> max (block_time={}ms, adj={})", + raw_adjusted.low_u64(), observed_block_time, adj_factor + ); + max_difficulty + } else { + raw_adjusted + }; log::debug!(target: "qpow", - "🟢 Current Difficulty: {:x}", - current_difficulty.low_u64() + "📊 current={:x} block_time={}ms buckets={} adj={} step={:x} delta={:x} new={:x}", + current_difficulty.low_u64(), + observed_block_time, + buckets_elapsed, + adj_factor, + step.low_u64(), + delta.low_u64(), + adjusted.low_u64() ); - log::debug!(target: "qpow", "🟢 Next Difficulty: {:x}", adjusted.low_u64()); - log::debug!(target: "qpow", "🕒 Observed Block Time Sum: {}ms", observed_block_time); - log::debug!(target: "qpow", "🎯 Target Block Time Sum: {target_block_time}ms"); adjusted } @@ -387,10 +438,13 @@ pub mod pallet { } pub fn get_min_difficulty() -> Difficulty { - // This value is related to clamp value, - // ie, if clamp is 10% this value must be at least 10 - // 1000 is safe for clamp values >= 0.01% - U512::from(1000u64) + // Constraint: `min_difficulty >= DifficultyBoundDivisor`, otherwise the + // per-block step `min_difficulty / DifficultyBoundDivisor` floors to + // zero and the controller cannot ever lift difficulty off the floor. + // We additionally floor at Ethereum's `MinimumDifficulty` (2^17 = + // 131_072) so the smallest valid network still requires real work. + let divisor = T::DifficultyBoundDivisor::get(); + U512::from(131_072u64).max(divisor) } pub fn get_max_difficulty() -> Difficulty { diff --git a/pallets/qpow/src/mock.rs b/pallets/qpow/src/mock.rs index 2022f124..ba47ee3b 100644 --- a/pallets/qpow/src/mock.rs +++ b/pallets/qpow/src/mock.rs @@ -2,13 +2,13 @@ use crate as pallet_qpow; use frame_support::{ pallet_prelude::ConstU32, parameter_types, - traits::{ConstU64, Everything}, + traits::{ConstI32, ConstU64, Everything}, }; use primitive_types::U512; use sp_core::H256; use sp_runtime::{ traits::{BlakeTwo256, IdentityLookup}, - BuildStorage, FixedU128, + BuildStorage, }; type Block = frame_system::mocking::MockBlock; @@ -69,15 +69,21 @@ impl pallet_timestamp::Config for Test { } parameter_types! { - pub const TestInitialDifficulty: U512 = U512([1000000, 0, 0, 0, 0, 0, 0, 0]); - pub const TestDifficultyAdjustPercentClamp: FixedU128 = FixedU128::from_rational(10, 100); + /// Test target = 1000ms. Bucket = 750ms places `target` inside the + /// `[bucket, 2*bucket) = [750, 1500)` no-change band, mirroring the + /// runtime's `bucket ≈ 2*target/3` design. + pub const TestInitialDifficulty: U512 = U512([1_000_000, 0, 0, 0, 0, 0, 0, 0]); + pub const TestDifficultyBoundDivisor: U512 = U512([2048, 0, 0, 0, 0, 0, 0, 0]); } impl pallet_qpow::Config for Test { type WeightInfo = (); - type EmaAlpha = ConstU32<500>; + type EmaAlpha = ConstU32<100>; type InitialDifficulty = TestInitialDifficulty; - type DifficultyAdjustPercentClamp = TestDifficultyAdjustPercentClamp; + type DifficultyBoundDivisor = TestDifficultyBoundDivisor; + type BlockTimeBucketMs = ConstU64<750>; + type MaxUpAdjFactor = ConstI32<1>; + type MaxDownAdjFactor = ConstI32<-99>; type TargetBlockTime = ConstU64<1000>; type MaxReorgDepth = ConstU32<10>; } diff --git a/pallets/qpow/src/tests.rs b/pallets/qpow/src/tests.rs index e2c2c142..762a6790 100644 --- a/pallets/qpow/src/tests.rs +++ b/pallets/qpow/src/tests.rs @@ -109,8 +109,10 @@ fn test_difficulty_bounds() { let min_difficulty = QPow::get_min_difficulty(); let max_difficulty = QPow::get_max_difficulty(); let initial_difficulty = QPow::initial_difficulty(); + let divisor = ::DifficultyBoundDivisor::get(); - assert_eq!(min_difficulty, U512::from(1000u64)); + assert!(min_difficulty >= divisor, "floor must allow non-zero step"); + assert_eq!(min_difficulty, U512::from(131_072u64)); assert!(max_difficulty > initial_difficulty); assert!(initial_difficulty > min_difficulty); }); @@ -196,19 +198,117 @@ fn test_ema_block_time_tracking() { #[test] fn test_difficulty_calculation() { new_test_ext().execute_with(|| { - let current_difficulty = U512::from(1000u64); - let observed_time = 2000u64; // 2x target - let target_time = 1000u64; + // Mid-difficulty value far from min/max so adjustments are visible. + let current_difficulty = U512::from(1_000_000u64); - // When blocks are slow, difficulty should decrease - let new_difficulty = - QPow::calculate_difficulty(current_difficulty, observed_time, target_time); + // Slow block (2x target). With bucket=750ms and target=1000ms, + // buckets_elapsed = 2000/750 = 2, adj_factor = 1 - 2 = -1. + // step = 1_000_000 / 2048 = 488. Δ = -488. New = 999_512. + let slower = QPow::calculate_difficulty(current_difficulty, 2000, 1000); + assert!(slower < current_difficulty); + + // Fast block (sub-bucket). adj_factor = +1. Δ = +488. New = 1_000_488. + let faster = QPow::calculate_difficulty(current_difficulty, 100, 1000); + assert!(faster > current_difficulty); + + // At-target block sits in the no-change band [750, 1500). + let unchanged = QPow::calculate_difficulty(current_difficulty, 1000, 1000); + assert_eq!(unchanged, current_difficulty); - // Should be bounded by min/max let min_difficulty = QPow::get_min_difficulty(); let max_difficulty = QPow::get_max_difficulty(); - assert!(new_difficulty >= min_difficulty); - assert!(new_difficulty <= max_difficulty); + assert!(slower >= min_difficulty && slower <= max_difficulty); + assert!(faster >= min_difficulty && faster <= max_difficulty); + }); +} + +#[test] +fn test_adj_factor_table() { + new_test_ext().execute_with(|| { + // With bucket=750ms, max_up=+1, max_down=-99, divisor=2048: + // adj_factor = clamp(1 - block_time/750, -99, 1) + let d = U512::from(2_048_000_000u64); // step = 1_000_000 + + // block_time in [0, 750): buckets=0, adj=+1, Δ=+1_000_000 + let r = QPow::calculate_difficulty(d, 0, 1000); + assert_eq!(r, d + U512::from(1_000_000u64)); + let r = QPow::calculate_difficulty(d, 749, 1000); + assert_eq!(r, d + U512::from(1_000_000u64)); + + // block_time in [750, 1500): buckets=1, adj=0, no change + let r = QPow::calculate_difficulty(d, 750, 1000); + assert_eq!(r, d); + let r = QPow::calculate_difficulty(d, 1499, 1000); + assert_eq!(r, d); + + // block_time in [1500, 2250): buckets=2, adj=-1 + let r = QPow::calculate_difficulty(d, 1500, 1000); + assert_eq!(r, d - U512::from(1_000_000u64)); + + // block_time in [75_000, 75_750): buckets=100, adj=-99 (cap) + let r = QPow::calculate_difficulty(d, 75_000, 1000); + assert_eq!(r, d - U512::from(99_000_000u64)); + + // Far past the cap: still -99 + let r = QPow::calculate_difficulty(d, 10_000_000, 1000); + assert_eq!(r, d - U512::from(99_000_000u64)); + + // Pathological u64::MAX block_time: still -99, saturates cleanly + let r = QPow::calculate_difficulty(d, u64::MAX, 1000); + assert_eq!(r, d - U512::from(99_000_000u64)); + }); +} + +#[test] +fn test_min_difficulty_escape_from_floor() { + // Critical regression: with the new additive form and a min derived from the + // divisor, the floor must be escapable in a single fast block. The + // pre-existing multiplicative-clamp implementation had a floor trap at + // min=1000 with up-clamp=1/2048 where 1000*(1+1/2048) truncated back to 1000. + new_test_ext().execute_with(|| { + let min_diff = QPow::get_min_difficulty(); + assert!( + min_diff >= U512::from(131_072u64), + "min should be >= Ethereum's MinimumDifficulty" + ); + + let lifted = QPow::calculate_difficulty(min_diff, 0, 1000); + assert!( + lifted > min_diff, + "floor must be liftable: lifted={} min={}", + lifted.low_u64(), + min_diff.low_u64() + ); + + // Step at the floor should equal exactly +(min/divisor). + let divisor = ::DifficultyBoundDivisor::get(); + let expected_step = min_diff / divisor; + assert_eq!(lifted, min_diff + expected_step); + }); +} + +#[test] +fn test_overflow_saturation() { + new_test_ext().execute_with(|| { + // Max difficulty: fast block should saturate, not overflow. + let max = QPow::get_max_difficulty(); + let r = QPow::calculate_difficulty(max, 0, 1000); + assert_eq!(r, max); + + // Min difficulty: slow block should clip to min, not underflow. + let min = QPow::get_min_difficulty(); + let r = QPow::calculate_difficulty(min, u64::MAX, 1000); + assert_eq!(r, min); + }); +} + +#[test] +fn test_no_change_when_at_target() { + new_test_ext().execute_with(|| { + let d = U512::from(1_000_000u64); + // target=1000ms sits in the no-change band [750, 1500). + let r = QPow::calculate_difficulty(d, 1000, 1000); + assert_eq!(r, d); }); } @@ -330,74 +430,36 @@ fn test_difficulty_recovers_after_sleep() { new_test_ext().execute_with(|| { let target = ::TargetBlockTime::get(); + // Warm up at target — adj_factor = 0, no change. for i in 1u64..=10 { run_block(i, i * target); } - let pre_sleep = QPow::get_difficulty(); assert_eq!(pre_sleep, U512::from(1_000_000u64)); - // Simulate laptop sleep: 1-hour gap between blocks + // Simulate laptop sleep: 1-hour gap between blocks. With bucket=750ms, + // 3_600_000 / 750 = 4800 buckets → adj_factor = max(1-4800, -99) = -99. + // Step = 1_000_000 / 2048 = 488. Δ = -488*99 = -48_312. New = 951_688. run_block(11, 10 * target + 3_600_000); + let post_sleep = QPow::get_difficulty(); + assert!(post_sleep < pre_sleep); + assert!(post_sleep > pre_sleep * U512::from(95u64) / U512::from(100u64)); - // 20 normal blocks after waking + // 20 normal blocks at target → adj_factor=0, difficulty stays put. + // (Single-block input means the slow patch does not keep echoing into + // future adjustments — exactly the property EIP-2 §Rationale calls out.) for i in 12u64..=31 { run_block(i, 10 * target + 3_600_000 + (i - 11) * target); } - - let recovered = QPow::get_difficulty(); - // EMA smoothing limits the spike, but alpha=500 is aggressive so recovery - // takes many blocks. 20 normal blocks bring difficulty to ~18% of pre-sleep. - assert!( - recovered > pre_sleep / 10, - "Difficulty should stay above 10% after sleep. Pre: {}, Post: {}", - pre_sleep.low_u64(), - recovered.low_u64() - ); + let after_normal = QPow::get_difficulty(); + assert_eq!(after_normal, post_sleep, "no slow tail after one bad block"); }); } #[test] -fn test_zero_observed_block_time() { +fn test_min_difficulty_matches_ethereum_floor() { new_test_ext().execute_with(|| { - let difficulty = U512::from(1_000_000u64); - let result = QPow::calculate_difficulty(difficulty, 0, 1000); - let min = QPow::get_min_difficulty(); - let max = QPow::get_max_difficulty(); - assert!(result >= min); - assert!(result <= max); - }); -} - -#[test] -fn test_min_difficulty_derived_from_clamp() { - new_test_ext().execute_with(|| { - assert_eq!(QPow::get_min_difficulty(), U512::from(1000u64)); - }); -} - -#[test] -fn test_min_difficulty_can_increase() { - new_test_ext().execute_with(|| { - let min_diff = QPow::get_min_difficulty(); - // Fast blocks → ratio clamped to 1.1 → floor(1000 * 1.1) = 1100 - let result = QPow::calculate_difficulty(min_diff, 1, 1000); - assert!( - result > min_diff, - "Min difficulty must be able to increase: {} should be > {}", - result.low_u64(), - min_diff.low_u64() - ); - }); -} - -#[test] -fn test_min_difficulty_floors_on_slow_blocks() { - new_test_ext().execute_with(|| { - let min_diff = QPow::get_min_difficulty(); - // Slow blocks → ratio clamped to 0.9 → floor(1000 * 0.9) = 900, clips to 1000 - let result = QPow::calculate_difficulty(min_diff, 100_000, 1000); - assert_eq!(result, min_diff); + assert_eq!(QPow::get_min_difficulty(), U512::from(131_072u64)); }); } @@ -405,10 +467,90 @@ fn test_min_difficulty_floors_on_slow_blocks() { fn test_difficulty_below_min_clips_up() { new_test_ext().execute_with(|| { let min_diff = QPow::get_min_difficulty(); - // Starting at 1 (below min), any result clips to min_difficulty + // Starting at 1 (below min): step = 1/2048 = 0, but post-adjustment clip + // brings the value up to min_difficulty. let result_fast = QPow::calculate_difficulty(U512::from(1u64), 1, 1000); let result_slow = QPow::calculate_difficulty(U512::from(1u64), 100_000, 1000); assert_eq!(result_fast, min_diff); assert_eq!(result_slow, min_diff); }); } + +#[cfg(test)] +mod proptests { + use super::*; + use crate::mock::{new_test_ext, QPow, Test}; + use proptest::prelude::*; + + fn arb_difficulty() -> impl Strategy { + prop_oneof![ + Just(U512::from(131_072u64)), + Just(U512::MAX), + Just(U512::from(2_700_000u64)), + (1u128..=u128::MAX).prop_map(U512::from), + ] + } + + fn run(f: impl FnOnce() -> T) -> T { + new_test_ext().execute_with(f) + } + + proptest! { + #[test] + fn result_always_in_bounds(d in arb_difficulty(), bt in 0u64..=u64::MAX) { + let (r, min, max) = run(|| ( + QPow::calculate_difficulty(d, bt, 1000), + QPow::get_min_difficulty(), + QPow::get_max_difficulty(), + )); + prop_assert!(r >= min, "result {} < min {}", r.low_u64(), min.low_u64()); + prop_assert!(r <= max); + } + + #[test] + fn monotone_in_block_time( + d in arb_difficulty(), + bt1 in 0u64..1_000_000, + bt2 in 0u64..1_000_000, + ) { + let (a, b) = if bt1 <= bt2 { (bt1, bt2) } else { (bt2, bt1) }; + let (fast, slow) = run(|| ( + QPow::calculate_difficulty(d, a, 1000), + QPow::calculate_difficulty(d, b, 1000), + )); + prop_assert!(fast >= slow, + "monotonicity broken: bt={} -> {}, bt={} -> {}", + a, fast.low_u64(), b, slow.low_u64()); + } + + #[test] + fn no_change_band_is_flat(d in arb_difficulty(), offset in 0u64..750u64) { + let (r, expected) = run(|| { + let bucket = ::BlockTimeBucketMs::get(); + let r = QPow::calculate_difficulty(d, bucket + offset, 1000); + let min = QPow::get_min_difficulty(); + let max = QPow::get_max_difficulty(); + (r, d.max(min).min(max)) + }); + prop_assert_eq!(r, expected); + } + + #[test] + fn step_magnitude_bounded(d in arb_difficulty(), bt in 0u64..=u64::MAX) { + let (r, min, divisor) = run(|| ( + QPow::calculate_difficulty(d, bt, 1000), + QPow::get_min_difficulty(), + ::DifficultyBoundDivisor::get(), + )); + if d < min { return Ok(()); } + let max_factor = U512::from( + (::MaxUpAdjFactor::get() as u32) + .max(::MaxDownAdjFactor::get().unsigned_abs()), + ); + let max_delta = (d / divisor).saturating_mul(max_factor); + let actual_delta = if r >= d { r - d } else { d - r }; + prop_assert!(actual_delta <= max_delta, + "delta {} exceeds max step {}", actual_delta.low_u64(), max_delta.low_u64()); + } + } +} diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 272a26e6..9acf08bb 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -35,7 +35,8 @@ use crate::{ use frame_support::{ derive_impl, parameter_types, traits::{ - AsEnsureOriginWithArg, ConstU128, ConstU32, ConstU8, Get, NeverEnsureOrigin, VariantCountOf, + AsEnsureOriginWithArg, ConstI32, ConstU128, ConstU32, ConstU8, Get, NeverEnsureOrigin, + VariantCountOf, }, weights::{ constants::{RocksDbWeight, WEIGHT_REF_TIME_PER_SECOND}, @@ -55,7 +56,7 @@ use smallvec::smallvec; use qp_scheduler::BlockNumberOrTimestamp; use sp_runtime::{ traits::{BlakeTwo256, One}, - AccountId32, FixedU128, Perbill, Permill, + AccountId32, Perbill, Permill, }; use sp_version::RuntimeVersion; @@ -150,21 +151,35 @@ parameter_types! { /// Target block time ms pub const TargetBlockTime: u64 = TARGET_BLOCK_TIME_MS; pub const TimestampBucketSize: u64 = 2 * TARGET_BLOCK_TIME_MS; // Nyquist frequency - /// Initial mining difficulty - low value for development - pub const QPoWInitialDifficulty: U512 = U512([1189189, 0, 0, 0, 0, 0, 0, 0]); - /// Difficulty adjustment percent clamp - pub const DifficultyAdjustPercentClamp: FixedU128 = FixedU128::from_rational(10, 100); + /// Initial mining difficulty. Sized to give miners a meaningful warm-up window + /// above `MinimumDifficulty` (2^17) while remaining easy enough to bootstrap. + pub const QPoWInitialDifficulty: U512 = U512([2_700_000, 0, 0, 0, 0, 0, 0, 0]); + /// Ethereum EIP-2 `DIFF_BOUND_DIVISOR`. Per-block unit step is + /// `parent_difficulty / 2048`, applied additively in both directions. + pub const QPoWDifficultyBoundDivisor: U512 = U512([2048, 0, 0, 0, 0, 0, 0, 0]); + /// Bucket for the EIP-2 signed adjustment factor. With `MaxUpAdjFactor = 1` + /// the no-change band is `[bucket, 2*bucket)`; `2/3 * target` (8s for a 12s + /// target) centres that band on the target block time. + pub const QPoWBlockTimeBucketMs: u64 = (2 * TARGET_BLOCK_TIME_MS) / 3; } impl pallet_qpow::Config for Runtime { - // Starting difficulty - should be challenging enough to require some work but not too high type InitialDifficulty = QPoWInitialDifficulty; - type DifficultyAdjustPercentClamp = DifficultyAdjustPercentClamp; + type DifficultyBoundDivisor = QPoWDifficultyBoundDivisor; + type BlockTimeBucketMs = QPoWBlockTimeBucketMs; + /// Homestead value (Byzantium uses 2 only when the parent has uncles; Quantus + /// has no uncles). + type MaxUpAdjFactor = ConstI32<1>; + /// Ethereum EIP-2 `-99` cap. Triggers only when a single block takes + /// `(MaxUp - MaxDown) * BlockTimeBucketMs = 100 * 8s = 800s` (≈13 minutes). + type MaxDownAdjFactor = ConstI32<-99>; type TargetBlockTime = TargetBlockTime; type MaxReorgDepth = ConstU32<180>; - type WeightInfo = (); - type EmaAlpha = ConstU32<100>; // out of 1000, last_block_time * alpha + (previous_ema * (1 - alpha)) on moving average + /// EMA is exposed via `get_block_time_ema` runtime API for Prometheus + /// telemetry only; it does **not** drive the controller (see EIP-2 + /// §Rationale). + type EmaAlpha = ConstU32<100>; } parameter_types! { diff --git a/runtime/tests/qpow_difficulty.rs b/runtime/tests/qpow_difficulty.rs new file mode 100644 index 00000000..0810d138 --- /dev/null +++ b/runtime/tests/qpow_difficulty.rs @@ -0,0 +1,105 @@ +//! Runtime-parameter consistency checks for the QPoW difficulty controller. +//! +//! These run against the *actual* `quantus_runtime` constants (not the mock), +//! so any future re-tuning that breaks the EIP-2 invariants fails CI here. + +use frame_support::traits::Get; +use pallet_qpow::Config; +use primitive_types::U512; +use quantus_runtime::Runtime; + +fn bucket() -> u64 { + ::BlockTimeBucketMs::get() +} +fn target() -> u64 { + ::TargetBlockTime::get() +} +fn max_up() -> i32 { + ::MaxUpAdjFactor::get() +} +fn max_down() -> i32 { + ::MaxDownAdjFactor::get() +} +fn divisor() -> U512 { + ::DifficultyBoundDivisor::get() +} +fn initial() -> U512 { + ::InitialDifficulty::get() +} +fn min_diff() -> U512 { + pallet_qpow::Pallet::::get_min_difficulty() +} + +#[test] +fn target_sits_inside_no_change_band() { + // With `adj_factor = max_up - block_time / bucket` clamped to [max_down, max_up], + // the no-change band is [max_up * bucket, (max_up + 1) * bucket). The target + // must fall inside it, otherwise every on-target block adjusts difficulty. + let band_lo = bucket().saturating_mul(max_up() as u64); + let band_hi = bucket().saturating_mul(max_up() as u64 + 1); + assert!( + target() >= band_lo && target() < band_hi, + "target {} not in no-change band [{}, {})", + target(), + band_lo, + band_hi, + ); +} + +#[test] +fn floor_is_liftable() { + // `step = parent_difficulty / divisor` must be ≥ 1 at the floor, otherwise the + // controller cannot escape it (the bug that motivated PR #564's review). + assert!( + min_diff() >= divisor(), + "floor {} < divisor {} — controller cannot escape min", + min_diff().low_u64(), + divisor().low_u64(), + ); +} + +#[test] +fn floor_matches_ethereum_minimum_difficulty() { + assert_eq!(min_diff(), U512::from(131_072u64)); +} + +#[test] +fn down_cap_fires_only_on_real_stalls() { + // -99 cap should require a single block ≥ 5× target — the EIP-2 §Rationale + // "black swan" threshold, not routine slow blocks. + let cap_ms = (max_up() - max_down()) as u64 * bucket(); + assert!( + cap_ms > 5 * target(), + "-99 cap fires at {}ms, only {}× target — too aggressive", + cap_ms, + cap_ms / target(), + ); +} + +#[test] +fn initial_difficulty_above_floor() { + assert!( + initial() > min_diff(), + "initial {} <= min {} — chain starts at the floor", + initial().low_u64(), + min_diff().low_u64(), + ); + assert!( + initial() >= divisor(), + "initial must be ≥ divisor so the per-block step is at least 1 unit", + ); +} + +#[test] +fn at_target_block_produces_no_change() { + let d = U512::from(1_000_000u64); + let r = pallet_qpow::Pallet::::calculate_difficulty(d, target(), target()); + assert_eq!(r, d, "at-target block must not adjust difficulty"); +} + +#[test] +fn upper_band_edge_still_flat() { + let d = U512::from(1_000_000u64); + let r = pallet_qpow::Pallet::::calculate_difficulty(d, 2 * bucket() - 1, target()); + assert_eq!(r, d, "block_time = 2*bucket - 1 must still produce no change"); +}