Skip to content

qpow: Ethereum EIP-2 additive difficulty adjustment#565

Open
n13 wants to merge 3 commits into
mainfrom
fix/qpow-ethereum-difficulty-adjustment
Open

qpow: Ethereum EIP-2 additive difficulty adjustment#565
n13 wants to merge 3 commits into
mainfrom
fix/qpow-ethereum-difficulty-adjustment

Conversation

@n13
Copy link
Copy Markdown
Collaborator

@n13 n13 commented May 20, 2026

Summary

Alternative to #564 that fixes the underlying issues exposed in that PR's review (chronic downward drift, asymmetric hashrate response, min_difficulty floor trap) by adopting EIP-2's additive form of difficulty adjustment instead of a multiplicative ratio-clamp.

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)

What's different from #564

  • Additive, not multiplicative. No 0.95^N cumulative-drift problem; the controller's expected per-block change is zero at the right difficulty.
  • Driven by the single-block time, not an EMA. Matches EIP-2 §Rationale — a single slow block must not keep penalising the chain for ~100 blocks via a slow-decaying state. The EMA storage and get_block_time_ema runtime API are preserved as Prometheus telemetry only, so node/src/prometheus.rs and existing dashboards keep working.
  • min_difficulty is now derived as max(2^17, DifficultyBoundDivisor). Closes the floor trap where 1000 * (1 + 1/2048) truncated back to 1000 under U512 arithmetic.
  • The −99 cap 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%).
  • Fail-loud guards: DifficultyBoundDivisor == 0 or MaxDown > MaxUp log an error and return the current difficulty rather than panic.

Runtime constants (12 s target)

Constant Value Source
QPoWDifficultyBoundDivisor 2048 EIP-2
QPoWBlockTimeBucketMs 2 * TARGET_BLOCK_TIME_MS / 3 = 8 000 ms Centres no-change band [8 s, 16 s) on the 12 s target
MaxUpAdjFactor 1 Homestead
MaxDownAdjFactor -99 EIP-2
QPoWInitialDifficulty 2_700_000 Kept from #564, well above the new 131 072 floor
EmaAlpha 100 (10 %) Telemetry only

Why EIP-2 (Homestead) and not EIP-100 (Byzantium)?

EIP-100 added two coupled changes on top of EIP-2:

  1. Uncle bonus: adj_factor = (2 if parent_has_uncles else 1) - ... — an extra +1 upward boost when the parent block has uncles.
  2. Denominator // 10 → // 9: a ~10% compensation for the upward bias the uncle bonus introduces, so block times stay on target.

Neither applies to Quantus:

EIP-100 ingredient Applies here? Why
Uncle bonus No Substrate PoW has a single canonical chain; siblings are discarded, not rewarded as uncles.
// 9 denominator No The denominator change exists only to offset (1). Applying it without the uncle bonus would just bias difficulty upward by ~10% with no offsetting reason.
-99 lower cap Yes — kept This part is shared with EIP-2 and is what we use.

The 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:

