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>
566 lines
48 KiB
Markdown
566 lines
48 KiB
Markdown
# Difficulty controller for Igneum: data, simulation, implementation, test network
|
|
|
|
3 October 2026, consensus-engineer. Everything measured here is reproducible from `sim/difficulty/`
|
|
(record, simulator, raw outputs) and the `difficulty` branch of `vendor/igneum-node`.
|
|
|
|
## 1. The question
|
|
|
|
Igneum's effective hash rate steps every hour when the mining program changes (35 to 48 Mhash/s
|
|
across seeds on the M5 Max, 228 vs 185 Mhash/s for 104 vs 128 loads on the RTX 5090,
|
|
`docs/bench-log.md`), and GPU miners hop in and out fast. Kaspa's sampled DAA (KIP-4) averages a
|
|
44-minute window and holds genesis bits for 600 blocks. The devnet of 3 October showed what that
|
|
costs. This document measures five controllers on synthetic steps and on the devnet record, tunes
|
|
the Igneum candidate on the synthetic set only, then validates it on the record and on a live
|
|
3-node test network.
|
|
|
|
## 2. Data: the overnight devnet record
|
|
|
|
`sim/difficulty/devnet-2026-10-03.csv`: 3,682 headers pulled through the observer node's wRPC JSON
|
|
endpoint (`getBlocks` from genesis, read only), columns hash, DAA score, blue score, timestamp,
|
|
bits, difficulty (2^255 / target, the node's own convention; expected hashes per block is twice
|
|
that), log2 target, epoch, chain flag, parent count, selected parent. Genesis bits `0x1d100000`
|
|
(difficulty 134,217,727, 2^28 expected hashes per block), sized for the RTX 5090 plus the M5 Max.
|
|
|
|
Timeline (block 1 at 19:07:49 UTC = 20:07 BST):
|
|
|
|
| Minutes from block 1 | Blocks | Blocks/s | Bits at end | Difficulty at end | DAA score at end | What was mining |
|
|
|---|---|---|---|---|---|---|
|
|
| 0 to 5 | 36 | 0.12 | 0x1d100000 | 134,217,727 | 35 | Mac Metal worker, 30 MH/s |
|
|
| 5 to 10 | 43 | 0.14 | 0x1d100000 | 134,217,727 | 78 | same |
|
|
| 10 to 15 | 24 | 0.08 | 0x1d100000 | 134,217,727 | 102 | same |
|
|
| 15 to 20 | 34 | 0.11 | 0x1d100000 | 134,217,727 | 136 | same, stopped at 19:28 UTC |
|
|
| 20 to 25 | 3 | 0.01 | 0x1d100000 | 134,217,727 | 139 | about 1.5 MH/s (three isolated blocks) |
|
|
| 25 to 30 | 1 | 0.00 | 0x1d100000 | 134,217,727 | 140 | same |
|
|
| 30 to 35 | 9 | 0.03 | 0x1d100000 | 134,217,727 | 149 | PC joins at 19:42 UTC |
|
|
| 35 to 40 | 172 | 0.57 | 0x1d100000 | 134,217,727 | 321 | PC, 116 MH/s estimated from the blocks (coordinator: 154 MH/s plus 4 MH/s AMD) |
|
|
| 40 to 45 | 187 | 0.62 | 0x1d100000 | 134,217,727 | 507 | same |
|
|
| 45 to 50 | 421 | 1.40 | 0x1e00b6a3 | 11,758,225 | 925 | first retarget at DAA 600 (19:56:12 UTC): bits 0x1d4caca1, then easing every block |
|
|
| 50 to 55 | 1,633 | 5.44 | 0x1e00d950 | 9,882,007 | 2,559 | difficulty bottoms at 8,552,118 (15.7x below genesis); peak 355 blocks in one minute |
|
|
| 55 to 60 | 639 | 2.13 | 0x1d36b2ef | 39,260,045 | 3,201 | the window fills with fast blocks, difficulty climbs past the correct level |
|
|
| 60 to 65 | 400 | 1.33 | 0x1d3a4442 | 36,856,148 | 3,599 | still above the correct level (about 58 million for 116 MH/s at 1 block/s) |
|
|
| 65 to 70 | 79 | 0.26 | 0x1d44da0d | 31,189,959 | 3,680 | epoch boundary at DAA 3,600; the PC's launcher rebuilds its kernel (miner exit 42) and the chain idles |
|
|
|
|
What Kaspa's rule did, as the coordinator saw it live and the record confirms: genesis difficulty
|
|
held through block 600 while the real rate was 0.6 blocks/s; the first retarget saw a window whose
|
|
600 blocks spanned 48 minutes (the Metal period, the idle gap, the PC period) and eased 4.8x at
|
|
once, then kept easing to 15.7x as more of the slow history entered the average; the chain ran at
|
|
5.4 blocks/s for five minutes (1,633 blocks) and 355 blocks in its peak minute; it then overshot
|
|
the other way to 1.5x too hard. The DAG widened accordingly: 3,681 blocks against 1,656 chain
|
|
blocks.
|
|
|
|
Hash-rate profile derived from the record (expected hashes of every block over wall time, between
|
|
the two visible events, Metal stop 19:28:05 UTC and PC start 19:42:40 UTC):
|
|
|
|
| From s | To s | Blocks/s at genesis difficulty | MH/s |
|
|
|---|---|---|---|
|
|
| 0 | 1,215 | 0.114 | 30.7 |
|
|
| 1,215 | 2,090 | 0.006 | 1.5 |
|
|
| 2,090 | 4,064 | 0.433 | 116.2 |
|
|
|
|
Exact replay check: the record's own timestamps and bits replayed through rusty-kaspa's integer
|
|
arithmetic (`sim.py kaspa_bits_exact`, same sampling rule `(parent_daa + 1) mod 4 = 0`, 661 samples,
|
|
earliest sample dropped from the average, compact-bits rounding) reproduce the chain's bits exactly
|
|
for the 244 retargets from DAA 600 to 844 and diverge from DAA 845, when the chain had passed
|
|
1 block/s and the sampled window began to include merged blocks that a chain-only replay cannot see.
|
|
The simulator's Kaspa model is therefore exact on a chain and approximate on a wide DAG.
|
|
|
|
## 3. Simulator
|
|
|
|
`sim/difficulty/sim.py`. One selected chain, DAA score = height, 1 block per second target,
|
|
solve times exponential with mean (expected hashes) / (hash rate); a hash-rate profile is piecewise
|
|
constant in time (miners) or keyed to the 3,600-block epoch (programs); timestamps are the true
|
|
times, optionally with uniform jitter. The difficulty unit is 2^28 hashes (the devnet genesis), so
|
|
every synthetic run starts at a realistic target far from `MAX_DIFFICULTY_TARGET`.
|
|
|
|
Metrics. "First within 10%": the first time after a step when the centred 121-block mean of the
|
|
expected block rate (hash rate over difficulty) is within 10% of target. "Settled": the first time
|
|
it then stays within 10% for 100 blocks. "Overshoot": how far past target the mean goes on the far
|
|
side before the next step. "Blocks above 2x / below 0.5x": blocks produced while the expected rate
|
|
was outside that band. "Worst gap": longest inter-block time. Steady state: standard deviation of
|
|
the expected rate ratio over a steady hour ("steady std"), and the coefficient of variation of
|
|
blocks per minute, which is 0.129 for a Poisson process at 60 per minute (the controller adds to it
|
|
only when it is noisy).
|
|
|
|
Controllers.
|
|
|
|
| Name | Rule | Source |
|
|
|---|---|---|
|
|
| kaspa | KIP-4 sampled DAA as the fork runs it | `consensus/src/processes/difficulty.rs`, `constants.rs` (661 samples, every 4th block, min 150 samples) |
|
|
| monero | 720 blocks, sorted timestamps, 60 cut each side, lag 15 | Monero `next_difficulty`, approximate from memory of the reference |
|
|
| lwma60, lwma120 | Zawy LWMA-1: linear weights, average target, solvetimes in [-132 s, 6 T] | Zawy's 2018 reference, approximate from memory |
|
|
| igneum | the rule of spec 2.3 | this document |
|
|
| igneum-literal | the brief's trigger: short lane engaged while the short-window rate is more than 25% off target | kept to measure the chatter |
|
|
|
|
Profiles. up50 (x50 at 3 h), down50 (/50 at 3 h), epoch30 (x1.3 or /1.3 drawn at each epoch
|
|
boundary from epoch 2, six boundaries), walk10 (10% per hour log random walk), hop3 and hop10 (a 3x
|
|
or 10x pool in and out every 15 minutes, twelve steps), polluted (the devnet case: 21 minutes at
|
|
0.11 blocks/s, 13 minutes idle, then 0.6 blocks/s at genesis difficulty), warmup-hard (genesis 10x
|
|
too hard), warmup-easy (genesis 10x too easy), real (the record's profile above).
|
|
|
|
## 4. Designing the Igneum candidate
|
|
|
|
Two findings changed the brief's sketch.
|
|
|
|
Zawy's LWMA estimator is biased while targets ramp. LWMA computes average target times average
|
|
solvetime. While the clamp drags the target down 3% per block after a 50x step, the short window
|
|
holds blocks mined at targets from 1x to 15x apart; average target times average solvetime then
|
|
over-estimates the target (Jensen), and the fast lane stalled at about 15x of the 50x step for a
|
|
full window length (trajectory dump, first implementation). Replacing every lane's estimator with
|
|
work over time (the sum of blue-work increments over the sum of clamped solvetimes, linear weights
|
|
for the short lane) removed the stall: the 50x step-up settles in 62 s instead of 224 s. This is the
|
|
estimator behind Kaspa's `estimateNetworkHashesPerSecond`, so the lineage stays Kaspa's.
|
|
|
|
The brief's trigger chatters. "Fast lane takes over when the short-window rate departs from target
|
|
by more than 25%" releases the fast lane as soon as the short window is back on target, while the
|
|
long window is still polluted by the pre-step blocks for 44 minutes; the long lane then pulls the
|
|
target the wrong way until the short window departs again. Measured as `igneum-literal`: polluted
|
|
case settles at 1,910 s against 72 s, hop3 fails to settle on 8 of 12 steps against 0, hop10 on 10
|
|
against 0. The trigger adopted compares the two lanes: the short lane rules while it disagrees with
|
|
the reference lane by more than 25%, which stays true exactly as long as the long window is wrong.
|
|
|
|
The epoch windows. At an epoch boundary the program changes and the hash rate steps; blocks of the
|
|
old program carry no information about the new one. Every lane is therefore restricted to the
|
|
block's own epoch: the short lane is the newest 120 blocks of the epoch, the reference lane is the
|
|
epoch window (blended with the parent's implied rate as k : 16 for small k) until the epoch has 600
|
|
blocks and Kaspa's sampled window restricted to the epoch after that. The parent's target is held
|
|
for the first 8 blocks of an epoch. Epoch 0 starts at genesis, so this is also the warm-up rule:
|
|
the dead zone is 8 blocks, not 600.
|
|
|
|
Asymmetric clamps. A step down is the painful case for any past-only controller: after a 50x drop
|
|
blocks arrive every 50 s and each one is the only evidence. With a symmetric 3% clamp the 50x
|
|
step-down took 1,786 s with a 211 s worst gap. The clamps are not what bounds timestamp
|
|
manipulation (the symmetric solvetime cap and the cancellation of a forged timestamp by the next
|
|
honest block are), so the ease direction can be faster: 10% per block gives 1,164 s and a 65 s
|
|
worst gap with no measurable change anywhere else, and widening the cap from 6 to 20 block times
|
|
(section 5) brings it to 657 s and 35 s.
|
|
|
|
## 5. Tuning on the synthetic set
|
|
|
|
One parameter at a time, two sweeps. Columns per profile: settled s ("rec"), first within 10% s
|
|
("first"), steady std; down50 adds the worst gap; polluted adds the peak rate. `clamp_pct` in the
|
|
first sweep moves both clamps together (the ease clamp did not exist yet).
|
|
|
|
First sweep, around the brief's symmetric 3% clamp and the 6 T cap (ns 120, trigger 25%, clamp 3% both ways, prior 16):
|
|
|
|
#### Tune ns
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| ns=60 | 72.8 | 72.8 | 0.069 | 1843.1 | 1843.1 | 0.069 | 114.2 | 127.0 | 80.2 | 0.067 | none | none | 0.089 | 0/0 | 301.6 | 251.0 | 0.069 | 56.4 | 56.4 | 0.083 | 4.4 |
|
|
| ns=120 | 63.0 | 63.0 | 0.040 | 1786.2 | 1786.2 | 0.040 | 211.2 | 155.9 | 99.0 | 0.033 | none | none | 0.061 | 0/0 | 275.6 | 275.6 | 0.040 | 69.3 | 69.3 | 0.048 | 5.8 |
|
|
| ns=180 | 63.3 | 63.3 | 0.035 | 2131.4 | 2131.4 | 0.035 | 125.1 | 156.3 | 99.0 | 0.017 | none | none | 0.056 | 0/0 | 356.1 | 356.1 | 0.035 | 76.5 | 76.5 | 0.042 | 5.6 |
|
|
|
|
#### Tune trig
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| trig=0.15 | 66.7 | 66.7 | 0.077 | 1591.5 | 1591.5 | 0.077 | 98.7 | 137.3 | 107.3 | 0.070 | none | none | 0.082 | 0/0 | 312.9 | 261.8 | 0.077 | 58.1 | 58.1 | 0.075 | 5.8 |
|
|
| trig=0.25 | 63.0 | 63.0 | 0.040 | 1786.2 | 1786.2 | 0.040 | 211.2 | 155.9 | 99.0 | 0.033 | none | none | 0.061 | 0/0 | 275.6 | 275.6 | 0.040 | 69.3 | 69.3 | 0.048 | 5.8 |
|
|
| trig=0.35 | 62.9 | 62.9 | 0.034 | 1835.4 | 1835.4 | 0.034 | 126.4 | 156.3 | 99.0 | 0.016 | none | none | 0.054 | 0/0 | 328.5 | 279.2 | 0.034 | 78.4 | 78.4 | 0.042 | 5.5 |
|
|
|
|
#### Tune clamp_pct
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| clamp_pct=0.02 | 84.0 | 84.0 | 0.039 | 2588.2 | 2588.2 | 0.039 | 216.8 | 156.3 | 99.4 | 0.031 | none | none | 0.060 | 0/0 | 373.6 | 336.9 | 0.039 | 80.1 | 80.1 | 0.046 | 5.4 |
|
|
| clamp_pct=0.03 | 63.0 | 63.0 | 0.040 | 1786.2 | 1786.2 | 0.040 | 211.2 | 155.9 | 99.0 | 0.033 | none | none | 0.061 | 0/0 | 275.6 | 275.6 | 0.040 | 69.3 | 69.3 | 0.048 | 5.8 |
|
|
| clamp_pct=0.05 | 62.2 | 62.2 | 0.040 | 1312.5 | 1312.5 | 0.040 | 173.8 | 155.8 | 98.9 | 0.034 | none | none | 0.062 | 0/0 | 284.6 | 258.8 | 0.040 | 59.9 | 59.9 | 0.049 | 5.4 |
|
|
|
|
#### Tune ease_pct
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| ease_pct=0.03 | 63.0 | 63.0 | 0.040 | 1786.2 | 1786.2 | 0.040 | 211.2 | 155.9 | 99.0 | 0.033 | none | none | 0.061 | 0/0 | 275.6 | 275.6 | 0.040 | 69.3 | 69.3 | 0.048 | 5.8 |
|
|
| ease_pct=0.06 | 62.7 | 62.7 | 0.039 | 1282.5 | 1282.5 | 0.039 | 176.0 | 155.6 | 98.8 | 0.031 | none | none | 0.061 | 0/0 | 299.5 | 265.0 | 0.039 | 58.5 | 58.5 | 0.048 | 5.8 |
|
|
| ease_pct=0.1 | 62.2 | 62.2 | 0.039 | 1164.4 | 1164.4 | 0.039 | 65.3 | 155.5 | 98.8 | 0.030 | none | none | 0.062 | 0/0 | 253.4 | 253.4 | 0.039 | 71.7 | 71.7 | 0.048 | 6.2 |
|
|
|
|
#### Tune k0
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| k0=8 | 63.0 | 63.0 | 0.040 | 1786.6 | 1786.6 | 0.040 | 211.1 | 156.0 | 99.0 | 0.033 | none | none | 0.061 | 0/0 | 311.5 | 280.9 | 0.040 | 67.1 | 67.1 | 0.048 | 5.5 |
|
|
| k0=16 | 63.0 | 63.0 | 0.040 | 1786.2 | 1786.2 | 0.040 | 211.2 | 155.9 | 99.0 | 0.033 | none | none | 0.061 | 0/0 | 275.6 | 275.6 | 0.040 | 69.3 | 69.3 | 0.048 | 5.8 |
|
|
| k0=32 | 63.0 | 63.0 | 0.040 | 1786.9 | 1786.9 | 0.040 | 211.9 | 156.0 | 99.1 | 0.033 | none | none | 0.061 | 0/0 | 290.0 | 263.0 | 0.040 | 71.4 | 71.4 | 0.047 | 5.7 |
|
|
|
|
#### Tune cap
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| cap=6 | 63.0 | 63.0 | 0.040 | 1786.2 | 1786.2 | 0.040 | 211.2 | 155.9 | 99.0 | 0.033 | none | none | 0.061 | 0/0 | 275.6 | 275.6 | 0.040 | 69.3 | 69.3 | 0.048 | 5.8 |
|
|
| cap=20 | 63.8 | 63.8 | 0.039 | 1703.7 | 1703.7 | 0.039 | 165.8 | 143.7 | 86.8 | 0.033 | none | none | 0.061 | 0/0 | 274.9 | 257.2 | 0.039 | 62.9 | 62.9 | 0.048 | 5.8 |
|
|
| cap=132 | 63.8 | 63.8 | 0.039 | 1649.6 | 1649.6 | 0.039 | 161.0 | 143.7 | 86.8 | 0.033 | none | none | 0.061 | 0/0 | 263.5 | 238.2 | 0.039 | 75.8 | 75.8 | 0.048 | 13.1 |
|
|
|
|
Second sweep, around the adopted defaults (ns 120, trigger 25%, harden 3%, ease 10%, prior 16, cap 6 T at the time of the sweep; the cap row decided the final value):
|
|
|
|
#### Tune ns
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| ns=60 | 57.5 | 57.5 | 0.080 | 595.9 | 595.9 | 0.080 | 87.5 | 120.2 | 92.6 | 0.076 | none | none | 0.093 | 0/0 | 201.2 | 152.1 | 0.080 | 60.8 | 60.8 | 0.090 | 5.2 |
|
|
| ns=120 | 62.2 | 62.2 | 0.039 | 1164.4 | 1164.4 | 0.039 | 65.3 | 155.5 | 98.8 | 0.030 | none | none | 0.062 | 0/0 | 253.4 | 253.4 | 0.039 | 71.7 | 71.7 | 0.048 | 6.2 |
|
|
| ns=180 | 63.5 | 63.5 | 0.034 | 1742.3 | 1742.3 | 0.034 | 93.4 | 155.8 | 98.8 | 0.017 | none | none | 0.056 | 0/0 | 354.2 | 344.3 | 0.034 | 77.3 | 77.3 | 0.042 | 5.7 |
|
|
|
|
#### Tune trig
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| trig=0.15 | 63.2 | 63.2 | 0.078 | 1098.2 | 1098.2 | 0.078 | 75.1 | 136.1 | 106.1 | 0.072 | none | none | 0.083 | 0/0 | 244.8 | 234.9 | 0.078 | 62.1 | 62.1 | 0.075 | 5.2 |
|
|
| trig=0.25 | 62.2 | 62.2 | 0.039 | 1164.4 | 1164.4 | 0.039 | 65.3 | 155.5 | 98.8 | 0.030 | none | none | 0.062 | 0/0 | 253.4 | 253.4 | 0.039 | 71.7 | 71.7 | 0.048 | 6.2 |
|
|
| trig=0.35 | 63.5 | 63.5 | 0.034 | 1238.4 | 1238.4 | 0.034 | 75.5 | 155.8 | 98.8 | 0.016 | none | none | 0.054 | 0/0 | 276.3 | 263.9 | 0.034 | 63.1 | 63.1 | 0.042 | 5.8 |
|
|
|
|
#### Tune clamp_pct
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| clamp_pct=0.02 | 97.1 | 97.1 | 0.037 | 1091.3 | 1091.3 | 0.037 | 71.6 | 155.3 | 98.7 | 0.027 | none | none | 0.061 | 0/0 | 269.7 | 258.5 | 0.037 | 71.9 | 71.9 | 0.048 | 6.2 |
|
|
| clamp_pct=0.03 | 62.2 | 62.2 | 0.039 | 1164.4 | 1164.4 | 0.039 | 65.3 | 155.5 | 98.8 | 0.030 | none | none | 0.062 | 0/0 | 253.4 | 253.4 | 0.039 | 71.7 | 71.7 | 0.048 | 6.2 |
|
|
| clamp_pct=0.05 | 60.1 | 60.1 | 0.040 | 1371.6 | 1371.6 | 0.040 | 175.9 | 155.5 | 98.6 | 0.033 | none | none | 0.063 | 0/0 | 295.1 | 262.7 | 0.040 | 71.6 | 71.6 | 0.049 | 6.1 |
|
|
|
|
#### Tune ease_pct
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| ease_pct=0.03 | 63.0 | 63.0 | 0.040 | 1786.2 | 1786.2 | 0.040 | 211.2 | 155.9 | 99.0 | 0.033 | none | none | 0.061 | 0/0 | 275.6 | 275.6 | 0.040 | 69.3 | 69.3 | 0.048 | 5.8 |
|
|
| ease_pct=0.06 | 62.7 | 62.7 | 0.039 | 1282.5 | 1282.5 | 0.039 | 176.0 | 155.6 | 98.8 | 0.031 | none | none | 0.061 | 0/0 | 299.5 | 265.0 | 0.039 | 58.5 | 58.5 | 0.048 | 5.8 |
|
|
| ease_pct=0.1 | 62.2 | 62.2 | 0.039 | 1164.4 | 1164.4 | 0.039 | 65.3 | 155.5 | 98.8 | 0.030 | none | none | 0.062 | 0/0 | 253.4 | 253.4 | 0.039 | 71.7 | 71.7 | 0.048 | 6.2 |
|
|
| ease_pct=0.15 | 61.6 | 61.6 | 0.038 | 1111.4 | 1111.4 | 0.038 | 65.4 | 155.2 | 98.4 | 0.030 | none | none | 0.062 | 0/0 | 261.6 | 250.5 | 0.038 | 67.1 | 67.1 | 0.048 | 6.4 |
|
|
|
|
#### Tune k0
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| k0=8 | 62.2 | 62.2 | 0.038 | 1144.7 | 1144.7 | 0.038 | 65.3 | 155.5 | 98.7 | 0.030 | none | none | 0.062 | 0/0 | 247.7 | 247.7 | 0.038 | 70.2 | 70.2 | 0.048 | 6.2 |
|
|
| k0=16 | 62.2 | 62.2 | 0.039 | 1164.4 | 1164.4 | 0.039 | 65.3 | 155.5 | 98.8 | 0.030 | none | none | 0.062 | 0/0 | 253.4 | 253.4 | 0.039 | 71.7 | 71.7 | 0.048 | 6.2 |
|
|
| k0=32 | 62.2 | 62.2 | 0.038 | 1169.9 | 1169.9 | 0.038 | 65.3 | 155.7 | 98.9 | 0.030 | none | none | 0.061 | 0/0 | 287.5 | 266.4 | 0.038 | 71.4 | 71.4 | 0.048 | 6.3 |
|
|
|
|
#### Tune cap
|
|
|
|
| ctrl | up50 rec | up50 first | up50 std | down50 rec | down50 first | down50 std | down50 gap | epoch30 rec | epoch30 first | epoch30 std | walk10 rec | walk10 first | walk10 std | walk10 >2x/<0.5x | hop10 rec | hop10 first | hop10 std | polluted rec | polluted first | polluted std | polluted peak |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| cap=6 | 62.2 | 62.2 | 0.039 | 1164.4 | 1164.4 | 0.039 | 65.3 | 155.5 | 98.8 | 0.030 | none | none | 0.062 | 0/0 | 253.4 | 253.4 | 0.039 | 71.7 | 71.7 | 0.048 | 6.2 |
|
|
| cap=20 | 61.7 | 61.7 | 0.038 | 657.4 | 657.4 | 0.038 | 35.3 | 143.7 | 86.5 | 0.030 | none | none | 0.062 | 0/0 | 211.9 | 208.1 | 0.038 | 70.0 | 70.0 | 0.049 | 8.5 |
|
|
| cap=132 | 61.7 | 61.7 | 0.038 | 629.5 | 629.5 | 0.038 | 32.4 | 143.7 | 86.5 | 0.030 | none | none | 0.062 | 0/0 | 212.9 | 209.1 | 0.038 | 63.8 | 63.8 | 0.049 | 16.4 |
|
|
|
|
Decisions: ns 120 (60 doubles the steady noise, 180 is slower on hops); trigger 25% (15% engages on
|
|
noise and doubles the steady noise, 35% slower on hops); harden 3% (2% slows the step-up to 97 s,
|
|
5% buys nothing on the up step and worsens the down step's gap); ease 10% (3% leaves the step-down
|
|
at 1,786 s, 15% buys 50 s more at no cost but was not adopted to keep a margin); prior 16 (no effect
|
|
across 8 to 32); cap 20 T (6 T leaves the step-down at 1,164 s, 20 T gives 657 s and a 35 s worst
|
|
gap and also helps the epoch steps and hopping; the 132 s cap, Kaspa's future tolerance, gains
|
|
nothing more and lets the idle gap of the polluted case over-ease the target to a 16x peak against
|
|
8.5x at 20 T and 6.2x at 6 T). The cap is not the manipulation bound: under the symmetric cap a
|
|
forged timestamp's contribution is cancelled by the next honest block whatever the cap, so the cap
|
|
only decides how much a genuine slow block may say. Nothing was tuned on the record.
|
|
|
|
## 6. Results on the synthetic set
|
|
|
|
Seed 7, honest timestamps, final parameters. Full output in `sim/difficulty/results.md`.
|
|
|
|
#### up50: hash rate x50 at 3 h
|
|
|
|
| controller | first within 10% s | settled s | recovery blocks | overshoot | steady std of rate | steady blocks/min CV | worst gap s | blocks above 2x | blocks below 0.5x | blocks | fast-lane blocks |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| kaspa | 1542.1 | 1542.1 | 4967 | 0.009 | 0.012 | 0.126 | 9.5 | 3760 | 0 | 21511 | none |
|
|
| monero | 94.0 | 94.0 | 713 | 0.089 | 0.037 | 0.144 | 10.3 | 657 | 0 | 18490 | none |
|
|
| lwma60 | 105.0 | 105.0 | 182 | 0.208 | 0.131 | 0.088 | 11.3 | 78 | 0 | 17977 | none |
|
|
| lwma120 | 231.2 | 231.2 | 388 | 0.189 | 0.092 | 0.096 | 10.5 | 164 | 0 | 18078 | none |
|
|
| igneum | 61.7 | 61.7 | 181 | 0.140 | 0.038 | 0.135 | 11.3 | 125 | 0 | 17927 | 377 |
|
|
| igneum-literal | 63.7 | 63.7 | 186 | 0.217 | 0.034 | 0.130 | 11.3 | 127 | 0 | 17956 | 230 |
|
|
|
|
#### down50: hash rate /50 at 3 h
|
|
|
|
| controller | first within 10% s | settled s | recovery blocks | overshoot | steady std of rate | steady blocks/min CV | worst gap s | blocks above 2x | blocks below 0.5x | blocks | fast-lane blocks |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| kaspa | 12295.7 | 12295.7 | 1569 | 5.102 | 0.012 | 0.126 | 178.5 | 1648 | 1256 | 16185 | none |
|
|
| monero | 6432.8 | 6432.8 | 701 | 0.039 | 0.037 | 0.144 | 187.3 | 0 | 567 | 19356 | none |
|
|
| lwma60 | 577.9 | 577.9 | 111 | 0.154 | 0.131 | 0.088 | 118.7 | 0 | 55 | 24588 | none |
|
|
| lwma120 | 1073.6 | 1073.6 | 177 | 0.129 | 0.092 | 0.096 | 64.9 | 0 | 97 | 24205 | none |
|
|
| igneum | 657.4 | 657.4 | 147 | 0.062 | 0.038 | 0.135 | 35.3 | 0 | 90 | 24548 | 474 |
|
|
| igneum-literal | 967.0 | 967.0 | 146 | 0.062 | 0.034 | 0.130 | 121.1 | 0 | 86 | 24276 | 267 |
|
|
|
|
#### epoch30: x1.3 or /1.3 at each epoch boundary from epoch 2
|
|
|
|
| controller | first within 10% s | settled s | worst recovery s | not recovered | overshoot | steady std of rate | steady blocks/min CV | worst gap s | blocks above 2x | blocks below 0.5x | blocks | fast-lane blocks |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| kaspa | 1583.0 | 1583.0 | 1974.6 | 0 | 0.043 | 0.010 | 0.130 | 12.3 | 0 | 0 | 29403 | none |
|
|
| monero | 456.2 | 456.2 | 603.3 | 0 | 0.116 | 0.038 | 0.141 | 16.0 | 0 | 0 | 28857 | none |
|
|
| lwma60 | 91.1 | 156.9 | 310.6 | 0 | 0.291 | 0.132 | 0.090 | 11.6 | 0 | 0 | 28658 | none |
|
|
| lwma120 | 136.9 | 153.1 | 240.8 | 0 | 0.229 | 0.094 | 0.097 | 11.5 | 0 | 0 | 28719 | none |
|
|
| igneum | 86.5 | 143.7 | 260.8 | 0 | 0.126 | 0.030 | 0.133 | 11.3 | 0 | 0 | 28735 | 351 |
|
|
| igneum-literal | 86.4 | 143.8 | 260.8 | 0 | 0.050 | 0.018 | 0.130 | 11.3 | 0 | 0 | 28775 | 149 |
|
|
|
|
#### walk10: 10% per hour log random walk
|
|
|
|
| controller | steady std of rate | steady blocks/min CV | worst gap s | blocks above 2x | blocks below 0.5x | blocks | fast-lane blocks |
|
|
|---|---|---|---|---|---|---|---|
|
|
| kaspa | 0.049 | 0.144 | 11.1 | 0 | 0 | 21728 | none |
|
|
| monero | 0.049 | 0.143 | 10.6 | 0 | 0 | 21541 | none |
|
|
| lwma60 | 0.132 | 0.088 | 11.1 | 0 | 0 | 21486 | none |
|
|
| lwma120 | 0.093 | 0.105 | 10.5 | 0 | 0 | 21518 | none |
|
|
| igneum | 0.062 | 0.130 | 11.2 | 0 | 0 | 21521 | 519 |
|
|
| igneum-literal | 0.055 | 0.136 | 11.1 | 0 | 0 | 21523 | 184 |
|
|
|
|
#### hop3: 3x pool hops in and out every 15 min from 3 h
|
|
|
|
| controller | first within 10% s | settled s | worst recovery s | not recovered | overshoot | steady std of rate | steady blocks/min CV | worst gap s | blocks above 2x | blocks below 0.5x | blocks | fast-lane blocks |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| kaspa | none | none | none | 12 | 0 | 0.012 | 0.126 | 21.0 | 1208 | 1036 | 22128 | none |
|
|
| monero | 336.7 | 336.7 | 362.4 | 6 | 0.062 | 0.037 | 0.144 | 18.6 | 2438 | 1437 | 20382 | none |
|
|
| lwma60 | 109.5 | 166.9 | 373.4 | 0 | 0.270 | 0.131 | 0.088 | 14.7 | 111 | 42 | 21408 | none |
|
|
| lwma120 | 161.8 | 184.3 | 292.1 | 0 | 0.201 | 0.092 | 0.096 | 15.0 | 215 | 129 | 21364 | none |
|
|
| igneum | 124.0 | 189.5 | 410.3 | 0 | 0.229 | 0.038 | 0.135 | 11.8 | 213 | 182 | 21228 | 8054 |
|
|
| igneum-literal | 429.3 | 372.9 | 493.1 | 8 | 0.202 | 0.034 | 0.130 | 16.1 | 86 | 529 | 20913 | 4294 |
|
|
|
|
#### hop10: 10x pool hops in and out every 15 min from 3 h
|
|
|
|
| controller | first within 10% s | settled s | worst recovery s | not recovered | overshoot | steady std of rate | steady blocks/min CV | worst gap s | blocks above 2x | blocks below 0.5x | blocks | fast-lane blocks |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| kaspa | none | none | none | 12 | 0 | 0.012 | 0.126 | 52.1 | 5700 | 1035 | 22951 | none |
|
|
| monero | 283.5 | 283.5 | 312.9 | 6 | 0.085 | 0.037 | 0.144 | 83.2 | 3551 | 569 | 19203 | none |
|
|
| lwma60 | 165.5 | 263.9 | 542.0 | 0 | 0.242 | 0.131 | 0.088 | 34.3 | 315 | 198 | 21008 | none |
|
|
| lwma120 | 282.8 | 325.5 | 789.0 | 0 | 0.199 | 0.092 | 0.096 | 30.2 | 576 | 417 | 20676 | none |
|
|
| igneum | 208.1 | 211.9 | 382.9 | 0 | 0.230 | 0.038 | 0.135 | 25.0 | 422 | 321 | 20631 | 8445 |
|
|
| igneum-literal | 466.6 | 505.8 | 698.0 | 6 | 0.237 | 0.034 | 0.130 | 35.7 | 816 | 666 | 20683 | 5432 |
|
|
|
|
#### polluted: window polluted by a pre-step slow period (21 min at 0.11 blocks/s, 13 min idle, then 0.6 blocks/s at genesis difficulty)
|
|
|
|
| controller | first within 10% s | settled s | recovery blocks | overshoot | steady std of rate | steady blocks/min CV | worst gap s | blocks above 2x | blocks below 0.5x | blocks | fast-lane blocks | peak rate | trough rate after 10 min |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| kaspa | 650.7 | 2748.3 | 5163 | 6.942 | 0.010 | 0.134 | 11.2 | 2320 | 0 | 18592 | none | 7.9 | 0.6 |
|
|
| monero | 124.0 | 124.0 | 735 | 0.092 | 0.032 | 0.142 | 10.4 | 675 | 0 | 17641 | none | 15.0 | 0.9 |
|
|
| lwma60 | 66.1 | 66.1 | 113 | 0.183 | 0.132 | 0.089 | 11.3 | 36 | 0 | 17138 | none | 5.7 | 0.6 |
|
|
| lwma120 | 110.3 | 110.3 | 201 | 0.147 | 0.091 | 0.108 | 10.5 | 74 | 0 | 17190 | none | 6.0 | 0.7 |
|
|
| igneum | 70.0 | 70.0 | 136 | 0.180 | 0.049 | 0.122 | 11.3 | 72 | 0 | 17005 | 2585 | 8.5 | 0.6 |
|
|
| igneum-literal | 817.0 | 1876.4 | 2546 | 0.197 | 0.040 | 0.141 | 11.3 | 571 | 0 | 17618 | 1986 | 8.4 | 0.6 |
|
|
|
|
#### warmup-hard: genesis difficulty 10x too hard
|
|
|
|
| controller | first within 10% s | settled s | recovery blocks | overshoot | steady std of rate | steady blocks/min CV | worst gap s | blocks above 2x | blocks below 0.5x | blocks | fast-lane blocks |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| kaspa | 5790.7 | none | none | 92.876 | 32.727 | 3.002 | 67.2 | 4860 | 599 | 6444 | none |
|
|
| monero | 286.6 | 286.6 | 159 | 0.047 | 0.038 | 0.135 | 17.5 | 0 | 33 | 7011 | none |
|
|
| lwma60 | 155.2 | 155.2 | 118 | 0.147 | 0.132 | 0.078 | 10.5 | 0 | 18 | 7128 | none |
|
|
| lwma120 | 187.9 | 187.9 | 152 | 0.111 | 0.095 | 0.102 | 10.5 | 0 | 18 | 7128 | none |
|
|
| igneum | 321.9 | 321.9 | 199 | 0.024 | 0.031 | 0.126 | 10.5 | 0 | 23 | 6953 | 138 |
|
|
| igneum-literal | 351.4 | 702.7 | 568 | 0.024 | 0.019 | 0.134 | 10.5 | 0 | 23 | 6979 | 179 |
|
|
|
|
#### warmup-easy: genesis difficulty 10x too easy
|
|
|
|
| controller | first within 10% s | settled s | recovery blocks | overshoot | steady std of rate | steady blocks/min CV | worst gap s | blocks above 2x | blocks below 0.5x | blocks | fast-lane blocks |
|
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
| kaspa | 1554.8 | 1554.8 | 3168 | 0.011 | 0.008 | 0.137 | 9.8 | 599 | 0 | 8869 | none |
|
|
| monero | 95.4 | 95.4 | 76 | 0.091 | 0.038 | 0.154 | 16.1 | 16 | 16 | 7145 | none |
|
|
| lwma60 | 57.9 | 57.9 | 87 | 0.183 | 0.133 | 0.073 | 9.2 | 8 | 0 | 7192 | none |
|
|
| lwma120 | 88.3 | 88.3 | 136 | 0.147 | 0.096 | 0.099 | 9.4 | 8 | 0 | 7225 | none |
|
|
| igneum | 58.7 | 58.7 | 114 | 0.082 | 0.029 | 0.133 | 10.3 | 61 | 0 | 7192 | 120 |
|
|
| igneum-literal | 58.4 | 58.4 | 114 | 0.086 | 0.015 | 0.129 | 10.3 | 61 | 0 | 7209 | 191 |
|
|
|
|
With +-500 ms timestamp jitter (half a block time, a harsh clock model), the ordering holds:
|
|
up50 settled Kaspa 1,535 s, LWMA60 159 s, LWMA120 140 s, Igneum 169 s; down50 Kaspa 12,523 s,
|
|
LWMA60 847 s, LWMA120 966 s, Igneum 1,047 s (first within 10% at 896 s, worst gap 49 s against 78
|
|
and 81); epoch30 Kaspa 1,535 s, LWMA60 63 s, LWMA120 124 s, Igneum 55 s; hop10 Kaspa never, LWMA60
|
|
210 s, LWMA120 318 s, Igneum 228 s; polluted Kaspa 2,722 s (peak 8.5x), LWMA60 89 s, LWMA120 174 s,
|
|
Igneum 71 s; steady std Kaspa 0.010, LWMA60 0.125, LWMA120 0.085, Igneum 0.046. The jitter costs the
|
|
Igneum short lane on the 50x cases because 20 ms solve times are drowned by 500 ms clock noise; it
|
|
still beats Kaspa by 9x and 12x there.
|
|
|
|
Reading. Kaspa's rule has the lowest steady-state noise (0.012) and the worst response to every
|
|
step: 26 minutes for a 50x up, 3.4 hours for a 50x down with a 5x overshoot, never settling under
|
|
pool hopping, 46 minutes and a 7.9x peak on the devnet case, and a 600-block dead zone that turns
|
|
a 10x-too-hard genesis into 97 minutes of near-silence. LWMA 60 is the fastest on step-downs and
|
|
the noisiest (0.132, 13% block-rate swings all day). The Igneum rule keeps the steady noise at
|
|
0.038 (three times Kaspa's, less than half of LWMA120's), is the fastest on the 50x step-up (62 s),
|
|
the epoch steps (144 s settled, 87 s first within 10%, against Kaspa's 1,583 s), the devnet case
|
|
(70 s), the 3x and 10x hopping (190 s and 212 s) and the too-easy genesis (59 s), second to LWMA60
|
|
on the 50x step-down (657 s against 578 s, with a 35 s worst gap against 119 s) and on the too-hard
|
|
genesis (322 s against 155 s, the price of the 10% ease clamp). On the polluted case every past-only
|
|
controller shows a peak when the PC arrives, because the difficulty was correctly set for the Metal
|
|
miner; the number that matters is how long the peak lasts: Igneum 70 s, Kaspa 46 minutes with a
|
|
second, larger peak of its own making.
|
|
|
|
## 7. Validation on the record
|
|
|
|
The record's hash-rate profile (section 2) replayed through every controller, not tuned on it:
|
|
|
|
| Controller | First within 10% s | Settled s | Overshoot | Blocks above 2x | Blocks within 10% of target (of about 3,700) | Fast-lane blocks |
|
|
|---|---|---|---|---|---|---|
|
|
| kaspa | 262 | never | 6.24 (peak 7.6x) | 2,340 | 0 | |
|
|
| monero | 136 | 136 | 0.09 | 672 | 2,822 | |
|
|
| lwma60 | 75 | 75 | 0.18 | 87 | 2,175 | |
|
|
| lwma120 | 157 | 157 | 0.15 | 174 | 2,390 | |
|
|
| igneum | 79 | 79 | 0.18 | 132 | 2,608 | 2,484 |
|
|
| igneum-literal | 482 | 1,516 | 0.01 | 515 | 2,170 | 1,959 |
|
|
|
|
The simulated Kaspa trajectory reproduces the live one: a late first retarget, a multi-x peak,
|
|
thousands of blocks above 2x, no settling within the record. The Igneum rule settles 79 s after the
|
|
PC arrives; it runs on the short lane for most of the remaining 33 minutes (2,484 blocks) because
|
|
the long window stays polluted by the 75x step for 44 minutes, which is the designed behaviour.
|
|
|
|
## 8. Implementation
|
|
|
|
Branch `difficulty` of `vendor/igneum-node` (worktree `vendor/igneum-node-diff`).
|
|
|
|
| File | Change |
|
|
|---|---|
|
|
| `consensus/core/src/igneum.rs` | `DifficultyRule { KaspaSampled, IgneumDual }` (serde kebab-case) and the `difficulty` constants module (SHORT_WINDOW 120, EPOCH_HOLD 8, PRIOR_BLOCKS 16, LONG_MIN 600, TRIGGER_PERCENT 25, HARDEN_PERCENT 3, EASE_PERCENT 10, CAP_BLOCKS 20) |
|
|
| `consensus/core/src/config/params.rs` | `Params.difficulty_rule` (IgneumDual on all four networks), `OverrideParams.difficulty_rule` and `OverrideParams.genesis_bits` (test networks: the genesis hash is recomputed from the new bits) |
|
|
| `consensus/src/processes/difficulty.rs` | `calculate_difficulty_bits(window, ghostdag, daa_score)` dispatches on the rule; `kaspa_difficulty_bits` is the old body unchanged; `igneum_difficulty_bits` gathers the chain steps (blue-work increment and clamped solvetime per selected-chain block of the epoch, at most 600 or 120) and the in-epoch samples of the window, then calls the pure `igneum_target` (Uint320 arithmetic, rates as unreduced fractions, no floating point) |
|
|
| `consensus/src/processes/window.rs`, `consensus/src/consensus/services.rs` | the rule and the epoch length threaded to the manager; the DAA score of the block passed to the retarget |
|
|
| `testing/integration/src/consensus_integration_tests.rs` | `difficulty_test` pins `KaspaSampled` (it asserts Kaspa's behaviour on mainnet parameters) |
|
|
|
|
Unit tests (`cargo test -p kaspa-consensus --lib difficulty`, 9 tests): hold for the first 8
|
|
blocks, steady state keeps the target within 0.1%, a 50x step up hardens by exactly 3% per block,
|
|
a 50x step down eases by exactly 10% per block, the short lane takes over only beyond 25%
|
|
disagreement with the long lane (on target and 20% fast stay on the long lane, 2x fast binds the
|
|
3% clamp), the epoch lane's shrinkage (8 blocks at 10% slower move the target 3.1%, 8 blocks at half
|
|
speed bind the 10% clamp), the maximum target is never exceeded, plus Kaspa's two level-work tests.
|
|
`cargo test -p kaspa-consensus-core --lib params`: 4 tests including the override-file parsing.
|
|
|
|
Cost: up to 600 compact-header and ghostdag reads per header for the first 600 blocks of an epoch,
|
|
120 afterwards, all from in-memory caches at 1 BPS. Re-measure before the 4 BPS step.
|
|
|
|
## 9. Test network
|
|
|
|
Three `igneumd` nodes from the `difficulty` branch (gRPC 26800, 26810, 26820; P2P 26801, 26811, 26821; node 2 and node 3 `--connect` to node 1; appdir `/tmp/igneum-diff-test/igneum`; override file `{"difficulty_rule": "igneum-dual", "genesis_bits": 505413632}`, i.e. bits `0x1e200000`, 2^19 expected hashes, genesis hash `f682b78a...e099`), three `igneum-miner mine --engine igneum-pow` CPU miners of 4 threads each with their own payout identities, on the shared Mac at load average 50 to 90 (two other agents were building). Schedule: miner A on node 1 for 1,200 s from block 1; miners B and C on nodes 2 and 3 from 359 s to 779 s. 1,133 blocks, 0 rejected (814 + 159 + 160 found), one sink at the end. Record: `sim/difficulty/testnet-igneum-2026-10-03.csv`.
|
|
|
|
What the miners delivered (expected hashes over wall time, the same estimator the simulator's record profile uses): 0.0653 MH/s with A alone, 0.0988 MH/s with A, B and C (1.51x, not 3x: three 4-thread miners on a loaded 18-core Mac share the cores, B and C each reported 0.032 MH/s against A's 0.054), 0.0686 MH/s after B and C stopped (0.69x). Genesis difficulty 262,144 was 4.0x too hard for A alone (the correct value is about 65,000).
|
|
|
|
| Event | Measured, first within 10% (61-block mean) | Simulator prediction on the same profile, 5 seeds | Kaspa's rule, simulator |
|
|
|---|---|---|---|
|
|
| Warm-up from a 4x too hard genesis | 132 s (held 8 blocks, then eased 10% per block; difficulty 190,283 at 60 s, 62,248 at 90 s) | 3, 92, 263, 278, 309 s (median 263) | never within 20 min (no retarget before block 600; 337 to 378 blocks in 20 min) |
|
|
| Miners B and C join, x1.51 | 214 s (within 10% at 23 s, then a 1.9 blocks/s burst at 360 to 390 s as the DAA score jumped with the merged blocks) | 49, 54, 61, 87, 180 s (median 61) | never |
|
|
| Miners B and C leave, /1.45 | 85 s | 110, 231, 300 s and twice not within the remaining 413 s | never |
|
|
| Worst gap after the warm-up | 7.3 s | 4.5 to 13.0 s | 13 to 21 s |
|
|
|
|
Reading: the live network recovered faster than the simulator on the warm-up and the step-down and slower on the step-up. The differences have a cause the simulator does not model: the measured hash rate of CPU miners on a loaded machine swings by 2x between 30-second bins (0.03 to 0.15 MH/s in the table above), so a 1.5x step is of the same size as the noise, and the 61-block mean crosses the 10% band on noise as often as on control. The warm-up, the one clean signal (4x), came in at 132 s against a simulator median of 263 s, both an order of magnitude inside Kaspa's 600-block dead zone (which at 0.25 blocks/s would have lasted 40 minutes). Kaspa's rule, live, on the same network and genesis (`sim/difficulty/testnet-kaspa-2026-10-03.csv`, override `{"difficulty_rule": "kaspa-sampled", "genesis_bits": 505413632}`, 10 minutes, A for 600 s, B and C from 200 s to 500 s): 70 blocks, bits `0x1e200000` on all 70 (difficulty 262,144 never moved), 0.08 blocks/s, worst gap 63 s, delivered hash rate 0.042 MH/s (the other agents' builds were heavier during this run). Never within 10% of target at any point: the 600-block dead zone would have ended after about 2 hours at that rate. The Igneum run on the same genesis was within 10% at 132 s.
|
|
|
|
Per-30-second table (blocks, mean difficulty, work over time) in `sim/difficulty/results.md`.
|
|
|
|
## 10. Limits and what is still open
|
|
|
|
The simulator has no DAG: blocks off the selected chain enter the implementation only through blue
|
|
work, and the record replay is chain-only (exact for 244 retargets, approximate after). No network
|
|
latency, no template refresh delay. Monero and LWMA are reproduced from memory of their reference
|
|
code and labelled approximate. The step-down remains the slowest case (657 s for 50x); real-time
|
|
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.
|