igneum/sim/difficulty/attacks/README.md
igneum-josh abb5a5d799 Difficulty under attack: hopping, pulsed rental, timestamp stretching, oscillation, epoch games, polluted window, flood; two FAILs with proposed diffs
Simulator harness over sim/difficulty/sim.py with multi-miner attribution and in-rule timestamp forging, a 3-node CPU test network (ports 27700+), results and bench-log entry. Timestamp stretching inside Kaspa's rules drops the Igneum block rate 34 to 88% (the per-step clamp cancels forged and honest pairs to zero time); proposed 10 s timestamp bounds plus a sanitised running clock in the chain steps (+0.7% to +1.1% drift at 50% in the simulator). Block flood underflows the 192-bit work after 4,142 blocks; a 2^128 target floor proposed. Rule not changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 00:04:44 +00:00

29 KiB

sim/difficulty/attacks

Adversarial runs against the Igneum difficulty rule (spec 2.3, difficulty branch of vendor/igneum-node) and Kaspa's sampled DAA, 3 and 4 October 2026, consensus test engineer. Six attacks a GPU farm or a pool-hopping bot would run, in the simulator first (attacks.py, on top of ../sim.py), then the two most damaging on a 3-node CPU test network (testnet.py, records under testnet/). One scenario fails. The proposed rule change is at the end, with the simulator run that shows it works. Nothing in the rule itself was changed here.

Files

File What
attacks.py the attack harness: several miners with on/off strategies and in-rule timestamp forging on top of the base simulator's controllers; the candidate controller IgneumSan (the proposed fix); markdown tables
testnet.py the 3-node CPU test network (ports 27700 and above, appdir /tmp/igneum-diff-attacks), schedule, header export over wRPC JSON, wall-time analysis
results.md raw simulator output of every run quoted here (seeds 7, 8, 9)
testnet/ts-past-igneum.csv, testnet/ts-future-igneum.csv, testnet/ts-past-kaspa.csv every header of the three test-network runs (columns as ../testnet-igneum-2026-10-03.csv plus vote_key_hash, blue_work) and the schedule logs beside them

Branch diff-attacks of vendor/igneum-node (worktree vendor/igneum-node-diff-attacks, from difficulty at 3ea7a3e3) adds one thing, attack tooling only: the environment variable IGNEUM_ATTACK_TS_OFFSET_MS shifts every template timestamp the node hands out (virtual_processor/processor.rs, mining/block_template/builder.rs), still floored at the past median plus one by the unchanged template code. Validation is untouched, so the forger's blocks are accepted by the honest nodes only if they are inside the rules.

What the harness adds to the base simulator

The base simulator has one miner and honest timestamps. The harness keeps its controllers (imported, unchanged) and adds: several miners, each with a hash rate in units of the honest base and a policy evaluated at block boundaries (hopper, oscillator, epoch gamer) or a time gate (rented burst); attribution of each block to the miner that found it, drawn by hash share at the solve; hashes spent per miner; and timestamp forging inside the consensus rules. The rules modelled are Kaspa's as the fork runs them: a header at most 132 s ahead of the clock (check_block_timestamp_in_isolation, TIMESTAMP_DEVIATION_TOLERANCE) and strictly above the sampled past median time (check_median_timestamp: 27 samples at 10-block intervals, mean of the central 11, SampledPastMedianTimeManager). Honest miners stamp real time, raised to the past median plus one when the rule forces it. Blocks per hash of a miner is the expected-gap estimator sum(1 / H_b) / sum(D_b / H_b) over the blocks it was on for (H_b total hash rate, D_b expected hashes), which has no block-count noise (the realised counts in the first run gave +13% "advantages" on 48 blocks that were sampling noise). Settle and gap metrics are the base simulator's (sim.metrics: 121-block centred mean within 10% of target, kept for 100 blocks).

Scenarios and criteria