Era Effective target Bucket target / bucket
EIP-2 Homestead ~15 s 10 s 1.5
EIP-100 Byzantium ~13.5 s 9 s 1.5
This PR (Quantus) 12 s 8 s (2*target/3) 1.5

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 -99 cap, u64::MAX saturation).
  • test_overflow_saturationU512::MAX and 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-targets
  • cargo test -p pallet-qpow → 24/24 pass
  • cargo test -p quantus-runtime --tests → 42/42 pass (1 pre-existing ignored)
  • Multi-node devnet run to observe difficulty curve under add/remove-GPU stress (the scenario that motivated EMA update wobble #564)
  • Sanity-check Prometheus block_time_ema continues to populate

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.
@v12-auditor
Copy link
Copy Markdown

v12-auditor Bot commented May 20, 2026

Caution

Compilation Failed: The audit stopped during compilation.

Open the run details here.

@n13
Copy link
Copy Markdown
Collaborator Author

n13 commented May 20, 2026

Composer 2.5 review

PR review: #565 — qpow: Ethereum EIP-2 additive difficulty adjustment

This PR replaces the EMA-driven multiplicative retargeting on main (and the direction of #564) with an additive, per-block controller aligned with EIP-2, while deliberately not adopting EIP-100 uncle machinery. Unit tests pass locally (24/24 in pallet-qpow).


What changed (vs main)

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:

  1. Uncle-aware +1 boost when parent has uncles
  2. Denominator // 10 → // 9 to 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

  1. Faithful EIP-2 core with sensible Quantus scaling (12 s / 8 s buckets).
  2. Fixes real bugs from review of EMA update wobble #564: floor trap, EMA echo, cumulative multiplicative drift.
  3. Strong unit coverage: boundary table, overflow, sleep-without-tail, floor escape.
  4. Observability preserved — EMA still feeds Prometheus; dashboards won’t break.
  5. Misconfig guards — zero divisor / inverted caps log errors and no-op (safe, though see nits).

Nits / risks

  1. Stale hook commenton_finalize still says difficulty is adjusted “using EMA”; implementation no longer does.
  2. Fail-soft on misconfig — returns current difficulty instead of panicking. Acceptable for production runtime, but worth a compile-time static_assert or runtime integration test that constants are sane.
  3. target_block_time unused in calculate_difficulty — fine for ABI stability; callers/tests should not assume it affects math.
  4. test_difficulty_recovers_after_sleep uses a loose > 95% bound; exact value is 951_688 from the PR’s own arithmetic — tightening would catch regressions.
  5. Timestamp trust model — adjustment uses pallet_timestamp deltas. 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.
  6. 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₂) (holding d fixed)
  • t ∈ [bucket, 2×bucket)diff(t) == d
  • |Δ| ≤ 99 × d / 2048 per 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"]
Loading
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_ema during GPU stress
  • Alert if last_block_duration > 800 s (max downward adjustment event)
  • Log / metric adj_factor from debug line (or add a runtime API for testnets)

Layer 6 — Fuzz / CI (optional but high value)

  • cargo fuzz target: 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

  1. All existing pallet-qpow + runtime tests green (already claimed).
  2. Fix PR description typo (30 s vs 16 s example).
  3. Update on_finalize doc comment (EMA → per-block EIP-2).
  4. Devnet GPU soak with Prometheus screenshots (closes the motivating EMA update wobble #564 gap).
  5. At least one golden-vector test tied to runtime constants (8_000 ms bucket, 12_000 ms 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.

@n13
Copy link
Copy Markdown
Collaborator Author

n13 commented May 20, 2026

Opus 4.7 max review

Review of PR #565 — qpow: Ethereum EIP-2 additive difficulty adjustment

Verdict: 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 (24/24 pass).

1. Verification against authoritative sources

EIP-2 (Homestead) — eips.ethereum.org/EIPS/eip-2

block_diff = parent_diff + parent_diff // 2048 * max(1 - (block_timestamp - parent_timestamp) // 10, -99) + int(2**((block.number // 100000) - 2))

go-ethereum calcDifficultyHomesteadconsensus/ethash/consensus.go

// 1 - (block_timestamp - parent_timestamp) // 10
x.Sub(bigTime, bigParentTime)
x.Div(x, big10)
x.Sub(big1, x)

// max(1 - (block_timestamp - parent_timestamp) // 10, -99)
if x.Cmp(bigMinus99) < 0 { x.Set(bigMinus99) }

// parent_diff + parent_diff // 2048 * adj_factor
y.Div(parent.Difficulty, params.DifficultyBoundDivisor)
x.Mul(y, x)
x.Add(parent.Difficulty, x)

if x.Cmp(params.MinimumDifficulty) < 0 { x.Set(params.MinimumDifficulty) }

with MinimumDifficulty = 131_072 (2^17) and DifficultyBoundDivisor = 2048.

What the PR ships

pub fn calculate_difficulty(
    current_difficulty: U512,
    observed_block_time: u64,
    _target_block_time: u64,
) -> U512 {
    let bucket = T::BlockTimeBucketMs::get().max(1);
    let max_up = T::MaxUpAdjFactor::get();
    let max_down = T::MaxDownAdjFactor::get();
    let divisor = T::DifficultyBoundDivisor::get();

    // ... misconfiguration guards ...

    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);
    // ...

This is a structurally exact port of EIP-2 Homestead with the time bucket generalised from 10s to a configurable BlockTimeBucketMs. The choice bucket = 8s for a 12s target is the equivalent of Homestead's 10s / 13–15s ratio (~2/3).

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_mul on U512; adj_factor clamped to [max_down, max_up] = [-99, 1] so .unsigned_abs() is bounded by 99 (no i32::MIN overflow). The test_overflow_saturation test exercises both U512::MAX and the floor against u64::MAX block_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) ensures min / 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_time to the controller on block 1 (giving adj_factor = 0) is safer than the previous behaviour of block_time = 0 (which would have spuriously pushed difficulty up on every chain start).
  • test_difficulty_recovers_after_sleep correctly 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

  1. _target_block_time is 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.

  2. No log::warn! on clipping. The previous code warned when adjusted < min_difficulty or adjusted > 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());
    }
  3. Mock parameters don't match runtime ratio. Mock uses bucket = 750ms / target = 1000ms (ratio 0.75); runtime uses 8000 / 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 to bucket = 666ms to match the 2/3 × target rule, or, better, adding a single runtime-parameter integration test (see §4).

  4. InitialDifficulty = 2_700_000 is unjustified in the diff. The PR carries it over from EMA update wobble #564 without re-justification. With min = 131_072, initial = 2_700_000 gives ~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.

  5. MaxReorgDepth left 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 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
