Difficulty: timestamp attack fixed in spec 2.3, simulator, analysis addendum, ledger M23

Spec 2.3: rule 1 measures every chain step on a sanitised clock stored per header
(c(b) = max(c(p) + clamp(t(b) - c(p), -20 T, +20 T), t(b) - 60 T), step min(c(b) - c(p),
20 T)); rule 4 bounds the output to [2^128, MAX_DIFFICULTY_TARGET]; the timestamp rules
are Igneum's own, 10 s ahead of the clock and 10 s behind the selected parent beside the
unchanged past-median rule; new parameter rows, the bounds paragraph rewritten (the old
"next honest block cancels it" was the attack), the attack and test-network results added.

sim/difficulty/sim.py: class Igneum carries the same clock, lag bound and floor, so the
rule as simulated is the rule as coded (attacks.py's igneum-san is now identical to it).

docs/analysis/difficulty-2026-10-03.md section 11: the attack, the three parts, before and
after tables (simulator seeds 7 to 9, base-profile regression within 10% on the 3-seed
means, pool hopping unchanged, the two 15-minute 3-node forger runs), unit tests, limits.

docs/bench-log.md: the 4 October entry. docs/fud-ledger.md: M23, status Fixed.

Node side: vendor/igneum-node branch difficulty, commit 52eacad9.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 00:56:20 +00:00
parent 42b236040d
commit 2622fd2ccb
5 changed files with 180 additions and 18 deletions

View file

@ -445,3 +445,122 @@ code and labelled approximate. The step-down remains the slowest case (657 s for
targeting would cut it further and is left out on purpose (spec 2.3). Red blocks' work is ignored
by every lane, as by Kaspa's estimator. The 4 BPS and 10 BPS steps need the sampled window to
replace the chain walk for the epoch lane and a re-derivation of the block-count constants.
## 11. Addendum, 4 October 2026: the timestamp attack and the fix
The attack run of `sim/difficulty/attacks/README.md` (consensus test engineer, 4 October 2026) failed
the rule of sections 4 to 9 on one scenario and found one panic. Timestamp stretching: a forger at
30 or 50% of the hash rate stamping every block at the edge of Kaspa's rules (132 s ahead, or the
past median plus one) took the simulated block rate to 0.66 / 0.42 / 0.51 of target at 30% (latest
/ earliest / alternating) and 0.56 / 0.12 / 0.23 at 50%, difficulty 1.5x to 9.9x on an unchanged
hash rate; on a 3-node test network a 50% forger took the chain to 0.24 blocks/s at 3x difficulty
(earliest) and 0.59 at 1.9x (latest). The cause was the sentence in spec 2.3 that was meant as the
defence: the symmetric 20 T clamp cancelled every forged step against the honest step after it, so
the lanes measured 1 - 2a(1 - a) of real time at forger share a. Block flood: 85 blocks per second
of PoW-less input drove the target below 2^64 after 4,142 blocks and `calc_work` panicked.
The three parts the README proposed are now the rule (`difficulty` branch, commit "Difficulty:
timestamp rules 10 s both ways, sanitised clock per header, target floor 2^128"):
| Part | Rule | Where |
|---|---|---|
| A | a header is at most 10 s ahead of the node's clock (`FUTURE_TOLERANCE_MS`) and at least its selected parent's timestamp minus 10 s (`BACK_TOLERANCE_MS`), beside the unchanged past-median rule; the template floor is the same | `pre_ghostdag_validation.rs`, `post_pow_validation.rs` (new error `TimeTooFarBehindParent`), `virtual_processor/processor.rs` |
| B | every validated header gets a sanitised clock c(b) = max(c(p) + clamp(t(b) - c(p), -20 T, +20 T), t(b) - 60 T), stored per header (`stores::clock`, prefix 62, deleted at pruning); the short and epoch lanes measure clock steps min(c(b) - c(p), 20 T); the long lane keeps the raw sampled span | `igneum.rs` (`sanitised_clock`, `clock_step`), `header_processor/processor.rs`, `difficulty.rs` (the chain walk) |
| C | both rule paths bound the output to [2^128, `MAX_DIFFICULTY_TARGET`] (`bound_target`) | `difficulty.rs` |
The simulator (`sim.py`, class `Igneum`) carries the same clock and floor, so the rule as simulated
is the rule as coded; `attacks.py`'s candidate `igneum-san` is now identical to `igneum`.
### Before and after
Timestamp stretching, simulator, seeds 7 to 9, block rate after one hour of forging as a fraction
of target (mean; worst seed in brackets), 6-hour runs (`attacks.py --scenario ts`):
| Forger | Stamp | 3 Oct rule, Kaspa's 132 s bounds | 3 Oct rule, 10 s bounds alone | 4 Oct rule (bounds and clock) |
|---|---|---|---|---|
| 30% | latest | 0.66 | 1.008 | 1.008 (1.018) |
| 30% | earliest | 0.42 | 0.636 (0.619) | 1.008 (1.024) |
| 30% | alternating | 0.51 | 0.975 | 1.004 (1.012) |
| 50% | latest | 0.56 | 1.009 | 1.009 (1.021) |
| 50% | earliest | 0.12 | 0.170 (0.150) | 1.007 (1.027) |
| 50% | alternating | 0.23 | 0.949 | 1.011 (1.022) |
| Mean difficulty ratio, worst cell | 9.9x | 5.9x | 1.00 |
| Worst gap, worst cell | 234 s | 106 s | 10.1 s |
| Flood, 85 blocks/s, 6,000 blocks | panic at block 4,142 | panic | floor 2^128 reached at block 2,635 (simulator) and about 2,630 (unit test), no panic |
The middle column is from the README's "tight rules alone" run; the clock alone under Kaspa's
bounds still collapsed at 50% (the clock's lag is a martingale once the forgery range exceeds half
the cap), which is why both parts ship. Kaspa's rule under the 10 s bounds drifts +0.0% to +0.9%.
Base profiles, simulator, seeds 7 to 9, 3-seed means (settled = 121-block mean within 10% of
target for 100 blocks; the regression criterion was 10% of the 3 October numbers):
| Profile | 3 Oct rule | 4 Oct rule | Change |
|---|---|---|---|
| Devnet record, 75x step, settled s | 102.8 (78.7, 151.3, 78.5) | 91.0 (73.1, 121.1, 78.9) | -11% (faster) |
| Devnet record, first within 10% s | 70.3 | 68.5 | -3% |
| up50, settled s | 154.4 | 154.4 | 0 |
| down50, settled s | 782.3 | 762.2 | -3% |
| down50, worst gap s | 78.3 | 73.9 | -6% |
| epoch30, settled s (mean of the steps) | 87.6 | 87.6 | 0 |
| hop10, settled s | 239.4 | 245.1 | +2% |
| polluted, settled s | 70.1 (70.0, 61.5, 78.9) | 74.9 (62.7, 70.0, 92.0) | +7% (seed 9: +17%) |
| polluted, peak rate | 8.0x | 11.4x | the price of the 60 T lag bound (no bound: 275x) |
| Steady std of the block rate | 0.038 to 0.045 | unchanged | 0 |
With honest stamps the sanitised clock is the raw clock except after an idle gap, which is where
the two profiles that contain one (down50, polluted, the record) move: a gap longer than 60 T is
paid back as 20 T steps for three blocks instead of being clamped once, so the rule eases a little
faster after a gap (down50, the record) and overshoots a little more on the polluted window.
Pool hopping, simulator, 24 h, hopper's blocks per hash against the always-on base (`attacks.py
--scenario hop`, seeds 7 to 9): unchanged to three decimals, as it must be with honest stamps. Greedy
hopper +1.5% at 10% of the base, +1.0% at 30%, +0.9% at 50%, +0.7% at 100%; with a 60 s minimum
dwell +1.3%, +0.1%, -1.7%, -4.0%; on 8 to 44% of the time, 227 to 818 switches a day; Kaspa's rule
+0.4% to +0.8%. Under 5% in every cell; the 0.7-point excess over Kaspa's rule stays as the README
left it.
Test network, 3 `igneumd` nodes from the fix plus the attack hook of `diff-attacks` (the forger's
node shifts its template stamps; validation is the fixed rule on every node), genesis bits
`0x1f010000`, honest 4-thread miner A on node 1 for 15 minutes, forger F (4 threads, about 50%)
honest on node 2 for 5 minutes then on node 3 with the offset, ports 28500 to 28521, appdir
`/tmp/igneum-diff-fix` (records `ts-past-igneum/record.csv`, `ts-future-igneum/record.csv` there):
| Run (15 min, forging from 300 s) | Phase | Chain blocks/s | All blocks/s | Mean difficulty | Forger share | Forger stamp offset s (mean; min, max) | Delivered hash |
|---|---|---|---|---|---|---|---|
| 4 Oct rule, earliest allowed stamp (986 blocks, 823 chain, 0 rejected) | honest 60 to 300 s | 0.82 | 1.00 | 102,226 | 0.48 | +3 (-15, +10) | A 0.110 MH/s over the run, F 0.109 MH/s over its forging leg |
| | forging 300 to 900 s | 0.88 | 1.02 | 100,134 | 0.48 | -23 (-90, -10) | same |
| | last 300 s | 0.83 | 0.95 | 106,141 | 0.47 | -24 (-90, -10) | same |
| 4 Oct rule, latest allowed stamp, +9 s (1,028 blocks, 829 chain, 0 rejected) | honest 60 to 300 s | 0.78 | 0.95 | 102,382 | 0.41 | +2 (0, +11) | A 0.112 MH/s, F 0.111 MH/s |
| | forging 300 to 900 s | 0.89 | 1.09 | 96,717 | 0.52 | +13 (+9, +26) | same |
| | last 300 s | 0.89 | 1.11 | 102,754 | 0.54 | +12 (+9, +26) | same |
| 3 Oct rule, earliest (README, `ts-past-igneum.csv`) | honest 0 to 300 s | 0.97 | | 91,375 | 0.49 | +2 | A 0.078, F 0.069 MH/s |
| | forging 300 to 900 s | 0.24 | | 275,135 | 0.53 | -322 (to -566) | same |
| | last 300 s | 0.20 | | 341,191 | 0.59 | -471 | same |
| 3 Oct rule, latest, +130 s (README, `ts-future-igneum.csv`) | honest 0 to 300 s | 0.98 | | 89,363 | 0.51 | +3 | A 0.112, F 0.110 MH/s |
| | forging 300 to 900 s | 0.59 | | 170,222 | 0.52 | +134 | same |
| | last 300 s | 0.50 | | 191,259 | 0.50 | +135 | same |
Chain blocks/s is the README's metric (every chain block placed at the stamp of the nearest earlier block of miner A, whose node keeps the real clock); with two miners on different nodes 16 to 19% of the blocks are merged rather than chained, so the all-blocks column (blocks whose DAA score falls inside the phase's chain range, over the phase length) is the rate the DAA sees. The honest phase is taken from 60 s because the genesis (2.5x too easy for two miners) gives 68 blocks in the first 30 s. Under the fixed rule the block rate is flat across the forging leg within the noise of two 4-thread CPU miners on a loaded Mac (chain 0.82 to 0.88 and 0.78 to 0.89, all-blocks 1.00 to 1.02 and 0.95 to 1.09), difficulty stays within 6% of the honest level (100k to 106k against 102k) on an unchanged delivered hash rate, and the honest nodes rejected nothing. The forger's offsets are what the rules allow: 10 s behind its parent, compounding to -90 s over a run of its own blocks before the past median stops it; 9 s ahead of its clock. Before the fix the same schedule took the chain to 0.24 blocks/s at 3x difficulty and 0.59 at 1.9x.
### Unit tests
`cargo test --release -p kaspa-consensus --lib difficulty`: 12 pass, the nine of section 8 plus
`igneum_flood_at_85_blocks_per_second_stops_at_the_minimum_target` (the `should_panic` test of
`diff-attacks` with the panic removed: every output at or above 2^128, the floor reached between
blocks 2,000 and 3,000, the floored work under 2^129), `both_rules_share_the_target_floor` and
`igneum_clock_steps_pay_a_forgery_back` (three blocks stamped 10 s behind their parents followed by
honest blocks: clock steps sum to the 8 s real span, raw clamped solvetimes sum to -6 s).
`cargo test --release -p kaspa-consensus-core --lib igneum`: 9 pass, including
`sanitised_clock_telescopes_a_forged_stamp` (a +10 s stamp and the honest block after it sum to
real time; steps clamp at 20 s both ways; a 300 s idle gap jumps the clock to the stamp minus 60 s).
### Limits
The clock store adds 8 bytes plus key per header. A header whose selected parent has no stored
clock (the pruning point after a proof, trusted headers) starts the clock at the parent's raw
stamp, so a forger could bias at most one window after a sync; not measured. The DAG effect of
forged stamps on red and merged blocks is still unmeasured (one chain in the simulator). Kaspa's
integration test `difficulty_test` pins `KaspaSampled` and the upstream timestamp tests assume the
132 s bounds; they were not re-run here. The hopper's 0.7-point excess over Kaspa's rule (README
scenario 1) is unchanged by this fix and stays open.

View file

@ -490,3 +490,13 @@ Scenario 5, pulsed miner (five steady voters at 1/6, one burster at 1/6 pulsing
| Verdict | amplification PASS, lock-alone FAIL (F1) | amplification PASS, lock-alone PASS |
Notes. The fallback path ("fallback: any node may aggregate") fired 0 times in both after runs: every voter on the single node is local and, with six keys of similar weight, each is drawn at every checkpoint, so every certificate named a drawn aggregator. The "paused" reading between the window filling and the first lock is the report's label for "window full, no lock yet"; it lasted one sample in s5 and five minutes in s2 (the floor against silent sybil weight, 3.3.1). The repo harness `tools/finality-attacks/run.mjs` keeps its pre-fix s2 and s5 criteria and should adopt the two measurements above; the fin-attacks miner prints no `FINALITY` line (that is the fin-fixes miner).
## 4 October 2026, difficulty rule: timestamp attack fixed (tight bounds, sanitised clock, target floor), simulator regression, 3-node forger test (consensus-engineer)
Machine: the same Apple M5 Max, shared with other agents' simulations (load 6 to 10). Worktree `vendor/igneum-node-diff`, branch `difficulty`, commit "Difficulty: timestamp rules 10 s both ways, sanitised clock per header, target floor 2^128" on `3ea7a3e3`; built with `CARGO_TARGET_DIR=target nice -n 19 cargo ... -j 4` on the rustup toolchain (cargo 1.99; the Homebrew cargo 1.69 in PATH cannot read the 2024 edition). Everything in `docs/analysis/difficulty-2026-10-03.md` section 11 and spec 2.3; ledger M23.
The change, the three parts of `sim/difficulty/attacks/README.md` as proposed: (A) `FUTURE_TOLERANCE_MS` 10 s in isolation and `BACK_TOLERANCE_MS` 10 s behind the selected parent in context (new `RuleError::TimeTooFarBehindParent`), past-median rule and window unchanged, template floor matched; (B) a sanitised clock per header, c(b) = max(c(p) + clamp(t(b) - c(p), -20 T, +20 T), t(b) - 60 T), in a new store (`stores::clock`, prefix `IgneumClock` 62, written in `commit_header`, deleted at pruning, falling back to the parent's raw stamp when the parent has none), the short and epoch lanes walking clock steps min(c(b) - c(p), 20 T); (C) `bound_target` floors both rules at 2^128. `sim/difficulty/sim.py` class `Igneum` carries the same clock and floor.
Unit tests: `cargo test --release -p kaspa-consensus --lib difficulty` 12 pass (the `diff-attacks` flood test with `should_panic` removed: every output at or above 2^128, floor reached between blocks 2,000 and 3,000, work under 2^129; `both_rules_share_the_target_floor`; `igneum_clock_steps_pay_a_forgery_back`: three blocks 10 s behind their parents then honest blocks, clock steps sum to the 8 s real span, raw clamped solvetimes to -6 s); `cargo test --release -p kaspa-consensus-core --lib igneum` 9 pass (`sanitised_clock_telescopes_a_forged_stamp`).
Simulator, seeds 7 to 9 (`attacks.py --scenario ts --ts-rules tight`), forger at 30 / 50% stamping latest / earliest / alternating, block rate after one hour of forging: 1.008 / 1.008 / 1.004 and 1.009 / 1.007 / 1.011 of target (drift +0.4% to +1.1%, worst seed +2.7%), mean difficulty ratio 1.00, worst gap 10.1 s; the 3 October rule under Kaspa's bounds: 0.66 / 0.42 / 0.51 and 0.56 / 0.12 / 0.23, under the 10 s bounds alone 0.636 and 0.170 on the earliest cells. Flood at 85 blocks/s in the simulator: floor reached at block 2,635, no overflow. Pool hopping (`--scenario hop`, 24 h): unchanged to three decimals, +1.5% at most greedy, -1.7% and -4.0% with a 60 s dwell at 50 and 100%.
Base-profile regression, 3-seed means, 3 October against 4 October: record 102.8 against 91.0 s settled (first within 10% 70.3 against 68.5 s); up50 154.4 against 154.4; down50 782.3 against 762.2 (worst gap 78.3 against 73.9 s); epoch30 87.6 against 87.6; hop10 239.4 against 245.1; polluted 70.1 against 74.9 (seed 9: 78.9 against 92.0), peak 8.0x against 11.4x; steady std unchanged. All means within 10%; the two profiles with an idle gap move, because a gap over 60 T is paid back as three 20 T steps instead of one clamped step.
Test network (3 `igneumd` nodes from the fix plus the `diff-attacks` template hook on a scratch branch, ports 28500 to 28521, appdir `/tmp/igneum-diff-fix`, genesis bits `0x1f010000`, honest 4-thread miner A on node 1 for 15 min, forger F 4 threads honest on node 2 for 5 min then on node 3 with the offset, two runs): earliest allowed stamp (offset -1e9 ms, floored by the rules; 986 blocks, 823 chain, 0 rejected): chain rate 0.82 blocks/s honest (60 to 300 s) at difficulty 102,226, 0.88 during forging (300 to 900 s) at 100,134, 0.83 in the last 300 s at 106,141, all-blocks rate 1.00 / 1.02 / 0.95, forger offsets -10 to -90 s (mean -23), hash A 0.110 MH/s, F 0.109. Latest allowed stamp (+9,000 ms; 1,028 blocks, 829 chain, 0 rejected): 0.78 honest at 102,382, 0.89 forging at 96,717, 0.89 last 300 s at 102,754, all-blocks 0.95 / 1.09 / 1.11, offsets +9 to +26 s, hash A 0.112, F 0.111. Flat within the CPU miners' noise; the 3 October rule on the same schedule fell to 0.24 blocks/s at 275,135 and 0.59 at 170,222 (bench-log entry above). Records in `/tmp/igneum-diff-fix/ts-past-igneum/record.csv` and `ts-future-igneum/record.csv` (not archived into the repo).
Not done: the DAG effect of forged stamps on red and merged blocks (one chain in the simulator); the clock of a header whose parent arrived through a pruning proof starts from the raw stamp (one window of exposure after a sync, unmeasured); upstream's timestamp integration tests assume the 132 s bounds and were not re-run; the hopper's 0.7-point excess over Kaspa's rule stays open.

View file

@ -1457,6 +1457,17 @@ Answer: Correct, medium. The 3 October attack run showed it: a 9,000-call modexp
Evidence: `docs/bench-log.md`, 4 October 2026 "execution layer attack fixes" (before and after table); the 3 October attack entry, F-exec-B; unit tests `executor::over_budget_pgas_is_aborted_charged_and_the_nonce_advances`, `executor::the_cap_is_the_remaining_block_budget`, `executor::estimate_reports_the_cap`, `pool::estimated_proving_gas_is_bounded_by_the_block_proving_limit`, `pool::template_never_exceeds_the_remaining_proving_budget`.
## Difficulty attack findings (4 October 2026): fixed
### M23. Forge timestamps inside the rules and the controller mines you a 10x difficulty for free
"Your fast controller clamps every solvetime to 20 s both ways and says the next honest block cancels a forged one. Good: I stamp every block of mine at the earliest the past median allows, the honest block after me gets clamped to +20 s, the pair sums to zero, and your lanes measure 1 - 2a(1 - a) of real time at my share a. With half the hashrate your chain runs at a fifth of its rate and 9.9x the difficulty, on no extra hash. Kaspa's window only drifts 5 to 11%. And while I am at it, 85 blocks a second of PoW-less input drives your target below 2^64 and `calc_work` panics the node."
Status: Fixed (4 October 2026). Spec 2.3 rules 1 and 4 and the timestamp rules; `difficulty` branch of `vendor/igneum-node` (`consensus/core/src/igneum.rs` difficulty module, `consensus/src/model/stores/clock.rs`, `header_processor/{pre_ghostdag_validation,post_pow_validation,processor}.rs`, `processes/difficulty.rs`, `virtual_processor/processor.rs`); `sim/difficulty/sim.py`.
Answer: Correct on every point, and measured first by our own attack run (`sim/difficulty/attacks/README.md`, scenarios 3 and 7): in the simulator a 50% forger took the block rate to 0.12 (earliest stamp) and 0.56 (latest) of target; on a 3-node test network to 0.24 and 0.59 blocks/s at 3x and 1.9x difficulty on an unchanged hash rate; the flood underflowed the 192-bit work type after 4,142 blocks. Spec 2.3's "the next honest block cancels it" was the bug, not the defence. Three rules now hold. (A) A header may be at most 10 s ahead of the node's clock (`FUTURE_TOLERANCE_MS`, Kaspa's 132 s kept only as the past-median window size) and at least its selected parent's timestamp minus 10 s (`BACK_TOLERANCE_MS`) beside the past-median rule, so every forgery is inside half the solvetime cap. (B) Every chain step of the short and epoch lanes is measured on a sanitised clock stored per header, c(b) = max(c(p) + clamp(t(b) - c(p), -20 T, +20 T), t(b) - 60 T), step = min(c(b) - c(p), 20 T), so forged steps telescope and are paid back by the honest blocks after them instead of cancelling to zero. (C) Both rule paths bound the output at 2^128 (`bound_target`), so block work stays under 2^128. Measured after the fix, simulator, seeds 7 to 9: the forger at 30% or 50%, earliest, latest or alternating, drifts the rate +0.4% to +1.1% after an hour (worst seed +2.7%, difficulty ratio 1.00, worst gap 10 s); the base profiles move by under 10% on the 3-seed means (down50 faster, polluted peak 12x against 8x, hopping and the record unchanged within noise); the flood stops at 2^128 after about 2,630 blocks with no panic (unit test). Test network, 3 `igneumd` nodes, 50% forger for 15 minutes: earliest allowed stamps, chain rate 0.82 blocks/s in the honest phase and 0.88 during forging at difficulty 102k to 100k (last five minutes 0.83 at 106k); latest allowed, 0.78 to 0.89 at 102k to 97k; 0 rejected of 986 and 1,028 blocks, delivered hash unchanged (A 0.110 and 0.112 MH/s, F 0.109 and 0.111); the 3 October rule on the same schedule fell to 0.24 blocks/s at 275k and 0.59 at 170k. Either part alone fails: the bounds without the clock still lose 36% and 83% to past-stamping, the clock under Kaspa's 132 s bounds collapses at 50% (the clock's lag is a martingale once the forgery range exceeds half the cap).
Evidence: `docs/bench-log.md`, 4 October 2026 "difficulty rule: timestamp attack fixed"; `docs/analysis/difficulty-2026-10-03.md` addendum (before and after table); `sim/difficulty/attacks/README.md`; unit tests `igneum::tests::sanitised_clock_telescopes_a_forged_stamp`, `difficulty::tests::igneum_clock_steps_pay_a_forgery_back`, `difficulty::tests::igneum_flood_at_85_blocks_per_second_stops_at_the_minimum_target`, `difficulty::tests::both_rules_share_the_target_floor`. Review ids: attack README scenarios 3 and 7.
## Status updates, 4 October 2026 (branch fin-fixes, commit da1eb889)
- **F17** (keys are free, the draw is per key). Status: Fixed in the node (4 October 2026). `is_aggregator` draws the 8 aggregators by weight, `output x total < 8 x weight x 2^64`, so a splitter holds the tickets its weight buys and no more; spec 3.10 S1 row; unit test `sortition_is_by_weight_not_key_count` (200 dust keys plus 6 real ones); attack harness scenario 2 re-run: honest keys drew 1.61 seats per crowded checkpoint against 1.55 expected by weight, where master drew 0.32 against 0.33 per key (`docs/bench-log.md`, "finality fixes F17 and F1"). Still open from this entry: the client's one-key default, S2 (O-3.5), the bitmap size (O-3.12). Was: Rule fixed (spec 7.2 and W6), node per key.

View file

@ -35,16 +35,16 @@ What the nonce commits to: the pre-PoW header hash `H` enters the register initi
## 2.3 Difficulty adjustment (fork points c1, c2)
Status: Implemented on the `difficulty` branch of `vendor/igneum-node` (`SampledDifficultyManager::igneum_difficulty_bits`, pure core `igneum_target`, constants `kaspa_consensus_core::igneum::difficulty`), measured in `sim/difficulty/` and on a 3-node CPU test network (`docs/analysis/difficulty-2026-10-03.md`). Network parameter `difficulty_rule`: `igneum-dual` on every Igneum network, `kaspa-sampled` selectable through the override file (`{"difficulty_rule": "kaspa-sampled"}`) so the two rules can be compared on the same genesis. This closes O-2.1: the window is not shortened and the cost-equalising rule of section 1.4.2 is no longer load-bearing for difficulty (it remains desirable for the verifier bound).
Status: Implemented on the `difficulty` branch of `vendor/igneum-node` (`SampledDifficultyManager::igneum_difficulty_bits`, pure core `igneum_target`, constants `kaspa_consensus_core::igneum::difficulty`), measured in `sim/difficulty/` and on a 3-node CPU test network (`docs/analysis/difficulty-2026-10-03.md`). Network parameter `difficulty_rule`: `igneum-dual` on every Igneum network, `kaspa-sampled` selectable through the override file (`{"difficulty_rule": "kaspa-sampled"}`) so the two rules can be compared on the same genesis. This closes O-2.1: the window is not shortened and the cost-equalising rule of section 1.4.2 is no longer load-bearing for difficulty (it remains desirable for the verifier bound). Revised 4 October 2026 after the attack run of `sim/difficulty/attacks/README.md` (ledger M23): the timestamp rules are Igneum's own (10 s both ways), every chain step is measured on a sanitised clock stored per header (`stores::clock`), and the output has a floor; the addendum of `docs/analysis/difficulty-2026-10-03.md` has the before and after.
Lineage, credited: the long lane is Kaspa's KIP-4 sampled window (`difficulty.rs`, samples at every 4th block, 661 samples, `estimateNetworkHashesPerSecond`'s blue-work-over-time estimator); the short lane is Zawy's LWMA weighting (linear weights rising to the newest block, solvetimes clamped symmetrically, 20 target times here against Zawy's 6) applied to the same work-over-time estimator instead of LWMA's average target, which is biased while targets ramp (measured: the fast lane stalled at 15x of a 50x step before the change, `docs/analysis/difficulty-2026-10-03.md`).
### The rule
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 (`sanitised_clock`); the step of b is min(c(b) - c(p), 20 T) (`clock_step`). 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 before). The clock is computed at header validation and stored beside blue work; a header whose selected parent carries no clock (a pruning-proof or trusted header) starts from the parent's raw stamp. The long lane keeps the raw sampled span.
2. Short lane S: the newest 120 selected-chain blocks of the block's own epoch, linear weights. Reference lane R: for the first 600 blocks of an epoch the epoch window (all its blocks, blended with the parent's implied rate as k : 16); from block 600 of the epoch Kaspa's sampled window restricted to the epoch.
3. S takes over when S and R differ by more than 25%; otherwise R rules. The trigger compares the two lanes, not the short rate to target (the literal form chatters once the short window is back on target while the long window is still polluted: measured 1,910 s against 72 s to settle on the polluted-window case).
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 bounded to [`MIN_DIFFICULTY_TARGET`, `MAX_DIFFICULTY_TARGET`] (`bound_target`, both rule paths). `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 (measured: without the floor, 85 blocks per second of PoW-less input underflowed the work type after 4,142 blocks and the node panicked, `sim/difficulty/attacks/README.md` scenario 7; with it the flood stops at 2^128 after about 2,630 blocks, unit test `igneum_flood_at_85_blocks_per_second_stops_at_the_minimum_target`).
5. The parent's target is held for the first 8 blocks of every epoch, including epoch 0: the warm-up starts at block 8, not 600.
| Parameter | Value | Constant | Label |
@ -58,10 +58,12 @@ Lineage, credited: the long lane is Kaspa's KIP-4 sampled window (`difficulty.rs
| Trigger | 25% disagreement between S and R | `TRIGGER_PERCENT` | Measured: 15% engages on noise (steady std 0.077), 35% slower on hops |
| Harden clamp | 3% per block | `HARDEN_PERCENT` | Measured: 2% slows the 50x step-up to 84 s from 62 s, 5% buys nothing on the up step |
| Ease clamp | 10% per block | `EASE_PERCENT` | Measured: 3% leaves the 50x step-down at 1,786 s and a 211 s worst gap, 10% gives 1,164 s and 65 s |
| Solvetime cap | 20 target times, both signs | `CAP_BLOCKS` | Measured: Zawy's 6 T leaves the 50x step-down at 1,164 s, 20 T gives 657 s and a 35 s worst gap; 132 s (the future tolerance) gains nothing more and lets an idle gap over-ease (16x peak against 8.5x on the devnet case) |
| Timestamp rules | 132 s future tolerance in isolation, strictly above the sampled past median in context | Kaspa's, unchanged | Designed |
| Solvetime cap | 20 target times, both signs, on the sanitised clock | `CAP_BLOCKS` | Measured: Zawy's 6 T leaves the 50x step-down at 1,164 s, 20 T gives 657 s and a 35 s worst gap; 132 s gains nothing more and lets an idle gap over-ease (16x peak against 8.5x on the devnet case) |
| Clock lag bound | 60 T (3 caps) behind the header's own stamp | `CLOCK_LAG_CAPS` | Measured: no bound peaks the polluted case at 275x, 120 T at 17.8x, 60 T at 11.4x; the forger results are the same at 60 and 120 T |
| Timestamp rules | 10 s future tolerance in isolation (`FUTURE_TOLERANCE_MS`); in context strictly above the sampled past median (Kaspa's 27-sample window, unchanged) and at least the selected parent's timestamp minus 10 s (`BACK_TOLERANCE_MS`); the template floor is the same | `FUTURE_TOLERANCE_MS`, `BACK_TOLERANCE_MS` | Measured: a 50% forger drifts the rate +0.7% to +1.1% with both bounds at 10 s and the clock, -36% to -83% with the bounds alone, collapse with the clock alone under Kaspa's 132 s (`sim/difficulty/attacks/README.md`). 10 s is half the cap: a forged step and its pay-back both fit inside one capped step |
| Minimum target | 2^128 | `MIN_TARGET_BITS` | Designed: unreachable by any network, keeps block work under 2^128 |
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; the per-block clamps bound how fast any estimator error, honest or not, reaches the target. The 3% harden clamp means a 50x step up is corrected in about 130 blocks (a few seconds of wall time at 50x), the 10% ease clamp means a 50x step down costs about 40 blocks of shrinking gaps (11 minutes measured, against 3.4 hours for Kaspa's rule).
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.7% rate drift at 50% past-stamping, +0.9% future, +1.1% alternating, worst seed +2.7%; the rule of 3 October, which cancelled a forged step against the next honest one, measured 1 - 2a(1 - a) of real time at forger share a and lost 42% to 88% of the block rate, `sim/difficulty/attacks/README.md`). The per-block clamps bound how fast any estimator error, honest or not, reaches the target. The 3% harden clamp means a 50x step up is corrected in about 130 blocks (a few seconds of wall time at 50x), the 10% ease clamp means a 50x step down costs about 40 blocks of shrinking gaps (11 minutes measured, against 3.4 hours for Kaspa's rule).
### Measured comparison
@ -83,6 +85,8 @@ Simulator (`sim/difficulty/sim.py`, seed 7, honest timestamps; the full tables w
| Steady std of block rate, 10%/h random walk | 0.049 | 0.049 | 0.132 | 0.093 | 0.062 |
| Devnet record (75x step on arrival of the PC), settled s | never (peak 7.6x, 2,340 blocks above 2x) | 136 | 75 | 157 | 79 |
Under attack (`sim/difficulty/attacks/README.md`, simulator, seeds 7 to 9, the 4 October rule): a forger at 30% or 50% of the hash rate stamping every block at the earliest or latest allowed time, or alternating, drifts the block rate by +0.4% to +1.1% after an hour (worst seed +2.7%, mean difficulty ratio 1.00, worst gap 10 s); the 3 October rule lost 34% to 88% in the same cells. Pool hopping, pulsed rental, the short-lane oscillation, the epoch games and the polluted window are unchanged by the clock (honest stamps make the sanitised clock the raw clock). Test network (3 igneumd nodes from the fix, 50% forger for the last 10 of 15 minutes, `docs/analysis/difficulty-2026-10-03.md` section 11): earliest allowed stamps, chain rate 0.82 blocks/s honest to 0.88 forging at difficulty 102k to 100k; latest allowed, 0.78 to 0.89 at 102k to 97k; 0 rejected of 986 and 1,028 blocks; the 3 October rule on the same schedule fell to 0.24 at 275k and 0.59 at 170k.
Test network (3 igneumd nodes, CPU miners, 20 minutes, `docs/analysis/difficulty-2026-10-03.md`): genesis 4x too hard, CPU miners A then A+B+C (x1.51) then A (/1.45) on a loaded machine; measured first-within-10% 132 s, 214 s, 85 s against simulator medians of 263 s, 61 s and 231 s on the same profile; 1,133 blocks, 0 rejected; Kaspa's rule on the same genesis would not have retargeted within the run (600-block dead zone at 0.25 blocks/s). Kaspa's rule live on the same genesis, 10 minutes: 70 blocks, bits unchanged on all 70, 0.08 blocks/s, worst gap 63 s, never within 10% of target.
### What this section does not do

View file

@ -13,7 +13,8 @@ Controllers
monero Monero style: last 720 blocks, timestamps sorted, 60 cut from each end, lag 15.
lwma60 Zawy LWMA-1, N = 60, solvetimes clamped to [-FTL, 6T], FTL = 132 s.
lwma120 the same with N = 120.
igneum the Igneum candidate (two lanes, epoch windows, 3% harden and 10% ease clamps, 20 T solvetime cap, warm-up from block 8).
igneum the Igneum rule (two lanes, epoch windows, 3% harden and 10% ease clamps, 20 T solvetime cap on a
sanitised running clock, warm-up from block 8, target floor 2^128).
igneum-literal
the brief's literal trigger (fast lane engaged while the short-window RATE is more than
25% off target) kept for the chatter measurement.
@ -39,6 +40,7 @@ EPOCH = 3600 # blocks per program epoch
FTL = 132 * 1000 # Kaspa's future timestamp tolerance, ms
MAX_TARGET = (1 << 255) - 1
ONE = float(1 << 255) # genesis-independent scale: target = ONE / D, D = expected hashes
MIN_TARGET = ONE / float(1 << 128) # the Igneum rule's floor: 2^128 expected hashes per block (spec 2.3 rule 4)
# ---------------------------------------------------------------- controllers
@ -163,21 +165,29 @@ class Igneum(Controller):
Every lane estimates the hash rate as work over time (what Kaspa's estimateNetworkHashesPerSecond does),
never as average target times average solvetime, which is biased while targets ramp.
k = blocks of the current epoch already in the past (epoch = height // 3600).
S (short): linear-weighted work over capped solvetimes of the last min(NS, k) chain blocks.
E (epoch): work over capped time of the whole epoch so far, shrunk toward the parent's implied rate by
Every chain step is measured on a sanitised running clock (4 October 2026, after the timestamp-stretching
attack of sim/difficulty/attacks/): c(genesis) = t(genesis); c(b) = max(c(p) + clamp(t(b) - c(p), -CAP, +CAP),
t(b) - LAG_CAPS x CAP); step(b) = min(c(b) - c(p), CAP). 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 instead of being cancelled to zero measured time.
S (short): linear-weighted work over the clock steps of the last min(NS, k) chain blocks.
E (epoch): work over the clock steps of the whole epoch so far, shrunk toward the parent's implied rate by
k / (k + K0); used as the reference while k < LONG_MIN.
L (long): blue work between the earliest and latest sample of Kaspa's sampled window restricted to the
epoch, over their time span; the reference once k >= LONG_MIN.
Candidate = S when S and the reference disagree by more than TRIG, else the reference. The output may
fall (harder) by at most CLAMP and rise (easier) by at most EASE per block. The parent's target is held
for the first K_MIN blocks of an epoch. Genesis bits only for blocks 1 to K_MIN."""
fall (harder) by at most CLAMP and rise (easier) by at most EASE per block, then is bounded to
[MIN_TARGET, ONE]. The parent's target is held for the first K_MIN blocks of an epoch. Genesis bits only for
blocks 1 to K_MIN."""
name = "igneum"
def __init__(self, g, ns=120, k_min=8, k0=16, long_min=600, trig=0.25, clamp_pct=0.03, ease_pct=0.10, cap=20 * T,
window=661, rate=4, literal=False, epoch_aware=True, kaspa_formula=False):
window=661, rate=4, literal=False, epoch_aware=True, kaspa_formula=False, lag_caps=3):
super().__init__(g)
self.ns, self.k_min, self.k0, self.long_min = ns, k_min, k0, long_min
self.trig, self.clamp_pct, self.cap = trig, clamp_pct, cap
self.lag_caps = lag_caps
self.tss = [] # sanitised clock per height
self.sts = [] # clock step per height (the solvetime the lanes see)
self.ease_pct = clamp_pct if ease_pct is None else ease_pct
self.window, self.rate = window, rate
self.literal = literal
@ -191,27 +201,35 @@ class Igneum(Controller):
def push(self, ts, target):
h = len(self.ts)
st = clamp(ts - self.ts[-1], -self.cap, self.cap) if h > 0 else 0
if h == 0:
st, tss = 0, ts
else:
# the sanitised clock: one capped step from the parent's clock, never more than LAG_CAPS caps behind
# the raw stamp (an honest idle gap is not paid back as cap-sized steps for many blocks)
tss = self.tss[-1] + clamp(ts - self.tss[-1], -self.cap, self.cap)
tss = max(tss, ts - self.lag_caps * self.cap)
st = min(tss - self.tss[-1], self.cap)
self.tss.append(tss)
self.sts.append(st)
super().push(ts, target)
self.pref_d.append(self.pref_d[-1] + ONE / target)
self.pref_t.append(self.pref_t[-1] + target)
self.pref_st.append(self.pref_st[-1] + st)
if h > 0 and h % self.rate == self.rate - 1:
self.samples.append((h, ts, self.pref_d[-1]))
self.samples.append((h, ts, self.pref_d[-1])) # the long lane keeps the raw sampled span
if len(self.samples) > self.window:
self.samples.popleft()
def rate_s(self, lo, hi):
"""Linear-weighted hash rate over chain steps lo+1..hi-1 (work of block j over solvetime of block j)."""
"""Linear-weighted hash rate over chain steps lo+1..hi-1 (work of block j over the clock step of block j)."""
n = hi - lo - 1
if n < 1:
return None
wsum = 0.0
dsum = 0.0
ts = self.ts
for i in range(1, n + 1):
j = lo + i
wsum += clamp(ts[j] - ts[j - 1], -self.cap, self.cap) * i
wsum += self.sts[j] * i
dsum += (self.pref_d[j + 1] - self.pref_d[j]) * i
wsum = max(wsum, n * n * T / 20)
return dsum / wsum
@ -266,7 +284,7 @@ class Igneum(Controller):
self.engaged += 1 if use_s else 0
cand = ONE / (r * T)
out = clamp(cand, parent / (1 + self.clamp_pct), parent * (1 + self.ease_pct))
return min(out, ONE)
return clamp(out, MIN_TARGET, ONE)
def make_controller(name, g, **kw):