# Attack Set-up Measured Criterion
1 Pool hopping for profit hopper with 10, 30, 50 or 100% of the honest base, on while D is below its trailing 6-hour mean (1% band), off above; greedy and with a 60 s minimum dwell; base constant; 24 simulated hours hopper's blocks per hash against the always-on base advantage under Igneum no larger than under Kaspa's rule, and under 5%
2 Pulsed hash rate (M14) 50x the base for 10 min every hour, and once; 8 h pulser's blocks per hash against the base, and its block share over its hash share (weight per hash) within 10% of steady
3 Timestamp stretching inside the rules forger with 30 or 50% of the hash rate stamping every block at the latest allowed (clock + 132 s), the earliest allowed (past median + 1 ms), or alternating; honest for 2 h then forging for 4 h block rate drift after the first hour of forging, mean difficulty ratio, worst gap no persistent drift beyond 5% of target rate
4 Short-lane manipulation 25% of the hash rate on and off every 120 blocks; 6 h std of the rate ratio against the steady value, worst gap std under twice the steady value
5 Epoch-boundary games a 30% miner off for the 8 hold blocks of every epoch; a 10x miner on only for the hold blocks; a 30% miner on only for the hold blocks; 8 h gamer's blocks per hash against the base the hold does not create a profitable window
6 Polluted window under adversarial timing a 10x miner joins at block 0 or 480 of an epoch and leaves at block 600 (the long lane takes over), 720 or 1,200 settle and worst gap after the leave, blocks below half rate no worse than the plain step-down
7 Block flood (side finding, 4 Oct) blocks every 12 ms (85 per second) fed to the rule, as another agent's pump harness did to the node whether the target leaves the range the block-work type can hold the node never panics on accepted input

Results (simulator, seeds 7, 8, 9, mean; worst seed where it differs)

# Attack Criterion Igneum Kaspa's rule Verdict
1 Pool hopping, 24 h, hopper 10 to 100% of the base, greedy and 60 s dwell advantage no larger than Kaspa's and under 5% +1.5% at most (10% greedy; 30%: +1.0%, 50%: +0.9%, 100%: +0.7%; with a 60 s dwell +1.3% to -4.0%); on 8 to 44% of the time, 230 to 820 switches a day +0.8% at most (0.4 to 0.8% in every cell); on 6 to 37% of the time, 64 to 132 switches PASS under 5%; FAIL by the letter on "no larger than Kaspa's" by 0.7 points (see 1 below)
2 Pulsed rental, 50x for 10 min hourly and once pulser's blocks per hash within 10% of steady (no weight amplifier) hourly: pulser earns 3.7% of the base's blocks per hash, weight per hash 0.26 (23% of blocks for 89% of hashes); once: 2.9% and 0.13 hourly: 85% of the base's, weight per hash 0.98; once: 57% and 0.87 PASS (the pulse buys no weight; the base's blocks per hash fall 37% in the hour after, worst gap 153 s, 184 blocks in the peak minute against Kaspa's 1,734)
3 Timestamp stretching, forger 30 and 50%, latest / earliest / alternating no persistent drift beyond 5% rate 0.66 / 0.42 / 0.51 at 30%, 0.56 / 0.12 / 0.23 at 50% (drift -34% to -88%), difficulty 1.5x to 9.9x, worst gap 234 s +4.9% / +6.9% / +9.0% at 30%, +5.0% / +11.3% / +9.8% at 50% (easing) FAIL (both rules; Igneum far worse). Reproduced on the test network. Rule change proposed
4 Short-lane oscillation, 25% on and off every 120 blocks std under twice steady std 0.160 against 0.045 steady (3.6x); floor of the attacker's own square wave 0.143; worst gap 11.0 s against 8.8 s; oscillator earns 1.4% less than the base std 0.147 against 0.010 (16x); same floor; worst gap 10.3 s FAIL by the letter for both; neither oscillates (12% and 3% above the floor); no change proposed
5 Epoch games: hold dodger 30%, hold flooder 10x, hold flooder 30% no profitable window 0.0%, +0.7% (worst seed +2.4%), +0.3% (worst +2.1%) 0.0%, 0.0%, -0.1% PASS
6 Polluted window, 10x joins at block 0 or 480, leaves at 600 / 720 / 1,200 no worse than the plain step-down leave at 600: settled 303 s (join at 0) and 292 s (join at 480), worst gap 17 s, 55 blocks below half rate; leave at 1,200: 334 s 2,910 s / never within 10% / 135 s / 3,540 s; never settles the join PASS
7 Block flood, 85 blocks per second of PoW-less input (side finding) the node never panics target below 2^64 after 4,142 blocks, calc_work panics at difficulty.rs:431 same hole, 17x slower (not run) FAIL. Floor proposed