~1 420 ~4.7 h
~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 at t + 12s + 60s = t + 72s (real elapsed: 12s).
  • block_time recorded by controller: 72_000ms / 8000ms = 9adj_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) with abs(adj_factor) ≤ 99: requires step > U512::MAX/99, i.e. current > U512::MAX × 2048/99 ≈ 20 × U512::MAX. Impossible.
  • current ± delta: saturated.
  • observed_block_time / bucket then cast to i32: PR explicitly caps at i32::MAX if the quotient exceeds it. test_adj_factor_table exercises u64::MAX block_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:

  1. Cold start: bootstrap from genesis with 1 miner, verify difficulty climbs from 2_700_000 smoothly.
  2. 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.
  3. Network partition / single-node stall: pause one miner for 30 minutes, restart. Verify difficulty drops by exactly −99 × step on the first post-stall block, then recovers at +1/2048 per fast block.
  4. Adversarial timestamp simulation: use a modified miner that sets timestamp = parent + MinimumPeriod or timestamp = system_time + 60s and run for 1000 blocks against an honest minority. Verify difficulty doesn't degenerate.

What I would not test

  • -99 cap exact threshold to the millisecond. Covered by test_adj_factor_table already.
  • saturating_* arithmetic on U512. Covered by test_overflow_saturation and 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

  1. Fix the "30s → -0.0488%" example in the PR description (it should be "20s → -0.0488%" or "30s → -0.0977%").
  2. Add the runtime-parameter integration test (T1.1) — this would have caught the entire class of failures from EMA update wobble #564.
  3. Either add a log::warn! on raw-result clipping, or document in the function comment that clipping is normal and silent.
  4. (Optional but recommended) add the property-based tests (T2) — the controller is now simple enough that exhaustive proptest coverage is feasible and would harden against future re-tuning.
  5. Confirm TooFarInFuture from client/consensus/qpow/src/lib.rs:50 is actually raised somewhere (looks declared-but-unused based on my search). If not, the 60s drift bound relies entirely on pallet_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.

n13 added 2 commits May 20, 2026 17:14
…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.
@n13 n13 mentioned this pull request May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant