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> |
||
|---|---|---|
| .. | ||
| testnet | ||
| attacks.py | ||
| README.md | ||
| results.md | ||
| testnet.py | ||
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.
-
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%). -
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.
-
Timestamp stretching. FAIL on every Igneum cell. See the next section.
-
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_floorcolumn). 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. -
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.
-
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