Reading, scenario by scenario.

  1. Pool hopping. Under Igneum the controller follows the hopper within about 10 blocks, so the hopper is on for only 8 to 44% of the time, switches 230 to 820 times a day, and earns at most 1.5% more per hash than the base (the 10% greedy hopper; 0.7% at 100%; a 60 s dwell turns the larger hoppers into losers, -4.0% at 100%). Under Kaspa's rule it earns 0.4 to 0.8%, because D barely moves inside an hour and there is little to hop. Under 5% in every cell, so hopping does not pay for the switching; but the Igneum figure is 0.7 points above Kaspa's in every greedy cell on every seed, which fails the letter of the criterion. It is not the ease clamp: with the ease clamp at 6% or 3% instead of 10% the greedy hopper still earns +1.5% at 10% and +0.8 to +0.9% at 50% (results.md, "ease clamp against the hopper", seed 7). The excess is the price of a controller that moves inside the hour at all: a hopper with a 1% band around the mean skims every swing, and Kaspa's rule gives it less only because its D does not react to the hopper within the hour (the pulse row is the same fact from the other side). No rule change is proposed for it; a 60 s minimum dwell, which any real pool switch has, already turns the 50% and 100% hoppers into losers (-1.7% and -4.0%).

  2. Pulsed rental. The finality concern (M14) is that bursts buy blocks, and therefore vote weight, at better than the hash-time formula. Under Igneum the pulser mines at 50x difficulty within 130 blocks of each burst and gets 23% of the blocks for 89% of the hashes: weight per hash 0.26 of the base's, 3.7% of the base's blocks per hash. Under Kaspa's rule the burst runs at the old difficulty for most of its 10 minutes and the pulser gets 88% of the blocks for 89% of the hashes: weight per hash 0.98, 85% of the base's blocks per hash, so renting in bursts costs about what steady mining costs. A single burst (period 10^6) gives 0.13 against 0.87. R3.1's "2x the formula" does not appear in either rule: the amplifier Kaspa's rule gives is that the pulse is cheap, not that it is over-paid. The collateral under Igneum is the step-down after each burst: the base's blocks per hash fall 37% over the hour and the worst gap is 153 s (Kaspa's rule: 103 s, and 1,734 blocks in the peak minute against 184). Pass on the criterion the attack is about; the hangover is the known step-down cost of spec 2.3.

  3. Timestamp stretching. FAIL on every Igneum cell. See the next section.

  4. Short-lane oscillation. The std of the rate ratio rises from 0.045 to 0.160 under Igneum (3.6x the steady value) and from 0.010 to 0.147 under Kaspa's rule (16x). Neither is controller oscillation: the attacker's own hash rate is a square wave, and a controller that held D at the mean would show std 0.143 (the std_floor column). Igneum sits 12% above that floor, Kaspa's rule 3%. The oscillator earns 1.5% less per hash than the base under Igneum and the same as the base under Kaspa's. By the letter of the criterion both rules fail; no past-only controller can pass it against a 25% square wave, so no rule change is proposed. Worst gap 11.0 s against 8.8 s steady.

  5. Epoch-boundary games. The hold dodger earns the same per hash as the base (0.0%, the hold blocks are mined at the parent's target, which is right for the base). The flooders, 10x and 30% on only for the 8 hold blocks, earn +0.7% and +0.3% (worst seeds +2.4% and +2.1%): the 8 fast blocks make the epoch lane harden the next few blocks for the stay-behinds, which is the flooder's whole margin. The first run's +13% was block-count noise on 48 blocks and vanishes under the expected-gap estimator. Pass.

  6. Polluted window under adversarial timing. Leaving exactly as the long lane takes over (block 600) settles in 303 s with a 17 s worst gap and 55 blocks below half rate (292 s when the miner joined at block 480, so the short lane was engaged at the switch); leaving at block 1,200 of the same epoch settles in 334 s. The lane switch is not a weak point: pass. Kaspa's rule takes 2,910 to 3,540 s to settle the same leave (and never settles the join); its 0 s for the 480 to 600 case is the dead zone (D never moved, so there was nothing to settle).

The failure: timestamp stretching

Mechanism. Spec 2.3 says "a forged timestamp can move one capped solvetime by at most 20 s in either direction and the next honest block's solvetime cancels it under the symmetric cap". The cancellation is real and it is the problem: a forged block at clock + 132 s contributes a clamped +20 s and the honest block after it, whose stamp is 131 s behind its parent's, contributes a clamped -20 s. The pair sums to zero while two real block intervals passed. Every run of forged blocks removes two intervals from the measured time, so with a forger at share a the lanes measure 1 - 2a(1 - a) of real time: 58% at 30%, 50% at 50%. The estimator reads that as more hash rate, the short lane disagrees with the robust long lane by more than 25% for as long as the forging lasts and therefore rules, and the target hardens until the block rate matches the shortened clock. Stamping at the earliest allowed time is worse, because the forger's own stamps enter the past-median window and drag the floor down (the measured offsets grow from -135 s to -2,100 s at 50% over 4 hours): -58% at 30%, -88% at 50%, worst gaps of 234 s. Kaspa's rule measures the span between the earliest and latest of 661 samples, so the same forging only stretches one end of a 2,644 s window: +5% to +11% easing.

Test network (3 igneumd nodes from diff-attacks, genesis bits 0x1f010000, honest miner A on node 1 for 15 minutes, forger F on node 2 honest for the first 5 minutes then on node 3 with the offset; two 4-thread CPU miners, about 50% each; the shared Mac at load 15 to 30):

Run (15 min, forging from 300 s) Phase Chain blocks/s Mean difficulty Forger share Forger stamp offset Delivered hash (miner summaries)
Igneum, earliest allowed stamp (testnet/ts-past-igneum.csv, 517 blocks, 437 chain) honest 0 to 300 s 0.97 91,375 0.49 +2 s A 0.078 MH/s over the run, F 0.069 MH/s over its forging leg
forging 300 to 900 s 0.24 275,135 0.53 -322 s (from -121 s at 300 s to -566 s at 870 s: the past median follows the forger down) same
last 300 s 0.20 341,191 0.59 -471 s same
Igneum, latest allowed stamp (testnet/ts-future-igneum.csv, 767 blocks, 649 chain) honest 0 to 300 s 0.98 89,363 0.51 +3 s A 0.112 MH/s, F 0.110 MH/s
forging 300 to 900 s 0.59 170,222 0.52 +134 s same
last 300 s 0.50 191,259 0.50 +135 s same
Kaspa's rule, earliest allowed stamp (testnet/ts-past-kaspa.csv, 1,454 blocks, 1,134 chain) 0 to 300 s (genesis held to block 600 at 210 s, then 84,000) 1.73 41,405 0.52 +1 s F 0.106 MH/s (A's summary line was cut by the stop)
forging 300 to 900 s 1.02 83,429 0.51 -173 s same
last 300 s 0.96 83,427 0.53 -180 s same

