qpow: Ethereum EIP-2 additive difficulty adjustment#565
Conversation
Replace the multiplicative-clamp controller with the EIP-2 additive form, drive it from the single-block time, derive min_difficulty from the DifficultyBoundDivisor (closes the 1000-floor trap), and keep the EMA purely as Prometheus telemetry.
|
Composer 2.5 review PR review: #565 — qpow: Ethereum EIP-2 additive difficulty adjustmentThis PR replaces the EMA-driven multiplicative retargeting on What changed (vs
|
| Aspect | main |
PR #565 |
|---|---|---|
| Input | EMA of block times | Single inter-block duration |
| Formula | difficulty × clamp(target/observed, 0.9…1.1) |
difficulty + (difficulty/2048) × adj_factor |
min_difficulty |
Fixed 1000 (floor trap) |
max(2^17, divisor) = 131_072 |
adj_factor |
Implicit in ratio | clamp(1 − t/bucket, −99, 1) |
| EMA | Controls difficulty | Telemetry only (Prometheus API preserved) |
The core implementation:
/// Difficulty adjustment per Ethereum EIP-2 / EIP-100, in its additive form:
// ...
pub fn calculate_difficulty(
current_difficulty: U512,
observed_block_time: u64,
_target_block_time: u64,
) -> U512 {
// ...
let adj_factor = max_up.saturating_sub(buckets_elapsed).max(max_down);
let step = current_difficulty / divisor;
// ...
}
Runtime constants match the PR table: 12 s target, bucket 8 s (2×target/3), divisor 2048, +1/-99 caps, initial difficulty 2_700_000.
Verification against Ethereum sources
EIP-2 (Homestead) — claims hold
Official formula (EIP-2 §Specification):
block_diff = parent_diff
+ parent_diff // 2048 * max(1 - (timestamp - parent.timestamp) // 10, -99)
+ ice_age_term
| PR claim | Verdict |
|---|---|
Additive parent_diff // 2048 × adj_factor |
Correct — same structure, ice age omitted (appropriate; Quantus has no bomb) |
-99 floor on adj_factor |
Correct |
| Single-block input, not smoothed average | Correct — EIP-2 §Rationale: coarse buckets + avoid slow EMA tail; fixes the pre-Homestead parent+1s gaming issue |
// 10 → generalized block_time / bucket |
Correct — ms buckets are equivalent to second buckets |
No-change band [bucket, 2×bucket) with max_up = 1 |
Correct — 12 s ∈ [8 s, 16 s) |
bucket ≈ target / 1.5 (Homestead ~15 s / 10 s) |
Correct — same geometric centering |
-99 stall threshold: Ethereum ≈ 100 × 10 s = 1000 s. Quantus: 100 × 8 s = 800 s (~13 min). PR math is right; only the bucket scale differs.
Minor doc nit: PR says a 30 s block gives −0.0488%. With an 8 s bucket, 30 s → 3 buckets → adj = −2 → ~−0.098% (2/2048). −0.0488% matches a ~16 s block (adj = −1). Worth fixing in the PR description.
EIP-100 (Byzantium) — omission is justified
EIP-100 adds:
- Uncle-aware
+1boost when parent has uncles - Denominator
// 10 → // 9to offset that bias
Quantus has no uncle reward path; applying // 9 without the uncle term would bias difficulty up ~10% with no compensating mechanism. Keeping Homestead max_up = 1 and divisor 2048 is the right call.
Uncle-rate manipulation (bitslog “Uncle Mining”, cited in EIP-100) is out of scope for this chain.
Floor trap regression — fixed
Old min = 1000 with multiplicative × (1 + ε) could stall under U512 integer math. New additive step at floor: 131_072 + 131_072/2048 = 131_136 in one fast block — covered by test_min_difficulty_escape_from_floor.
#564 comparison
#564 doubled down on EMA + asymmetric multiplicative clamps to reduce GPU wobble. #565 correctly identifies that EMA causes slow tails and asymmetric multiplicative rules bias drift. These are different design philosophies; #565 is better grounded in proven PoW retargeting literature.
Strengths
- Faithful EIP-2 core with sensible Quantus scaling (12 s / 8 s buckets).
- Fixes real bugs from review of EMA update wobble #564: floor trap, EMA echo, cumulative multiplicative drift.
- Strong unit coverage: boundary table, overflow, sleep-without-tail, floor escape.
- Observability preserved — EMA still feeds Prometheus; dashboards won’t break.
- Misconfig guards — zero divisor / inverted caps log errors and no-op (safe, though see nits).
Nits / risks
- Stale hook comment —
on_finalizestill says difficulty is adjusted “using EMA”; implementation no longer does. - Fail-soft on misconfig — returns current difficulty instead of panicking. Acceptable for production runtime, but worth a compile-time
static_assertor runtime integration test that constants are sane. target_block_timeunused incalculate_difficulty— fine for ABI stability; callers/tests should not assume it affects math.test_difficulty_recovers_after_sleepuses a loose> 95%bound; exact value is 951_688 from the PR’s own arithmetic — tightening would catch regressions.- Timestamp trust model — adjustment uses
pallet_timestampdeltas. Substrate inherents enforce monotonicity and reasonable drift, but this PR does not add PoW-specific timestamp rules (unlike Ethereum’s tight coupling of seal time to difficulty). Worth explicit devnet/fuzz tests. - PR test plan gap — multi-node GPU add/remove soak (the scenario that motivated EMA update wobble #564) is still unchecked.
Testing strategy
Layer 1 — Unit / pure function (extend current suite)
Already strong; add:
| Test | Attack / property |
|---|---|
| Golden vectors from EIP-2 | Hand-compute 5–10 (parent_diff, block_time) → child_diff pairs for runtime constants (8 s bucket) and assert bit-exact output |
| Exact sleep recovery | Replace > 95% with assert_eq!(951_688) after 1 h gap at d = 1_000_000 (mock params) |
| Monotonicity in band | For fixed d, f(t) non-increasing as t increases across bucket boundaries |
| Symmetry at target | Sequence of N blocks at exactly 12 s → difficulty unchanged (already partially covered) |
| Ramp bounds | K consecutive block_time = 0 → difficulty ≤ d₀ × (1 + K/2048); same for -99 capped slow blocks |
| Config sanity | const_assert or test: InitialDifficulty ≥ divisor, max_down ≤ max_up, bucket > 0 |
Layer 2 — Property-based (proptest / quickcheck)
Generate random (d, t) within [min, max]:
- Result always ∈
[min, max] t₁ < t₂⇒diff(t₁) ≥ diff(t₂)(holdingdfixed)t ∈ [bucket, 2×bucket)⇒diff(t) == d|Δ| ≤ 99 × d / 2048per block
Layer 3 — Block-hook integration (mock runtime)
Use run_block patterns like test_difficulty_recovers_after_sleep:
| Scenario | Expected behavior |
|---|---|
| Hashrate halving | 50 blocks at ~24 s → monotonic decrease, then plateau near new equilibrium |
| Hashrate doubling | 50 blocks at ~6 s → monotonic increase, bounded +1 step/block |
| GPU on/off oscillation | Alternating 6 s / 18 s for 200 blocks — bounded oscillation amplitude (no runaway like old EMA×mult) |
| Chronic downward drift | Regression for #564: 100 blocks at 15 s (above target but below 2×bucket) — difficulty should not crawl down unboundedly |
| Ping-pong after outage | One 1 h gap, then 100×12 s — difficulty flat after first block (EIP-2 “no slow tail”) |
| Floor approach | Drive diff to min, one fast block, confirm escape |
| Genesis / block 1 | First real block uses target_time neutral input |
Layer 4 — Known attack vectors (simulation + devnet)
Map Ethereum-documented issues to Quantus:
flowchart TD
A[Attacker controls block author / timestamp] --> B{Attack type}
B --> C[Micro-time gaming]
B --> D[Bucket boundary gaming]
B --> E[Long stall then resume]
B --> F[Hashrate shock]
B --> G[Private fork timestamps]
C --> C1["Always t less than bucket: +1 every block"]
D --> D1["Hover at 7999ms vs 8000ms"]
E --> E1["Single -99 hit then flat — test_difficulty_recovers_after_sleep"]
F --> F1["Add/remove GPU — devnet soak + Prometheus"]
G --> G1["Reorg depth 180 + cumulative work — consensus layer"]
| Attack | Ethereum reference | Quantus test |
|---|---|---|
parent.timestamp + 1 gaming |
EIP-2 §Rationale — skewed mean toward infinity under old rule | Simulate sequences with block_time = 1 ms (or MinimumPeriod) for 100 blocks; assert difficulty rises ~100/2048 but block production still limited by real wall clock + inherents |
| Bucket boundary bias | Coarse // 10 intentional |
Sweep t ∈ {7999, 8000, 15999, 16000} ms on devnet; confirm step changes only at boundaries |
| Timestamp manipulation for fork advantage | Uncle Maker (IACR 2022/1020) — uncle-specific | Lower priority without uncles; still fuzz timestamp inherents on competing templates at same height |
| Difficulty bomb / ice age | EIP-2 ice age term | Confirm absent and document intentional omission |
| Uncle rate inflation | EIP-100 §Rationale | N/A — assert no // 9 or uncle bonus in code paths |
| Floor trap | #564 review | test_min_difficulty_escape_from_floor ✓ |
| EMA slow tail | #564 / pre-EIP-2 Ethereum | test_difficulty_recovers_after_sleep ✓ — extend with alternating slow/normal |
| Black swan / client bug gap | EIP-2 -99 cap |
One block at 800s+; assert exactly one max downward step, then stable |
| Hashrate shock (economic) | Operational | Required devnet: 2–4 nodes, measure difficulty, last_block_duration, block_time_ema while adding/removing GPU; acceptance: peak-to-peak swing < X% over Y blocks (define X from ops, e.g. 20% over 50 blocks) |
| Time-warp (private chain) | Bitcoin literature | Low priority for public net; private devnet with rewound timestamps — chain work + reorg depth should dominate |
| Selfish mining via difficulty | General PoW | Hard to unit-test; monitor block time variance vs difficulty on devnet |
Layer 5 — Observability & ops
- Grafana: overlay
difficulty,last_block_duration,block_time_emaduring GPU stress - Alert if
last_block_duration> 800 s (max downward adjustment event) - Log / metric
adj_factorfrom debug line (or add a runtime API for testnets)
Layer 6 — Fuzz / CI (optional but high value)
cargo fuzztarget: random(U512::from_low_u64(d), u64::from(t))→ no panic, bounded delta- Nightly devnet job: 30 min mining with periodic load change
Suggested acceptance criteria before merge
- All existing
pallet-qpow+ runtime tests green (already claimed). - Fix PR description typo (30 s vs 16 s example).
- Update
on_finalizedoc comment (EMA → per-block EIP-2). - Devnet GPU soak with Prometheus screenshots (closes the motivating EMA update wobble #564 gap).
- At least one golden-vector test tied to runtime constants (
8_000ms bucket,12_000ms target).
Verdict
Approve with minor doc/comment fixes and mandatory devnet validation. The Ethereum alignment is accurate for EIP-2; EIP-100 exclusion is well argued. The change directly addresses #564’s structural issues (floor trap, EMA tail, multiplicative drift). Unit tests are solid; the main gap is operational validation under real hashrate churn and timestamp-edge scenarios that only appear with live block authors.
I can draft concrete test cases (golden vectors or a devnet checklist) as follow-up patches if you want them in-repo.
|
Opus 4.7 max review Review of PR #565 — qpow: Ethereum EIP-2 additive difficulty adjustmentVerdict: Approve in principle — the controller is mathematically sound and a clear improvement over both the previous code and PR #564. Two issues to address before merging (one is documentation-only, one is a missing runtime-parameter integration test). The PR's claims hold up against authoritative Ethereum sources except for one specific numerical error in the description. I confirmed the math by reading both the EIP-2 specification and the go-ethereum reference implementation, and by running the test suite locally ( 1. Verification against authoritative sourcesEIP-2 (Homestead) — eips.ethereum.org/EIPS/eip-2
go-ethereum
|
| Claim in PR | Verified |
|---|---|
| Additive form matches EIP-2 §Specification | Yes |
MinimumDifficulty = 131_072 = 2^17 matches Ethereum |
Yes — pallets/qpow/src/lib.rs:431 |
DifficultyBoundDivisor = 2048 matches Ethereum |
Yes — runtime/src/configs/mod.rs:159 |
-99 cap fires at (MaxUp − MaxDown) × bucket = 800s ≈ 13 min single-block |
Yes (100 × 8000ms = 800000ms) |
| Single-block driven, not EMA | Yes — block_time = now − last_time, lib.rs:222–233 |
| EMA preserved as Prometheus telemetry only | Yes — update_block_time_ema still called, but its output is no longer fed into calculate_difficulty |
| Floor escapable in one fast block | Yes — 131_072 / 2048 = 64; new test test_min_difficulty_escape_from_floor proves it |
One numerical error in the PR description
"Routine slow patches use the linear region (e.g. a 30 s block → −0.0488%)."
A 30 s block gives buckets = 30000/8000 = 3, adj_factor = max(1−3, −99) = −2, so the relative change is −2/2048 ≈ −0.0977%, not −0.0488%. The −0.0488% figure is correct for a single −1 step (i.e. a single block in [16s, 24s)).
This is documentation only — the code is correct. Fix the example to "20 s block → −0.0488%" or "30 s block → −0.0977%" before merging.
2. Code review
What's right
- Saturation is consistent and safe.
saturating_add,saturating_sub,saturating_mulonU512;adj_factorclamped to[max_down, max_up] = [-99, 1]so.unsigned_abs()is bounded by99(noi32::MINoverflow). Thetest_overflow_saturationtest exercises bothU512::MAXand the floor againstu64::MAXblock_time. - Fail-loud misconfiguration guards in
calculate_difficulty(divisor == 0,max_down > max_up) — aligned with the user rule "never have a silent failure." - Floor is now self-consistent:
get_min_difficulty() = max(2^17, DifficultyBoundDivisor)ensuresmin / divisor ≥ 1, so the step is at least 1 unit and the floor is always escapable. This is the critical fix the v12 audit on EMA update wobble #564 demanded. - First-block handling: feeding
target_timeto the controller on block 1 (givingadj_factor = 0) is safer than the previous behaviour ofblock_time = 0(which would have spuriously pushed difficulty up on every chain start). test_difficulty_recovers_after_sleepcorrectly captures the EIP-2 §Rationale property the previous EMA-based code violated: one slow block produces one large drop, and the slow block does not keep echoing through future adjustments.
Nits and minor issues
-
_target_block_timeis now an unused parameter. Comment says "kept for ABI stability." This is genuinely useful for SemVer if external callers exist — but it should at least be#[allow(unused_variables)]-style explicit, and a follow-up to drop the param could be tracked. -
No
log::warn!on clipping. The previous code warned whenadjusted < min_difficultyoradjusted > max_difficulty. The new code silently.max(min).min(max). If the floor or ceiling ever binds in production, you lose the diagnostic signal. Consider:if raw_adjusted < min_difficulty { log::warn!(target: "qpow", "difficulty clipped UP to floor: raw={:x} -> min={:x}", raw_adjusted.low_u64(), min_difficulty.low_u64()); }
-
Mock parameters don't match runtime ratio. Mock uses
bucket = 750ms / target = 1000ms(ratio 0.75); runtime uses8000 / 12000(ratio 0.667). The 0.75 ratio is a different controller geometry — its no-change band is[750, 1500), centred 1.125× target, where the runtime's[8000, 16000)is centred 1.0× target. This was exactly the kind of mock-runtime drift the v12 audit on EMA update wobble #564 flagged. Suggest either changing mock tobucket = 666msto match the2/3 × targetrule, or, better, adding a single runtime-parameter integration test (see §4). -
InitialDifficulty = 2_700_000is unjustified in the diff. The PR carries it over from EMA update wobble #564 without re-justification. Withmin = 131_072,initial = 2_700_000gives~20×headroom above floor, which is reasonable. A one-line comment ("≈ 20× min_difficulty, leaves room for downward adjustment during bootstrap") would help future readers. -
MaxReorgDepthleft at 180. Not changed by this PR, but at 12s target = 36 min of reorg window. Worth confirming this is intentional — long deep reorgs interact with difficulty manipulation attacks (see §3.5).
3. Attack-vector analysis
I went through the canonical PoW difficulty-adjustment attacks and assessed each against PR #565 as proposed. Citations are the EIP-2 paper, EIP-100, Bitcoin Optech on Timewarp, BIP-54, and Zawy's Alternating Timestamp Attack.
3.1. Timewarp attack (Bitcoin-style) — not applicable
Bitcoin's timewarp exploits the boundary between difficulty-retarget windows (2016-block epochs). PR #565 retargets every block, so there are no epoch boundaries to exploit. Same answer for Zawy's alternating-timestamp variant.
3.2. EIP-2 §Rationale "parent + 1" attack — bounded but not eliminated
Pre-EIP-2 Ethereum had a step function (+1 if delta < 13s else -1), and miners learned to game it by setting every timestamp to parent + 1, which pushed difficulty up indefinitely.
EIP-2 (and this PR) replaces the step with the linear max(1 − delta/bucket, −99). The attack still pushes adj_factor = +1 for any delta < bucket, but unlike the old step function, the algorithm targets the mean of block times rather than the median; EIP-2 §Rationale proves the mean is bounded above by ~24s (in the Homestead numbers).
Quantus equivalent of that bound: With max_up = 1, bucket = 8s, no fees other than mining incentives, the upper bound on mean equilibrium block time is approximately 2 × bucket = 16s (the top of the no-change band). In the worst case where 100% of miners minimise their timestamp delta to MinimumPeriod = 100ms, mean block time settles at the bucket boundary; this is higher than the 12s target, so the attack hurts liveness modestly. The economic incentive to perform it is weak because the difficulty increase falls on the next block (likely mined by someone else).
3.3. Hashrate-bandit / coin-hopping — inherent EIP-2 weakness, unchanged
An attacker rents k× hashrate, mines until difficulty catches up, then leaves. With additive +parent/2048 per fast block:
| Hashrate spike | Blocks to catch up | Wall-clock at 12 s target |
|---|---|---|
| 2× | ~1 420 | ~4.7 h |
| 5× | ~3 295 | ~11 h |
| 10× | ~4 716 | ~15.7 h |
When the attacker leaves and blocks revert to k × target, the downward recovery is faster (because −k adj_factor per block until the no-change band, then 0). For k = 10: blocks come in at 120s → adj = −14 → −14/2048 ≈ −0.68% per block, recovers in log(10)/log(1.0068) ≈ 338 blocks (~67 min at 12s target). Asymmetry: ~16h up, ~1h down. The attacker can rinse and repeat.
This is the same attack Ethereum's PoW had for years and one of the reasons Ethereum moved to PoS. PR #565 doesn't make this worse than EIP-2 — but it doesn't make it better either. Mitigations belong to a separate design effort (e.g. DigiShield-style asymmetric clamping, or ASERT-style exponential targeting). I'd flag this as known-acceptable for v1 if the team is aware.
3.4. Single-block stall (laptop sleep, validator nap) — handled correctly
A single block ≥ 800s drops difficulty by exactly −99 × parent/2048 ≈ −4.83%. Subsequent on-target blocks produce adj_factor = 0, so the chain doesn't pay a long recovery tail. This is the single most important property the PR fixes from #564. test_difficulty_recovers_after_sleep proves it.
3.5. Future-timestamp attack (Quantus-specific) — dependent on sp_timestamp drift
The node uses sp_timestamp::InherentDataProvider::from_system_time() (node/src/service.rs:479) and Substrate's standard pallet-timestamp validation. The accepted future drift is MAX_TIMESTAMP_DRIFT_MILLIS = 60_000 (60 s).
A malicious miner can therefore set their block's timestamp up to 60s ahead of real time. Worst case:
- Honest parent at
t, malicious block att + 12s + 60s = t + 72s(real elapsed: 12s). block_timerecorded by controller:72_000ms / 8000ms = 9→adj_factor = max(1−9, −99) = −8→ per-block change≈ −0.391%.
This is a single-block trick. Per-attack downward push is bounded by drift / bucket step-units, and a single honest block in between resets the "future-timestamp credit" since each block's timestamp must still be ≤ system_time + 60s. So sustained downward drift requires sustained adversarial control of consecutive blocks. With 10% attacker hashrate and Poisson block generation, expected sustained run is ~1.1 blocks (E[geom(0.9)] ≈ 1.11), so this is a small but non-zero advantage.
Action: ensure the timestamp inherent check actually fires (the TooFarInFuture error is declared in client/consensus/qpow/src/lib.rs:50 but I don't see it raised anywhere in that file — worth confirming it's enforced via the standard pallet_timestamp::check_inherents path).
3.6. Negative-time / equal-time blocks — prevented by pallet-timestamp
pallet_timestamp::MinimumPeriod = 100ms (runtime) enforces strictly monotonic timestamps with at least 100ms gap. The controller's now.saturating_sub(last_time) is therefore never zero on a properly-validated chain. Good defence in depth via saturating_sub regardless.
3.7. U512 / arithmetic overflow — tested and saturated
step = current / 2048: U512 division, no overflow.step × abs(adj_factor)withabs(adj_factor) ≤ 99: requiresstep > U512::MAX/99, i.e.current > U512::MAX × 2048/99 ≈ 20 × U512::MAX. Impossible.current ± delta: saturated.observed_block_time / bucketthen cast toi32: PR explicitly caps ati32::MAXif the quotient exceeds it.test_adj_factor_tableexercisesu64::MAXblock_time →−99. Good.
4. Testing strategy
The current suite is good for the formula itself but light on:
- runtime-parameter coverage (mock-vs-runtime drift),
- integration with the consensus client and timestamp validation,
- equilibrium dynamics over many blocks,
- adversarial timestamp sequences.
Here is a concrete strategy organised from cheapest to most expensive.
Tier 1 — Add to pallets/qpow/src/tests.rs (unit, ~minutes to write)
T1.1. Runtime-parameter assertion (closes the mock-vs-runtime gap). Add to the runtime workspace (runtime/tests/) — verify the actual runtime constants land in the right relationship:
#[test]
fn runtime_difficulty_constants_are_consistent() {
type T = quantus_runtime::Runtime;
use pallet_qpow::Config;
let bucket = <T as Config>::BlockTimeBucketMs::get();
let target = <T as Config>::TargetBlockTime::get();
let max_up = <T as Config>::MaxUpAdjFactor::get();
let max_down = <T as Config>::MaxDownAdjFactor::get();
let divisor = <T as Config>::DifficultyBoundDivisor::get();
let min = pallet_qpow::Pallet::<T>::get_min_difficulty();
// Target must sit in the no-change band [bucket * max_up, bucket * (max_up+1)).
assert!(target >= bucket * max_up as u64);
assert!(target < bucket * (max_up as u64 + 1));
// Floor must be liftable.
assert!(min >= divisor, "floor unliftable: min/divisor would be 0");
// -99 cap fires only on real stalls (>= 5x the target).
let cap_threshold_ms = (max_up - max_down) as u64 * bucket;
assert!(cap_threshold_ms > 5 * target);
}This is what was missing in #564 — a test that runs against the actual quantus_runtime constants.
T1.2. Adj-factor boundary regression (currently has gaps). The existing test_adj_factor_table covers most boundaries but misses one important transition: the upper edge of the no-change band (block_time = 2*bucket - 1 and = 2*bucket). Add explicit assertions for 1499→0 (already there) and add 1500→-1 (already there as the start of [1500, 2250)) — but also 2249→-1 and 2250→-2. Cheap, mechanical.
T1.3. Symmetry test. Above/below target by n buckets should produce step magnitudes of |max_up − n| and |max_up − (−n)| respectively. Confirms no off-by-one between up and down directions:
#[test]
fn step_magnitudes_are_symmetric_around_target() {
new_test_ext().execute_with(|| {
let d = U512::from(2_048_000_000u64);
let bucket = <Test as Config>::BlockTimeBucketMs::get();
// 0 buckets below the no-change band: +1 step
let up = QPow::calculate_difficulty(d, 0, 1000);
// 1 bucket above the no-change band: -1 step (mirrored)
let down = QPow::calculate_difficulty(d, 2 * bucket, 1000);
let up_delta = up - d;
let down_delta = d - down;
assert_eq!(up_delta, down_delta, "step magnitudes must mirror");
});
}T1.4. Misconfiguration guards. The PR added log::error! returns for divisor == 0 and max_down > max_up, but there's no test that those guards actually fire. Construct a misconfigured mock and assert calculate_difficulty returns current_difficulty unchanged.
Tier 2 — Property-based testing with proptest (~half a day)
proptest is already a dev-dependency in this workspace (see qpow-math/src/lib.rs:202 for an existing pattern). Add a proptests module to pallets/qpow/src/tests.rs:
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn arb_difficulty() -> impl Strategy<Value = U512> {
// bias toward "interesting" magnitudes
prop_oneof![
Just(U512::from(131_072u64)), // floor
Just(U512::MAX), // ceiling
(1u128..=u128::MAX).prop_map(U512::from),
]
}
proptest! {
// Result is always in [min, max].
#[test]
fn result_in_bounds(d in arb_difficulty(), bt in 0u64..=u64::MAX) {
new_test_ext().execute_with(|| {
let r = QPow::calculate_difficulty(d, bt, 1000);
let min = QPow::get_min_difficulty();
let max = QPow::get_max_difficulty();
prop_assert!(r >= min && r <= max);
});
}
// Monotonicity: at fixed difficulty, faster blocks ⇒ ≥ difficulty.
#[test]
fn monotone_in_block_time(d in arb_difficulty(), bt1 in 0u64..1_000_000, bt2 in 0u64..1_000_000) {
new_test_ext().execute_with(|| {
let (a, b) = if bt1 < bt2 { (bt1, bt2) } else { (bt2, bt1) };
let fast = QPow::calculate_difficulty(d, a, 1000);
let slow = QPow::calculate_difficulty(d, b, 1000);
prop_assert!(fast >= slow);
});
}
// No-change band: any block_time in [bucket, 2*bucket) returns d (modulo clipping).
#[test]
fn no_change_band(d in arb_difficulty(), offset in 0u64..750u64) {
new_test_ext().execute_with(|| {
let bucket = <Test as Config>::BlockTimeBucketMs::get();
let r = QPow::calculate_difficulty(d, bucket + offset, 1000);
let min = QPow::get_min_difficulty();
let max = QPow::get_max_difficulty();
let expected = d.max(min).min(max);
prop_assert_eq!(r, expected);
});
}
}
}Three invariants caught most of the bugs in the v12 audit on #564.
Tier 3 — Equilibrium simulation (~1 day)
Add pallets/qpow/tests/equilibrium.rs as an integration test that runs N blocks with synthetic timestamps and asserts the controller converges. Two scenarios:
#[test]
fn converges_to_target_under_constant_hashrate() {
// Simulate 10_000 blocks with exponential-iid block times,
// target_inverse_hashrate set so true mean is exactly the runtime target.
// After warmup, assert: |stored_difficulty - true_difficulty| / true_difficulty < 1%.
}
#[test]
fn tracks_2x_hashrate_step_change() {
// 1_000 blocks at hashrate H, then 1_000 at 2H.
// Assert: after 2_000 blocks past the change, difficulty has risen by
// 1.5x < ratio < 2.5x of the starting difficulty (sanity bounds on
// 1420-block exponential catch-up time).
}A simulation can be entirely in-process with pallet_timestamp::Pallet::<Test>::set_timestamp and QPow::on_finalize. It catches systemic bias (the kind that doomed #564's −5%/+0.05% asymmetric controller) that unit tests miss.
Tier 4 — Adversarial timestamp scenarios (~1 day, integration)
These map directly onto the attack-vector list in §3 and should live as integration tests in runtime/tests/:
| Test | Scenario | Pass condition |
|---|---|---|
attack_minimum_period_spam |
1000 consecutive blocks with delta = 100ms (MinimumPeriod floor). |
Difficulty rises monotonically but reaches a bounded ceiling (no runaway). Mean block time tracked separately stabilises in [bucket, 2×bucket). |
attack_max_drift_spam |
1000 consecutive blocks with delta = 60s (max future drift). |
Per-block change ≈ −7/2048 ≈ −0.34%. After 1000 blocks, difficulty drop is bounded by a known geometric factor. |
attack_alternating_extremes |
Alternate delta = 100ms and delta = 60s. |
Net change after a pair is bounded; over many pairs the controller does not drift unboundedly in either direction. |
attack_isolated_stall |
One block with delta = 1h, then 100 on-target blocks. |
Single drop of exactly −99 × step, then flat for the remaining 100 blocks (regression test for the EMA-tail bug from PR #564). Note: this is already covered by test_difficulty_recovers_after_sleep but could be expanded with explicit per-block assertions. |
attack_repeated_stalls |
10 blocks with delta = 1h, each separated by 10 on-target blocks. |
After 10 stalls, difficulty is exactly current × (1 − 99/2048)^10 (within rounding). Asserts compounding is geometric, not catastrophic. |
attack_hashrate_bandit |
2× hashrate (i.e. half block times) for 500 blocks, then 1× for 500 blocks. | After 500 fast blocks, difficulty has risen but not yet caught up (this is the inherent EIP-2 weakness — document it, don't fix it). After 500 recovery blocks, difficulty returns to within 10% of the original. |
For these, build a small simulation helper that drives pallet_timestamp and QPow::on_finalize for arbitrary timestamp sequences. The mock test helper run_block(num, ts) in pallets/qpow/src/tests.rs:133 is already most of what's needed.
Tier 5 — Fuzzing (~optional, ~half a day for setup)
If you have a fuzz-friendly CI lane, the calculate_difficulty function is an ideal target for cargo fuzz or proptest-as-fuzz: input domain is (U512, u64, u64), output is U512, and the invariants are simple (bounded, monotonic, deterministic). Run for an hour, look for panics — there shouldn't be any thanks to the saturating math, but it's cheap insurance.
Tier 6 — Multi-node devnet (~the test plan already lists this)
The PR's test plan already includes "multi-node devnet run to observe difficulty curve under add/remove-GPU stress." I'd specifically run these scenarios on devnet and capture Prometheus dashboards:
- Cold start: bootstrap from genesis with 1 miner, verify difficulty climbs from
2_700_000smoothly. - GPU add/remove: same scenario that motivated EMA update wobble #564. Should now show a measured rise, plateau at new equilibrium, then return after removal. No long EMA tail.
- Network partition / single-node stall: pause one miner for 30 minutes, restart. Verify difficulty drops by exactly
−99 × stepon the first post-stall block, then recovers at+1/2048per fast block. - Adversarial timestamp simulation: use a modified miner that sets
timestamp = parent + MinimumPeriodortimestamp = system_time + 60sand run for 1000 blocks against an honest minority. Verify difficulty doesn't degenerate.
What I would not test
-99cap exact threshold to the millisecond. Covered bytest_adj_factor_tablealready.saturating_*arithmetic onU512. Covered bytest_overflow_saturationand these are stdlib primitives.- Genesis-block path. The single conditional branch is small and the existing tests indirectly cover it.
5. Concrete suggested actions before merge
- Fix the "30s → -0.0488%" example in the PR description (it should be "20s → -0.0488%" or "30s → -0.0977%").
- Add the runtime-parameter integration test (T1.1) — this would have caught the entire class of failures from EMA update wobble #564.
- Either add a
log::warn!on raw-result clipping, or document in the function comment that clipping is normal and silent. - (Optional but recommended) add the property-based tests (T2) — the controller is now simple enough that exhaustive
proptestcoverage is feasible and would harden against future re-tuning. - Confirm
TooFarInFuturefromclient/consensus/qpow/src/lib.rs:50is actually raised somewhere (looks declared-but-unused based on my search). If not, the 60s drift bound relies entirely onpallet_timestamp::check_inherents, which is fine but should be intentional.
The rest can be follow-ups. The core change is mathematically correct, addresses every legitimate criticism the v12 audit on #564 raised, and ports a battle-tested algorithm Ethereum used for years.
Related context I found while reviewing: v12 audit on #564 — most of what made #564 unmergeable is what this PR actually fixes, including the exact "floor trap" example (1000 × (1 + 1/2048) = 1000 under U512 truncation) that motivated the min = max(2^17, 2048) derivation here.
…ants - Warn on raw-result clipping to floor/ceiling so a stuck controller is diagnosable from logs alone. - Add proptest invariants for the additive controller: bounds, monotone-in-block_time, no-change band flatness, step magnitude. - Add runtime-parameter integration test asserting the actual quantus_runtime constants form a coherent EIP-2 configuration — catches the mock/runtime drift that hid the floor trap in #564. - Remove dead TooFarInFuture variant; the 60s drift bound is intentionally enforced via pallet_timestamp::check_inherents.
Summary
Alternative to #564 that fixes the underlying issues exposed in that PR's review (chronic downward drift, asymmetric hashrate response,
min_difficultyfloor trap) by adopting EIP-2's additive form of difficulty adjustment instead of a multiplicative ratio-clamp.What's different from #564
0.95^Ncumulative-drift problem; the controller's expected per-block change is zero at the right difficulty.get_block_time_emaruntime API are preserved as Prometheus telemetry only, sonode/src/prometheus.rsand existing dashboards keep working.min_difficultyis now derived asmax(2^17, DifficultyBoundDivisor). Closes the floor trap where1000 * (1 + 1/2048)truncated back to1000under U512 arithmetic.−99cap fires only on real stalls — a single block ≥(MaxUp − MaxDown) × bucket = 100 × 8 s = 800 s(≈13 min). Routine slow patches use the linear region (e.g. a 20 s block → −0.0488%, a 30 s block → −0.0977%).DifficultyBoundDivisor == 0orMaxDown > MaxUplog an error and return the current difficulty rather than panic.Runtime constants (12 s target)
QPoWDifficultyBoundDivisor2048QPoWBlockTimeBucketMs2 * TARGET_BLOCK_TIME_MS / 3= 8 000 ms[8 s, 16 s)on the 12 s targetMaxUpAdjFactor1MaxDownAdjFactor-99QPoWInitialDifficulty2_700_000EmaAlpha100(10 %)Why EIP-2 (Homestead) and not EIP-100 (Byzantium)?
EIP-100 added two coupled changes on top of EIP-2:
adj_factor = (2 if parent_has_uncles else 1) - ...— an extra+1upward boost when the parent block has uncles.// 10 → // 9: a ~10% compensation for the upward bias the uncle bonus introduces, so block times stay on target.Neither applies to Quantus:
// 9denominator-99lower capThe structural property that does carry over from both EIPs is
bucket ≈ target / 1.5, which puts the target block time at the geometric centre of the[bucket, 2*bucket)no-change band:2*target/3)So: same structural choice as both Ethereum eras, with the uncle-specific machinery from EIP-100 intentionally omitted.
Tests
Mock parameters now mirror the runtime structure (bucket = 750 ms for a 1 000 ms target, divisor = 2048, max_up = +1, max_down = -99). New / rewritten:
test_min_difficulty_escape_from_floor— regression for the floor trap.test_adj_factor_table— exhaustive boundary coverage (every interesting block-time bucket, the-99cap,u64::MAXsaturation).test_overflow_saturation—U512::MAXand floor both saturate cleanly.test_no_change_when_at_target— at-target blocks produce zero change.test_difficulty_recovers_after_sleep— rewritten: one outage block drops difficulty by exactly-99 × step, then 20 on-target blocks keep it flat (no slow tail — the key EIP-2 property).Test plan
cargo check --workspace --all-targetscargo test -p pallet-qpow→ 24/24 passcargo test -p quantus-runtime --tests→ 42/42 pass (1 pre-existingignored)block_time_emacontinues to populate