The offsets are the forger's header stamps against the honest miner's clock (every chain block placed at the stamp of the nearest earlier block of miner A, whose node has the real clock). The first future-stamp run (/tmp/igneum-diff-attacks/ts-future-igneum, not archived) was contaminated for 40 s by a mis-timed start of the next run's honest miner and was repeated; it showed the same halving (0.5 to 0.6 blocks/s at 1.7x difficulty).

Both runs reproduce the simulator's direction and size: the block rate falls to a fifth within four minutes of past-stamping and difficulty climbs 3x on an unchanged hash rate (the simulator's 50% past case: rate 0.12, difficulty 9.9x after hours); future-stamping halves the rate at 1.7x difficulty (simulator: 0.56 and 1.8x). Kaspa's rule on the same genesis and schedule retargeted once at block 600 (210 s, the genesis was 2.5x too easy for the two miners) and then held 83,400 through ten minutes of past-stamping at 1.0 block/s: its window of 661 samples still reached back to genesis, so the forger's -180 s stamps moved one end of a 900 s span.

Proposed rule change (not applied; a diff for the consensus-engineer)

Two parts, and both are needed. The simulator shows each alone is not enough (results.md, "tight rules alone": the unchanged controller under the tight rules still loses 35% at 30% past-stamping, because a run of forged blocks stays inside the per-step clamp while the honest pay-back after it does not; "clock alone": the sanitised clock under Kaspa's 132 s rules still fails at 50%, because with a forgery range larger than the cap the clock's lag is a martingale at a 50% share).

Part A, the timestamp rules. Bound the forgery range to half the solvetime cap in both directions, so that a forged step and its pay-back both fit inside the cap:

--- a/docs/spec/02-consensus.md  (section 2.3, parameter table and the paragraph after it)
-| Timestamp rules | 132 s future tolerance in isolation, strictly above the sampled past median in context | Kaspa's, unchanged | Designed |
+| Timestamp rules | 10 s future tolerance in isolation (`FUTURE_TOLERANCE_MS`); in context strictly above the sampled past median (Kaspa's window, unchanged) and at least the selected parent's timestamp minus 10 s (`BACK_TOLERANCE_MS`) | Measured: a 50% forger drifts the rate +0.2% with both bounds at 10 s and the clock below, -38% with the parent bound alone, -83% with Kaspa's bounds (`sim/difficulty/attacks/`) | Measured |
-What the clamps and caps bound. A forged timestamp can move one capped solvetime by at most 20 s in either direction and the next honest block's solvetime cancels it under the symmetric cap (a run of L forged blocks nets L - 1 s of real time), so a minority cannot bias a lane by more than a few percent; ...
+What the clamps and caps bound. A forged timestamp moves the sanitised clock by at most 20 s and the honest blocks after it move the clock back to real time, so the sum of steps over any lane telescopes to the real span up to one step at each end; the timestamp rules keep every forgery inside 10 s, half the cap, so the pay-back fits in one step and a 50% forger cannot hold the clock away from real time (measured: +0.2% rate drift at 50% past-stamping, +0.9% future, `sim/difficulty/attacks/README.md`); ...
--- a/consensus/core/src/igneum.rs  (module difficulty)
+    /// A header may be at most this far ahead of the node's clock (Igneum's own bound; Kaspa's 132 s
+    /// `TIMESTAMP_DEVIATION_TOLERANCE` stays as the size of the past-median window only)
+    pub const FUTURE_TOLERANCE_MS: u64 = 10_000;
+    /// A header may be at most this far behind its selected parent
+    pub const BACK_TOLERANCE_MS: u64 = 10_000;
--- a/consensus/src/pipeline/header_processor/pre_ghostdag_validation.rs
     fn check_block_timestamp_in_isolation(&self, header: &Header) -> BlockProcessResult<()> {
-        let max_block_time = unix_now() + self.timestamp_deviation_tolerance * 1000;
+        let max_block_time = unix_now() + igp::FUTURE_TOLERANCE_MS;
--- a/consensus/src/pipeline/header_processor/post_pow_validation.rs
     pub fn check_median_timestamp(&self, ctx: &mut HeaderProcessingContext, header: &Header) -> BlockProcessResult<()> {
         let (past_median_time, window) = self.window_manager.calc_past_median_time(ctx.ghostdag_data())?;
         ctx.block_window_for_past_median_time = Some(window);
-        if header.timestamp <= past_median_time {
-            return Err(RuleError::TimeTooOld(header.timestamp, past_median_time));
+        let parent_time = self.headers_store.get_timestamp(ctx.ghostdag_data().selected_parent).unwrap();
+        let floor = past_median_time.max(parent_time.saturating_sub(igp::BACK_TOLERANCE_MS + 1));
+        if header.timestamp <= floor {
+            return Err(RuleError::TimeTooOld(header.timestamp, floor));
         }

The past-median window keeps Kaspa's 27 samples (MEDIAN_TIME_SAMPLED_WINDOW_SIZE is derived from the 132 s constant, which is why the future bound is a new constant and not a change to TIMESTAMP_DEVIATION_TOLERANCE). The template builder's floor (min_block_time) must use the same floor. 10 s is tied to the 20 T cap: the constraint is range <= CAP / 2 in both directions; a 20 s range would need a 40 T cap, which the base measurements rejected for the idle-gap over-ease. Honest clocks within 10 s is what NTP gives; a miner whose clock is further out has its blocks rejected until it fixes it, as with Kaspa's 132 s today.

Part B, the controller. Measure every chain step on a sanitised running clock instead of the raw parent stamp, so that a forgery is paid back by the honest blocks after it instead of being cancelled to zero measured time:

--- a/docs/spec/02-consensus.md  (section 2.3, rule 1)
-1. Every lane estimates the hash rate as work over time (blue-work increment over clamped solvetime), never as average target times average solvetime.
+1. Every lane estimates the hash rate as work over time (blue-work increment over the step of a sanitised clock), never as average target times average solvetime. The clock: c(genesis) = t(genesis); c(b) = max(c(p) + clamp(t(b) - c(p), -20 T, +20 T), t(b) - 60 T) for selected parent p; the step of b is min(c(b) - c(p), 20 T). The steps of a lane sum to the clock's span over it, so a forged stamp is paid back by the honest blocks after it; the 60 T bound keeps an honest idle gap from being paid back as cap-sized steps for many blocks (measured: without it the polluted case peaks at 275x, with it 11.4x against 8.0x today). The long lane keeps the raw sampled span.
--- a/consensus/src/processes/difficulty.rs  (igneum_difficulty_bits, the chain walk)
-            let solvetime = (cur_data.timestamp as i64 - parent_data.timestamp as i64).clamp(-cap, cap);
+            let solvetime = (clock(cur) as i64 - clock(parent) as i64).min(cap);

where clock(b) is the sanitised clock, stored per header beside blue work (a one-step recurrence along the selected chain: clock(b) = max(clock(p) + clamp(t(b) - clock(p), -cap, cap), t(b) - 3 * cap), computed at header processing where blue_work is), so the walk needs no anchor. Walking it instead from the oldest collected block's raw stamp works only with a margin of a few extra steps and is not recommended at 50%. In the simulator the change is IgneumSan in attacks.py (push: the clock; rate_s: the steps), 25 lines.

What the change does to the base profiles (3 seeds, results.md, "base profiles"): up50, epoch30, walk10 and hop10 unchanged to the second; down50 762 s against 782 s; warm-up from 10x too hard 327 s against 381 s; polluted peak 11.4x against 8.0x; steady std unchanged. The attacks of scenarios 1, 2, 4, 5 and 6 under the candidate are identical to the unchanged rule to three decimals (results.md, full suite, rows igneum-san), as they must be: with honest stamps the sanitised clock is the raw clock. Under Kaspa's 132 s rules the candidate alone is worse than the unchanged rule against a forger (rate 0.09 at 30% future-stamping), which is the martingale argument above in numbers: the clock is only safe once the forgery range is below half the cap.

The timestamp attack under the candidate, both parts applied (3 seeds):

Rule Timestamp rules Share Stamp Forger offset s Rate after 1 h (drift) Worst seed Mean D ratio Worst gap s
igneum (unchanged) tight 30 future 10 1.008 (+0.8%) +1.8% 1.00 9.7
igneum (unchanged) tight 30 past -17 0.636 (-36.4%) -38.1% 1.60 32.5
igneum (unchanged) tight 30 alt 1 0.975 (-2.5%) -3.5% 1.03 10.7
igneum (unchanged) tight 50 future 10 1.009 (+0.9%) +2.1% 1.00 9.8
igneum (unchanged) tight 50 past -30 0.170 (-83.0%) -85.0% 5.92 106.0
igneum (unchanged) tight 50 alt 2 0.949 (-5.1%) -5.7% 1.06 11.6
igneum-san (candidate) tight 30 future 10 1.008 (+0.8%) +1.8% 1.00 9.7
igneum-san (candidate) tight 30 past -16 1.008 (+0.8%) +2.4% 1.00 9.9
igneum-san (candidate) tight 30 alt 1 1.004 (+0.4%) +1.2% 1.00 9.8
igneum-san (candidate) tight 50 future 10 1.009 (+0.9%) +2.1% 1.00 9.8
igneum-san (candidate) tight 50 past -22 1.007 (+0.7%) +2.7% 1.00 10.1
igneum-san (candidate) tight 50 alt 2 1.011 (+1.1%) +2.2% 1.00 10.0
kaspa tight 30 to 50 all 1.000 to 1.009 +1.5% 1.00 9.2

Every candidate cell is inside 3% on the worst seed; the unchanged rule under the tight rules alone still loses 36% and 83% to past-stamping; Kaspa's rule under the tight rules is flat. The candidate under Kaspa's 132 s rules (results.md, "clock alone") still collapses at 50%, which is why Part A is not optional. A 60 s lag bound was chosen over 120 s because the polluted peak is 11.4x against 17.8x and the forger results are the same (results.md, "lag bound").

Scenario 7: the flood panic (side finding)

Another agent's pump harness fed the difficulty node about 85 blocks per second with wall-clock timestamps (blocks that bypass proof of work; docs/bench-log.md, hash-rate decay entry) and the node panicked at consensus/src/processes/difficulty.rs:431, calc_work: "Work should not exceed 2**192". Mechanism: the rule hardens the target by 3% per block with no floor; at 12 ms per block the epoch and short lanes read a hash rate 85x the target's, the harden clamp binds on every block, and the target passes below 2^64 after 4,142 blocks (49 s at that rate). BlueWorkType is 192 bits, so calc_work of the next header's bits (the parent's work for the epoch lane, and the header's own blue work) cannot be represented and the node aborts. Under real proof of work the flood cannot be sustained (every 23 blocks double the hashes needed, so the chain simply stops), but a node must not panic on input it accepted, and skip_proof_of_work nodes and simpa accept it.

Reproduction, exact: cargo test -p kaspa-consensus --lib igneum_flood -- --nocapture on the diff-attacks branch (igneum_flood_at_85_blocks_per_second_drives_the_target_below_block_work_range, a should_panic test that drives igneum_target from the attack network's genesis bits 0x1f010000 with 12 ms steps and calls calc_work on each output). Output: target below 2^64 at block 4142 (epoch start 3600), bits 0x900fe5b, then the panic at line 431. FAIL.

Proposed fix, a floor on the target, the mirror of the existing MAX_DIFFICULTY_TARGET cap:

--- a/docs/spec/02-consensus.md  (section 2.3, rule 4)
-4. The output may make the target fall (difficulty rise) by at most 3% per block and rise (difficulty fall) by at most 10% per block, relative to the selected parent's target, then is capped at `MAX_DIFFICULTY_TARGET`.
+4. The output may make the target fall (difficulty rise) by at most 3% per block and rise (difficulty fall) by at most 10% per block, relative to the selected parent's target, then is clamped to [`MIN_DIFFICULTY_TARGET`, `MAX_DIFFICULTY_TARGET`]. `MIN_DIFFICULTY_TARGET` = 2^128: 2^128 expected hashes per block is unreachable by any network (the whole Bitcoin network is near 2^69 hashes per second, approximate) and keeps every block's work under 2^128, so the 192-bit blue work cannot overflow in a century of blocks (measured: without the floor, 85 blocks per second underflows the work type after 4,142 blocks, `sim/difficulty/attacks/README.md` scenario 7).
--- a/consensus/core/src/igneum.rs  (module difficulty)
+    /// Hardest target the rule may emit: 2^128 expected hashes per block (`MIN_DIFFICULTY_TARGET`)
+    pub const MIN_TARGET_BITS: u32 = 128;
--- a/consensus/src/processes/difficulty.rs  (igneum_target)
     let lo = parent * 100 / (100 + igp::HARDEN_PERCENT);
     let hi = parent * (100 + igp::EASE_PERCENT) / 100;
-    let out = candidate.max(lo).min(hi).min(max_target);
+    let min_target = Uint320::from_u64(1) << igp::MIN_TARGET_BITS;
+    let out = candidate.max(lo).min(hi).min(max_target).max(min_target);

and the same .max(min_target) on the last line of kaspa_difficulty_bits, which has the same hole 17 times more slowly (its window shrinks the target 85x per 2,644 blocks at that rate). A test for the floor is the flood test with the should_panic removed and assert!(bits_target >= 2^128) on every output.

Limits

The simulator has one chain and no DAG, as the base simulator; a forger on a DAG also controls the timestamps of its red and merged blocks, which the lanes ignore. The past median is modelled on the chain's own sampled window. The test network's forger shares a loaded machine with the honest miner, so its share wanders between 30 and 70% from bin to bin. Kaspa's rule on the test network had not retargeted within 15 minutes of forging (600-block dead zone from genesis), so the live Kaspa comparison is the dead zone itself, as on 3 October. The candidate controller was run in the simulator only; the node change is a proposal.

Runs

python3 attacks.py --rules igneum,igneum-san,kaspa --seeds 3            every scenario (about 20 min, 4 jobs)
python3 attacks.py --scenario ts --ts-rules tight --rules igneum,igneum-san,kaspa --seeds 3
python3 attacks.py --scenario none --base --rules igneum,igneum-san,kaspa --seeds 3
python3 testnet.py --rule igneum --scenario ts --ts-offset-ms -1000000000 --minutes 15 --out /tmp/igneum-diff-attacks/ts-past-igneum
python3 testnet.py --rule igneum --scenario ts --ts-offset-ms 130000 --minutes 15 --out /tmp/igneum-diff-attacks/ts-future-igneum
python3 testnet.py --rule kaspa --scenario ts --ts-offset-ms -1000000000 --minutes 15 --out /tmp/igneum-diff-attacks/ts-past-kaspa
python3 testnet.py --analyse /tmp/igneum-diff-attacks/ts-past-igneum