Horizon: lane 1 (consensus-security) lands, with the 51 percent paper

docs/analysis/51-percent.md: what a 51 percent attacker can and cannot do on Igneum, with
numbers (the selected-chain race over a 90-s hold, the lock as the reorder bound, the veto
at 1/3 of weight and its rental cost, the departure case and the LEAVE item, the p2p surface).
docs/analysis/horizon/consensus-security.md: the attack catalogue across GHOSTDAG ordering,
the difficulty rule, the finality weight, miner signalling, proof records, the exec layer and
p2p, each with the bound and the rental cost at the measured USD 11.7 per GH/s-hour; the
pruned-node unwrap class with its sibling list in the sync and IBD flows; fourteen ranked
defences, three of them not recommended with the reason.

Models and results: sim/horizon/consensus-security/ (ghostdag_sim.py, finality_horizon.py,
cost_model.py, signalling.py, result files).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 20:07:43 +00:00
parent b668ec4f4c
commit 7a69daa5ef
14 changed files with 4125 additions and 0 deletions

View file

@ -0,0 +1,84 @@
# What a 51 percent attacker can and cannot do on Igneum
6 October 2026. For the litepaper and the ledger. Written for a reader who maintains Monero or Kaspa and does not take a finality claim on trust. Every number names its model or its measurement; "approximate" marks the rest. Models and runs: `sim/horizon/consensus-security/` (`ghostdag_sim.py`, `finality_horizon.py`, `cost_model.py`, `signalling.py`), the finality simulator `sim/finality_v2.py` with `sim/results_v2.md`, the specification `docs/spec/02-consensus.md` and `03-finality.md`. The long form is `docs/analysis/horizon/consensus-security.md`.
Price basis: USD 11.7 per GH/s-hour, measured on rented pods on 6 October 2026 (`docs/bench-log.md`, "Rental cost of hash": 1,748 MH/s for USD 20.44 an hour; the market supplied no more than about 2 GH/s that evening, so every figure above that is a list-price extrapolation). An attacker holding share A of the total hash rents A/(1 - A) times the honest network N.
## 1. What 51 percent buys, and at what price
Igneum orders blocks by GHOSTDAG (a rusty-kaspa fork, k = 18 at 1 block/s) and locks a checkpoint every 30 blue blocks when two thirds of the trailing 30 days of blue blocks, per vote key, have signed it (spec 03, Q3). The lock lands 63 to 93 s after the checkpoint block (determined 60 blue score later, certified about 3 s after; `sim/results_v2.md` A).
A majority of hash buys the ordering race up to that lock. The DAG simulator (`ghostdag_sim.py`, GHOSTDAG as `vendor/igneum-node/consensus/src/processes/ghostdag/protocol.rs` runs it, 20 seeds per cell, one-way delay 0.67 s = the cloud devnet's p99 propagation) gives, at 1 block/s:
| attacker share | hold 30 s | hold 60 s | hold 90 s | hold 120 s |
|---|---|---|---|---|
| 20% | wins 10%, reorg median 0 / max 18 | 0%, 0 / 1 | 0%, 0 / 1 | 0%, 0 / 1 |
| 34% | 40%, 0 / 18 | 40%, 0 / 36 | 5%, 0 / 39 | 10%, 0 / 52 |
| 51% | 85%, 10 / 15 | 85%, 20 / 28 | 80%, 32 / 44 | 80%, 42 / 53 |
| 67% | 100%, 8 / 13 | 100%, 16 / 23 | 100%, 26 / 31 | 100%, 34 / 41 |
"wins" = the private chain became the honest selected chain on release; "reorg" = chain blocks removed. The arithmetic: a private chain merges honest blocks as blue until its first block has k in its anticone, then every later honest block is red in it; it wins when its R blocks plus k exceed the honest N_h, always at or above 50 percent, below it only for holds under k A / ((1 - 2A) lambda) seconds (6 s at 20%, 56 s at 34%). Withholding longer than the lock latency is useless: the first checkpoint inside the hold locks at most 93 s after it is mined, and a chain missing a certified checkpoint is not a fork-choice candidate (spec 03 F1). Bound: the lock latency, 90 to 120 s. Price of the 90-s race at 51 percent: USD 0.30 at 1 GH/s, USD 304 at 1 TH/s (`cost_model.py`). What it earns: a deposit credited before its lock, which the four-state rule forbids (ledger P17: a wallet shows included, executed, proven, finalised and credits on the last).
Repeated 60-s withholding at 51 percent turns 26 to 28 percent of honest blocks red; a red block's subsidy goes to its merger (spec 02 2.5), so the majority takes about a quarter of honest income and lifts its weight share to about 56 percent (approximate), reorganising the chain every minute in public. It does not reach two thirds.
The proving pool is the one place 51 percent earns more than it spends today. Consensus checks a proof record's signature and its statement against the node's own execution, and not the proof (spec 07 7.7 item 4, 7.8 item 8; ledger P21). A producer who writes the correct statement, random proof bytes and its own payout address into its own block is paid the shard; at 51 percent of blocks that is at least 51 percent of the 20 percent pool (11,636 IGN an hour at full subsidy) and, since its fake rides its next block while an honest proof takes 9 to 11 s on a 5090, most of the shards outside the 10-s exclusive window. Proof verification in consensus closes it; it is the first item in section 5.
## 2. What it cannot do, and why
| it cannot | because | measured |
|---|---|---|
| Reverse a certified checkpoint | a candidate tip must pass through every certified checkpoint (F1); two certificates at one index need two thirds of total weight each, 4/3 in all, so a conflicting pair needs an equivocator holding a third of 30 days of blocks (spec 03 3.11.2) | 0 conflicting locks under 1/3 in every partition and eclipse seed; conflicts from 34% (`sim/results_v2.md` H, I, L3, M5; `finality_horizon.py` P and E) |
| Reach two thirds of weight with 51 percent of blocks | weight is blue blocks over a flat 30-day window: share = (t/30) A, ceiling A; 51% holds 51% on day 30 and never more while honest miners mine | the formula holds to 0.1 day (`results_v2.md` B, `finality_horizon.py` R); red-flooding lifts it to about 56% (approximate) |
| Buy weight faster than mining it | a pulsed rental buys 0.26 blocks per hash under the controller (`sim/difficulty/attacks` scenario 2); a bought key is worth its blocks and decays as the window slides, share = A (1 - t/30) + r t/30 (3.11.5) | `results_v2.md` K, `finality_horizon.py` K: keys worth 51% hold the veto to day 24 and are worth 30% on day 30 |
| Forge state | every full node executes natively and ignores a record whose statement differs from its own execution (the veto, spec 07 7.2 item 5); a soundness bug is a light-client problem (P7) | the exec-attacks suite, 96 of 97 checks on the shipped node (`docs/review/redteam-2026-10-04.md` row 28) |
| Change a rule | code activates when 95% of a day's blue blocks signal it, with a floor height as backstop (P2, `docs/plans/counter-asic-3-node.md` section 6); the signal has 0.07 points of noise over 86,400 blocks, so 94.9% never flips and 95.1% flips on day one | `signalling.py`; the fast-time gate's three cases and its failed case |
| Grind the hourly program | the epoch seed is a 10-minute class-group VDF of a checkpoint block fixed 20 minutes before the epoch; withholding a block to pick a program has expected gain 0 against a 300x evaluator margin (spec 04 4.1, 4.6) | `proto-vdf` Monte Carlo over 2,000,000 epochs |
| Stretch the clocks | a header is at most 10 s ahead of the clock and 10 s behind its parent; a sanitised clock pays a forgery back | a 50% forger drifts the rate +0.4 to +1.1% (M23 fixed, `sim/difficulty/attacks/README.md`) |
## 3. The table: capability against share, time, cost and earnings
| capability | share | time | rent at 1 GH/s | at 100 GH/s | at 1 TH/s | subsidy it earns meanwhile (IGN) | net |
|---|---|---|---|---|---|---|---|
| Reorder the last k blocks | any | 20 s | USD 0 | 2 | 24 | 0 | a loss |
| Win the lock-latency race (90 s) | 45 to 51% | 90 s | USD 0.3 | 30 | 304 | 1k | nothing credited under a lock |
| Reach the veto (1/3 of weight): pause finality at will | 51% | 20 days | USD 6k | 585k | 5.8M | 22M | the attacker earns 51% of it back |
| the same | 90% | 11.1 days | USD 28k | 2.8M | 28M | 22M | |
| Lock alone (2/3 of weight): certify any chain forward | 67% | 30 days | USD 17k | 1.7M | 17M | 44M | 33% of the period's subsidy at equilibrium |
| the same | 90% | 22.2 days | USD 56k | 5.6M | 56M | 44M | |
| Hold a pause once the veto is held | 1/3 | for ever | 0 marginal | 0 | 0 | keeps earning | free |
| 12-h double spend during a pause or the first 30 days | 51% | 12 h | USD 146 | 15k | 146k | 559k | the deposit |
| Orphan an hour of honest blocks beyond merge depth (pause only) | 51% | 1.5 h | USD 18 | 2k | 18k | 70k | the honest hour's subsidy, lost to all |
| Private DAG heavier over the window (cold-start node, no certificate) | 51% | 30 days | USD 9k | 877k | 8.8M | 34M | one fresh node misled |
| Capture the proving pool with fake records | any producer | continuous | 0 extra | 0 | 0 | up to 20% of emission | the one positive line |
| Block a rule change | 6% | per day until the floor | USD 18 | 1.8k | 18k | 6% of subsidy | about zero |
| Pause finality by taking the two hands down (tonight's topology) | 0 hash | | a DoS | | | 0 | free |
At the rental-market equilibrium, where hash joins until rent equals subsidy (39, 156 and 780 GH/s at IGN prices of USD 0.005, 0.02 and 0.10, the three inputs of `docs/analysis/security-budget.md`, not predictions), the veto nets about 48 percent of 20 days of the chain's subsidy and locking alone about 33 percent of 30 days (`cost_model.py` section 3).
## 4. The residual risks, plainly
1. **The pause.** Finality pauses whenever less than two thirds of 30-day weight is connected and signing, and the chain runs on proof of work with a 12-hour depth meanwhile (spec 03 3.7 item 2, 3.9). A third of weight holds the pause for nothing once it has it (`finality_horizon.py` S: 0 locks for the whole silence at 34 to 90 percent, 0 conflicts). During a pause a 51 percent miner is a 51 percent miner on any proof-of-work chain, with the 12-hour depth and USD 146 at 1 GH/s to buy it. What we are building against it is in section 5.
**The departure case** (not an attack, the same pause): tonight on the live devnet 20 keys holding 42.7 percent of the frozen weight table stopped mining within three minutes (a rehearsal job), the signing weight fell to 53.1 percent at checkpoint 6843 and finality paused at 18:39:40Z; rule v2 would have locked again after 35 minutes as the departed blocks aged out of the sliding table, and the frozen table (Q5, the live rule) holds the pause for one window, 2 hours there and 30 days on mainnet; locks had formed with the hand nodes down, so it was weight, not topology (`docs/analysis/horizon/finality-and-weight.md` 3.1 and 4.1; `finality_horizon.py` C: 51 percent leaving pauses 10.5 days under v2, 30.0 under v3). A sudden exit of a third or more of weight, by a price crash or a hosting failure, does the same. What an attacker can do during it is exactly the pause line: proof of work with a 12-hour depth, nothing against any certificate, no new lock to forge; what it cannot do is end the pause early or lock alone, since the departed weight is still in the denominator. The fix is a signed departure (LEAVE, section 5): a key that announces it is leaving is out of every denominator one hour later, which a partitioned key cannot fake.
2. **The first 20 to 30 days.** No lock forms before the window holds 30 days of history (spec 03 3.8, `min_daa` = window): the first month is proof of work with the 12-hour depth, by design, and the renter's day counts start at genesis. On day 30 an attacker producing share s of blocks from day k holds s (31 - k)/30 of the window: 75 percent from day 2 locks alone on day 30 (ledger F1).
3. **Sybil of keys buys nothing; buying keys buys their blocks.** Weight is blue blocks and every draw is by weight (W6; harness s2). A pool's key with its history can be sold or stolen and is worth exactly its 30 days of blocks, decaying linearly as the window slides (K); the sellers' price, not hash, is the limit, and a seller who keeps a copy strips the buyer by equivocating.
4. **Two thirds of total under churn.** The denominator is every key's blocks in the window. If half the honest miners leave, a miner at 51 percent of the old hash holds 51/(51 + 24.5) = 67.5 percent of the window after 30 days and locks alone (spec 03 3.7 item 4: "same as Bitcoin, with a month's warning"). A departure that stops mining and signing at once pauses finality 30 (1 - 1/(3A)) days under rule v2 and until the frozen table expires, 30 days after the last lock, under rule v3 (`finality_horizon.py` C), because a view cannot tell a departure from a partition.
5. **A partition longer than a window forks finality.** Each side fills its own table; under v3 neither side under two thirds locks for 30 days after the last common certificate, then both lock alone at once (`results_v2.md` M3) and an operator's trusted certificate resolves it (3.11.4). Under a third of weight, no equivocator shortens that.
6. **The proving pool.** Section 1's fake-record capture, until proofs are verified in consensus.
## 5. What we are building next
| rank | defence | what it closes | cost | liveness cost |
|---|---|---|---|---|
| 1 | Proof verification in consensus: a record whose aggregated proof does not verify against the pinned key is invalid | the pool capture of section 1 | 8 to 12 hours; per-record verify time to measure | none |
| 2 | Weight-gated deep fork choice: a tip forked more than D (about 10 min) back is a candidate only if the keys that built it hold a third of the weight table at the fork | the pause-time and first-month deep reorg: rented hash has no weight for 10 days, so a 12-h double spend needs the 20-day veto | 10 to 16 hours | none for certificates; a sub-third partition side cannot reorg the other past D, which is the intended outcome |
| 3 | Vote-or-burn: a block whose key has participation under 0.5 in its own past burns 20 percent of its producer share | the free pause: silence then costs 7,757 IGN an hour at 34 percent | 6 to 8 hours | none; partition-safe by construction |
| 4 | Peer floor and mesh: every box dials three others beside the hands; the node alarms under three peers or two checkpoints without a vote; no `unwrap` on any peer-driven sync path (a pruned node was crashed by one request tonight) | a star fleet pausing on one host; one request crashing any pruned node | 5 to 7 hours + 4 | none |
| 5 | The vote signs the execution root too | snapshot poisoning, and "ordered and executed" in one certificate | 8 to 12 hours | lock latency plus executor lag |
| 6 | Signalling over 7 consecutive daily windows, the floor a week past the publish | a one-day renter forcing a flip | 3 hours | a week's latency on class changes |
| 7 | LEAVE, the signed departure (lane 3's rank 1): a `leave` item carried in blocks, the key out of every denominator one hour after inclusion, sent by the app and the fleet library on a clean stop; F5's trusted certificate implemented | tonight's 30-day-scale pause after a planned departure; sim: first lock 1 h after a 34 to 50 percent departure, 0 conflicts in every partition row (`finality-and-weight.md` 6) | 6 + 4 hours | none |
| 8 | A client-shipped certified checkpoint | the cold-start private DAG | 3 hours | none |
Not adopted: prover attestations as a finality leg (the provers are the miners, coverage is a few percent, every lock would wait on a proof); vesting weight (a bought key transfers vested weight); any automatic rule that keeps locks going after an abrupt departure (a view cannot tell it from a partition: `results_v2.md` L4, M3).
The honest sentence for the litepaper: a hash majority on Igneum can reorder about two minutes, can buy a veto over finality in twenty public days and then pause it for free, can double-spend at a 12-hour depth only while finality is paused or in the first month, and today can take the proving pool without proving; it cannot reverse a certificate, cannot reach two thirds of weight while honest miners mine, cannot forge state, and cannot change a rule without 95 percent of a day's blocks.

View file

@ -0,0 +1,328 @@
# Horizon lane 1: consensus security. What a hash majority buys on Igneum, attack by attack, with the bound and the price
Date: 6 October 2026, evening UK (written 19:40 to 21:30 UTC). Lane: consensus-security. Worktree: `/Users/joshm/Projects/igneum-wt-horizon` (branch `horizon` at `3f4f719`). Companion paper: `docs/analysis/51-percent.md`. Models and runs: `sim/horizon/consensus-security/` (README at the end of this file, section 9).
What was read before modelling: `docs/spec/02-consensus.md`, `03-finality.md`, `04-seeds-and-vdf.md`, `06-open-items.md`, `07-execution.md`, `08-client-security.md`; `docs/fud-ledger.md` F1 to F25, M14, M15, M23, M24, P7, P9, P11, P12, X18 to X20, G8, G12, G13, C4, D6, E16; `docs/analysis/difficulty-2026-10-03.md` (section 11), `difficulty-2026-10-04-oscillation.md`, `sim/difficulty/attacks/README.md` (the seven attacked ways); `sim/results_v2.md` A to M; `docs/benchmarks/finality-v3-2026-10-04/*.md`, `docs/benchmarks/round4-consensus-2026-10-04/results-final2.md`; `tools/finality-attacks/README.md` and `lib/net.mjs`, `tools/harness/README.md` and scenarios, `tools/exec-attacks/README.md`; `docs/review/redteam-2026-10-04.md`, `docs/review/round-4-reddit-2026-10-06.md`; `docs/plans/counter-asic-3-node.md` section 6 (P2); `docs/analysis/security-budget.md`; `docs/bench-log.md` "Rental cost of hash, 6 October 2026"; `vendor/igneum-node/consensus/src/processes/ghostdag/protocol.rs` (main checkout); `infra/fast-time/README.md` and `override-60x.json`; CLAUDE.md's 6 October rules. Two facts from main during the lane (19:4xZ, the first later corrected): the live devnet's finality has been paused since 18:39:40Z (lane 3 confirmed the cause at c3aa502: a 42.7 percent departure held by the frozen table, not the hub outage); and run A on igneum-devnet-2 at 10 blocks/s in a star of 41 miners through one seed gave 12.4 blocks/s, 77 percent red blocks, 321 tips, max reorg 55, with the controller lowering difficulty on the low blue rate (cite as "main, 6 Oct 2026 19:4xZ, block-rate-devnet2.md run A" until the file carries the rows).
Price basis for every cost: USD 11.7 per GH/s-hour, measured on RunPod community pods on 6 October 2026 (`docs/bench-log.md`, "Rental cost of hash": 1,748 MH/s for USD 20.44 an hour; approximate above 2 GH/s because the market supplied no more; the live devnet was 1.16 GH/s). Costs are given per network size at 1, 10, 100 GH/s and 1 TH/s. Writing rules: no em dashes, numbers in tables, every figure labelled measured, simulated, cited or approximate.
## 1. Summary for the coordinator
A hash majority on Igneum buys the ordering race inside the lock latency and nothing a certificate covers, and that is measured here, not asserted. Three findings lead.
1. **The lock does the work the k-cluster cannot.** The DAG simulator (`ghostdag_sim.py`, GHOSTDAG as `protocol.rs` runs it) shows a 45 to 51 percent withholder wins the selected-chain race over a 90-second hold 70 to 85 percent of the time, reorganising 32 to 46 chain blocks at 1 block/s; a 34 percent withholder wins only inside 30 to 60 s (40 percent of attempts) and never at 90 s; a 20 percent one only the last k = 18 blocks (5 to 15 percent of attempts). Finality's lock lands about 63 to 93 s after a checkpoint block (spec 03 C1, 3.11.3), so the window a majority can reorder is the lock latency, measured at 90 to 120 s, not a block count. Below two thirds of weight, no hash share reaches past a certificate (spec 03 3.11.2, `sim/results_v2.md` H, I, L3, M5: 0 conflicts under 1/3 in every seed).
2. **The pause is the residual, and it is cheap to buy and free to hold.** Reaching the veto (1/3 of 30-day weight) at 51 percent of blocks takes 20 days and costs USD 6k at 1 GH/s, USD 5.8M at 1 TH/s in rent (`cost_model.py`), of which the attacker earns 51 percent back as subsidy; once held, silence costs nothing (the silent key keeps mining and earning) and pauses finality for as long as it likes (`finality_horizon.py` S: at 34 to 90 percent silent, 0 locks for the whole silence, 0 conflicts). During a pause the chain is proof of work with a 12-hour depth, and a 12-hour 51 percent double spend costs USD 146 at 1 GH/s and USD 146k at 1 TH/s. Tonight's pause is the departure case, confirmed by lane 3 (`docs/analysis/horizon/finality-and-weight.md` 3.1): 20 keys holding 42.7 percent of the frozen table stopped mining in three minutes, the signing weight fell to 53.1 percent at checkpoint 6843, the frozen table (Q5) holds the pause for a window (2 h on the devnet, 30 days on mainnet) where rule v2 would have locked after 35 minutes; certificates formed while the hub was down, so the topology hypothesis is refuted. The signed departure (LEAVE, lane 3's rank 1) is the fix.
3. **The proving pool is capturable by any block producer today, in proportion to its hash and up to most of it.** Consensus checks a proof record's statement against native execution and its signature, and NOT the proof (spec 07 7.7 item 4, 7.8 item 8); the first valid record carried pays. A producer that writes a correct statement with random proof bytes into its own block is paid the shard; at 51 percent of blocks it takes at least 51 percent of the 20 percent pool (11,636 IGN an hour at full subsidy) and, because its fake lands in its next block while honest records need 9 to 11 s of proving, most of the shards outside the 10-s exclusive window. This is ledger P21 priced: the only line in the table where a hash majority earns more than it spends. The fix is proof verification in consensus (section 6, rank 1).
The ranked proposals are in section 6. Two cost nothing in liveness and close whole classes: proof verification in consensus (rank 1) and weight-gated deep fork choice (rank 2: a chain forked deeper than D seconds is a candidate only if the keys that built it hold a third of the weight table at the fork, so rented hash cannot reorg past D even during a pause). Two cost liveness and are not recommended as asked: prover attestations as a second finality leg, and any rule that re-enables locks under the frozen table after an abrupt departure, because a view cannot tell a departure from a partition.
## 2. Method
| Model | File | What it does | Machine, lock, seeds |
|---|---|---|---|
| GHOSTDAG withholding | `sim/horizon/consensus-security/ghostdag_sim.py` | Abstract DAG: Poisson arrivals at 1 and 10 blocks/s, 8 equal honest miners publishing at once, one uniform one-way delay d, one attacker at share H withholding (hold T then release; or selfish: release when about to lose or at lead 6), GHOSTDAG coloring and selected parent exactly as `protocol.rs` (k-cluster with `blues_anticone_sizes`, topological mergeset, blue work), 10 parents. Reports, from honest miner 0: reorg depth in chain blocks against the chain it held at release, fork age, whether the private tip became the chain, attacker blue share, honest blocks turned red | Mac, `with-lock.sh run nice -n 19`, 20 seeds per cell; 1 bps k 18 (`ghostdag_results_1bps.md`, 11 s), 10 bps k 124 (`ghostdag_results_10bps.md`, 223 s); the star check inline (section 4.3) |
| Finality sweep | `sim/horizon/consensus-security/finality_horizon.py` | Imports `sim/finality_v2.py` unchanged (1,000 Pareto keys, 3 regions, 2-s delay, 2.2 percent outage, no DAG, perfect retarget, keys free); adds six sweeps over the adversary's share 20, 34, 51, 67, 90 percent: renter, silent set, bought keys, poisoned eclipse, partition with an equivocator under v2 and v3, abrupt departure under v2 and v3 | Mac, run lock, seeds 7 and 11; `finality_horizon_results.md` |
| Signalling game | `sim/horizon/consensus-security/signalling.py` | Arithmetic on P2 (95 percent of a one-day blue-block window, floor height): binomial noise, holdout cost, forced-flip cost, signal-then-defect | Mac, instant; `signalling_results.md` |
| Cost table | `sim/horizon/consensus-security/cost_model.py` | Every attack's rented hash A/(1 - A) x N, its duration from the spec's arithmetic, its cost at 1 to 1,000 GH/s, what it earns at the three IGN price inputs of `security-budget.md`, and the equilibrium network where rent equals subsidy | Mac, instant; `cost_results.md` |
| Node harness | `tools/finality-attacks/run.mjs s6 --fast-time` against the 0.3.14 Mac binary (`vendor/igneum-node/target-0314/release`, built 6 Oct 17:56), rule v3 forced on, SCALE 0.4, ports 29800+, suffix 980, `/tmp/igneum-horizon-fin` | The 3/3 and 4/2 partitions on a real DAG | Mac, run lock; result in section 4.5 (or the recorded runs if the node refused the file) |
Where a number is from a recorded run and not re-run tonight it is cited by file. Nothing live was touched; no tracked file outside this lane's three paths was edited.
## 3. Evidence: the measured and simulated numbers
### 3.1 Ordering: what a withholder does to the selected chain (simulated, `ghostdag_results_1bps.md` section 2, 20 seeds, d = 0.67 s = the cloud devnet's p99 propagation, ledger F7)
| attacker share H | hold 30 s | hold 60 s | hold 90 s | hold 120 s |
|---|---|---|---|---|
| 20% | won 10%, reorg med 0 / max 18, att blue 65% | 0%, 0 / 1, 38% | 0%, 0 / 1, 21% | 0%, 0 / 1, 18% |
| 34% | won 40%, 0 / 18, 77% | 40%, 0 / 36, 55% | 5%, 0 / 39, 35% | 10%, 0 / 52, 28% |
| 45% | won 70%, 10 / 17, 94% | 75%, 22 / 32, 84% | 70%, 33 / 46, 80% | 70%, 46 / 57, 80% |
| 51% | won 85%, 10 / 15, 90% | 85%, 20 / 28, 88% | 80%, 32 / 44, 86% | 80%, 42 / 53, 84% |
| 67% | won 100%, 8 / 13, 98% | 100%, 16 / 23, 98% | 100%, 26 / 31, 99% | 100%, 34 / 41, 99% |
| 90% | won 100%, 2 / 4, 98% | 100%, 4 / 9, 99% | 100%, 6 / 13, 99% | 100%, 9 / 17, 99% |
"won" = the private tip became honest miner 0's selected chain; "reorg" = chain blocks removed from the chain it held at release (median / max over seeds); "att blue" = the attacker's blue blocks over its blocks in the honest view (its weight and subsidy kept). The fork age when it wins equals the hold (30 to 122 s). At d = 5 s (Kaspa's design bound) the same shares win more often at short holds (34% wins 100% at 30 s) and the same at long ones. Natural reorg depth with no attacker: p99 2, max 2 to 3 at d 0.35 to 0.67 s (the cloud devnet measured p99 3, max 5 over 37,113 removals, ledger F7); p99 15 at d = 5 s.
The arithmetic behind the table: the attacker's private chain merges honest blocks as blue until its first private block has k honest blues in its anticone, then every later honest block is red in that chain. So the private tip's blue work is R + min(k, N_h) against the honest tip's N_h, with R = H lambda T and N_h = (1 - H) lambda T: it wins whenever R + k > N_h, that is for every T when H >= 1/2 and for T < k H / ((1 - 2H) lambda) below it (6 s at 20%, 56 s at 34%, 180 s at 45%, with Poisson noise around each). The honest blocks it turns red (hon red: 25 to 46 percent of honest blocks at 45 to 51 percent and 60 to 120 s holds; 4 to 14 percent at 34 percent) pay their 80 percent to the attacker when its chain wins (spec 02 2.5, the red rule), so a sustained withholder at or above 45 percent takes honest subsidy and raises its weight share; at 20 to 34 percent its own blocks go red and it loses both.
Weight share a repeating withholder settles at, derived from the 60-s rows (the longest hold that beats the lock on every checkpoint, section 5.1), approximate: w = H x attblue / (H x attblue + (1 - H)(1 - honred)).
| H | 20% | 34% | 45% | 51% | 67% | 90% |
|---|---|---|---|---|---|---|
| weight share under sustained 60-s withholding | 9% | 25% | 48% | 56% | 77% | 95% |
| blue rate the difficulty controller reads (share of true) | 86% | 76% | 79% | 80% | 86% | 94% |
So 51 percent of hash reaches about 56 percent of weight by red-flooding the honest side, still 11 points under two thirds; the honest miners lose about a quarter of their subsidy for as long as it lasts, and the chain reorganises every minute, in public.
### 3.2 Ordering at 10 blocks/s (simulated, `ghostdag_results_10bps.md`, k = 124)
| measure | d = 0.35 s | d = 0.67 s | d = 2 s |
|---|---|---|---|
| natural reorg depth p99 / max, no attacker | 11 / 15 | 22 / 26 | 99 / 109 |
| 51% hold 30 s: won, reorg med / max | - | 100%, 50 / 58 | - |
| 51% hold 90 s | - | 100%, 146 / 163 | - |
| 34% hold 30 s / 60 s | - | 95% (59 / 64) / 0% | - |
| 20% hold 10 s / 30 s | - | 45% (10 / 33) / 0% | - |
At 10 blocks/s the same shares reorganise ten times the chain blocks in the same seconds, and the natural reorg depth at a 2-s delay (99) is above the mainnet checkpoint depth d = 60. C1's rule that d scales with the rate (d = 60 B, ledger F7 round 2) is load-bearing; at 10 blocks/s with d = 600 the determination sits 60 s behind the checkpoint as at 1 block/s.
### 3.3 The star: run A reproduced (simulated inline, section 4.3 code; 10 bps, k 124, 8 miners, honest only, 120 s)
| uniform relay delay d, s | blocks in flight | honest blocks red | natural reorg p99 / max at miner 0 | reading |
|---|---|---|---|---|
| 0.67 | 7 | 0% | 19 / 23 | healthy |
| 2 | 20 | 0% | 78 / 99 | reorgs grow with d |
| 5 | 50 | 0% | 8 / 133 | still under k |
| 10 | 100 | 31% | 0 / 0 | past k/lambda: miners stop switching, each on its own chain |
| 15 | 150 | 50% | 0 / 0 | |
| 20 | 200 | 60% | 0 / 0 | |
| 30 | 300 | 67% | 0 / 0 | run A's 77% red sits beyond this row |
Once the effective delay passes k / lambda (12.4 s at k 124 and 10 blocks/s), the DAG stops converging: every miner's own tip is heaviest in its own view, red climbs to two thirds, and the "reorg 0" rows are the absence of consensus, not its presence (run A's 321 tips). Through one hub relaying 12 blocks/s to 41 peers the effective delay is the hub's validation and relay time, which the fleet measures and this lane does not; the model says 77 percent red needs 30 s or more of it, approximate. The controller then reads the blue rate as a third of the true rate and eases, which widens the DAG further (section 4.2 attack 8). Lane 5 owns the fix; the attack bound is in 4.2.
### 3.4 Finality weight over the adversary's share (simulated, `finality_horizon_results.md`, seeds 7 and 11; the table is inserted in section 3.5 from the run)
### 3.5 Finality sweep results
Condensed from `finality_horizon_results.md` (seeds 7 and 11; the full tables are there). A = the adversary's share.
| sweep | 20% | 34% | 51% | 67% | 90% |
|---|---|---|---|---|---|
| R renter, signing: day it reaches 1/3 / 2/3 (sim; formula 10/A, 20/A) | never / never | 30.0 / never | 20.0 / never | 15.0 / 30.0 | 12.0 / 23.0 (formula 11.1 / 22.2; the dust effect of B) |
| S silent set keeps mining, 6 h: locks while silent, first lock after resume | 100%, 0 min | 0%, 0 min (720 stalled) | 0%, 0 min | 0%, 0 min | 0%, 0 min; 0 conflicts in every row |
| K bought keys worth A, buyer mines 30%: veto held (days) / stalls if silent (of 86,400) | never / 816 to 1,045 | day 1 to 6 / 36k to 42k | day 1 to 24 / 74k to 76k | day 1 to 26 / 79k to 80k | day 1 to 27 / 82k; share at day 30 is 30% in every row; 0 conflicts |
| E poisoned eclipse, 20% pool, 2 h: conflicting locks (eclipsed side holds 20% + A) | 0 (40%) | 0 (54%) | 67 to 70 from minute 2 (71%) | 30 to 32 from minute 4 (87%) | not run: the attacker alone is over 2/3 |
| P 50/50 partition with an equivocator, 150 min, v2 and v3: conflicting locks, first at | 0 | 21 to 70, minute 14 to 78 (the knife edge) | 299 to 300, minute 0 | 301, minute 0 | 293 to 300, minute 0; every pre-heal lock kept, 0 post-heal stalls, v3 = v2 in every cell |
| C abrupt departure (stops mining and signing): first lock, days, v2 / v3 (analytic v2 30(1 - 1/(3A))) | 0.00 / 0.00 | 0.8 / 30.0 (0.6) | 10.5 / 30.0 (10.4) | 15.2 / 30.0 (15.1) | 18 to 19 / 30.0 (18.9); 0 conflicts |
Readings. R: the formula holds to 0.1 day; 51 percent never reaches two thirds. S: from one third upward the pause is exactly the silence, free to the silent set. K: bought weight is worth its blocks and decays; a silent 51 percent buyer pauses finality for 24 days then loses the veto. E: an eclipse cannot produce a conflict below the one-third equivocator bound whatever the pool; above it the conflict is the equivocator's, not the eclipse's. P: the bound is one third in every view under both rules, as 3.11.2 says; at 34 percent the model's 2.2 percent outage makes it intermittent (21 to 70 of 300 indices). C: under v2 the pause after a departure ends when the survivors fill two thirds of the sliding table; under v3 (the live rule since 135,200) on day 30 whatever the share; on the devnet's 2-hour window those days are minutes: 2 h after the last lock under v3, which for tonight's 18:39:40Z lock is about 20:40Z (approximate, if the departed boxes hold over a third of the frozen table and do not return).
### 3.6 Signalling (arithmetic, `signalling_results.md`)
| fact | value |
|---|---|
| noise on a one-day window share at p = 0.95 | 0.07 points; a 94.5% fleet never flips, a 95.1% fleet flips on day one (P 0.91) |
| 6% holdout rent per day at 1 / 10 / 100 / 1,000 GH/s | USD 18 / 179 / 1,792 / 17,923 (it earns 6% of the subsidy meanwhile) |
| forced flip, 95% of one day's blue blocks (19 N for 24 h) | USD 5,335 at 1 GH/s, 53k at 10, 534k at 100, 5.3M at 1 TH/s; the market could not supply a TH/s on 6 Oct |
| signal then defect | the defector's blocks fail PoW under the new program and are refused; cost falls on it alone |
### 3.7 Cost of every attack (arithmetic, `cost_results.md`, excerpt; the full table has 12 rows x 4 network sizes)
| attack | share, duration | rent at 1 GH/s | at 100 GH/s | at 1 TH/s | subsidy earned meanwhile (IGN) |
|---|---|---|---|---|---|
| win the lock-latency race (90 s) | 51%, 90 s | USD 0.3 | USD 30 | USD 304 | 1k |
| 12-h double spend during a pause or the first 30 days | 51%, 12 h | USD 146 | USD 15k | USD 146k | 559k |
| orphan an hour beyond merge depth during a pause | 51%, 1.5 h | USD 18 | USD 2k | USD 18k | 70k |
| the veto, 1/3 of weight | 51%, 20 d | USD 6k | USD 585k | USD 5.8M | 22M |
| lock alone, 2/3 of weight | 67%, 30 d | USD 17k | USD 1.7M | USD 17.1M | 44M |
| long-range private DAG over the window (cold start) | 51%, 30 d | USD 9k | USD 877k | USD 8.8M | 34M |
| hold a pause once the veto is held | 0 marginal | 0 | 0 | 0 | keeps earning |
| take the hands or the 8 aggregators down | 0 hash | DoS cost only | | | |
| fake proof records as a block producer | 0 extra hash | 0 | 0 | 0 | up to the whole 20% pool |
At the rental-market equilibrium (hash joins until rent equals subsidy: 39, 156 and 780 GH/s at USD 0.005, 0.02 and 0.10) the veto nets about 48 percent of 20 days of the chain's subsidy and locking alone about 33 percent of 30 days; the 12-hour pause-time double spend costs about 12.5 hours of subsidy.
## 4. The attacks, by layer: what each achieves, the defence, the bound, the price
The hash shares H run 20, 34, 51, 67, 90 percent in every table; "rent" is A/(1 - A) x N x hours x USD 11.7 and the four network sizes are 1, 10, 100, 1,000 GH/s.
### 4.1 GHOSTDAG ordering
| attack | what it achieves (sim) | defence | bound | rent (1 GH/s to 1 TH/s) | earns |
|---|---|---|---|---|---|
| Selfish mining on the DAG (release when about to lose, lead 6) | blue share equals hash share within 0.3 points at every H (`ghostdag_results_1bps.md` section 3): honest blocks are merged, not orphaned, so there is no relative gain; max reorg 1 to 3 chain blocks | GHOSTDAG merges parallel blocks; a red block pays the merger | 0 gain; the attacker's reds are its loss | 0 extra | nothing |
| Withholding to reorder (double spend) | 20%: the last k blocks, 5 to 15% of attempts; 34%: 30 to 60 s, 40%; 45 to 51%: the whole hold, 70 to 85%, 32 to 46 chain blocks at 90 s; 67%+: every attempt | the lock: a candidate tip must pass through every certified checkpoint (spec 03 F1); the checkpoint block locks 63 to 93 s after it is mined | reorg depth = the lock latency, 90 to 120 s (spec 3.11.3), whatever H under 2/3 of weight; credit on the lock only (P17's four states) | 90 s at 51%: USD 0.3 / 3 / 30 / 304 | a deposit credited BEFORE the lock, which no conforming wallet does |
| Sustained red-flooding of honest blocks (repeat 60-s withholds) | 45 to 51%: honest blocks 25 to 28% red, honest subsidy to the attacker, weight share 48 to 56%, chain reorganising every minute | the lock bounds each hold to under 63 s (the first checkpoint inside the hold locks by then); W2 counts blues | weight ceiling about 56% at 51% of hash, 77% at 67% (approx., 3.1): a 51% miner never reaches 2/3 | the ordinary cost of 51% | about a quarter of honest subsidy while it lasts |
| Balance attack (keep two honest halves balanced) | needs network control, not hash: harness s3 and the redteam show a partition under merge depth heals to one chain (`redteam-2026-10-04.md` rows 2, 3) | merge depth 3,600 s merges the sides; beyond it, blue work and finality decide | one chain within merge depth; beyond it the 3.7 item 9 fork (section 4.3 finality) | 0 hash | nothing without a partition tool |
| k-cluster poisoning (make honest blocks red) | the same as red-flooding: only a withholder can be in an honest block's anticone without being in its past; share bound as above | k = 18 at 1 bps (Kaspa's table, delay bound 5 s) | at d = 5 s a 34% withholder turns 29% of honest blocks red in a 30-s window (sim) | as 51% | redirected subsidy at 45%+ only |
| Timestamp games on ordering | none on GHOSTDAG (order is by blue work and hash); on the clocks see 4.2 | 10-s future tolerance, parent minus 10 s (spec 02 2.3) | past-median time can run at most 10 s ahead of real time: nothing against a 30-day window | 0 | nothing |
| Merge-depth games (release a chain forked over 3,600 s ago) | honest blocks of the hour become unmergeable and are abandoned if the released chain is heavier: the 229-block shape of 6 Oct (CLAUDE.md 6 Oct rules) | F1: a chain missing a certified checkpoint is not a candidate; any lock inside the hour kills it | only during a pause or the first 30 days; depth then bounded by the finality depth, 12 h | 1.5 h at 51%: USD 18 / 183 / 2k / 18k | an hour of honest subsidy orphaned, none gained |
| Red-block flooding (publish blocks on stale parents) | the attacker's blocks are red, pay the honest merger, carry no weight; honest blues unaffected | W2, the red rule | pure loss to the attacker | | nothing |
### 4.2 The difficulty rule: the seven recorded ways and the ones to add
The seven of `sim/difficulty/attacks/README.md` and `results.md`, read not re-derived (Igneum rule v2 with the 4 October clock and floor):
| # | way | recorded bound | status |
|---|---|---|---|
| 1 | pool hopping (10 to 100% of the base, 24 h) | +1.5% blocks per hash at most, 0.7 points over Kaspa's rule; a 60-s dwell makes the 50 and 100% hoppers lose 1.7 to 4.0% | PASS under 5%, open by the letter |
| 2 | pulsed rental (50x for 10 min hourly) | weight per hash 0.26 (Kaspa's rule 0.98): a pulse buys no weight; the base's blocks per hash fall 36% in the hour after | PASS (M14, F14 closed with the finality run: the renter never reaches a third) |
| 3 | timestamp stretching (30 and 50% forger, earliest, latest, alternating) | +0.4 to +1.1% drift after an hour (worst seed +2.7%), difficulty ratio 1.00, worst gap 10 s; on 3 igneumd nodes a 50% forger moved nothing (0.82 to 0.88 blocks/s, 0 rejected) | FIXED (M23); before the fix the chain ran at a fifth of its rate at 9.9x difficulty |
| 4 | short-lane oscillation (25% square wave every 120 blocks) | std 0.160 against 0.045 steady, 12% above the attacker's own square wave; the oscillator earns 1.4% less | FAIL by the letter, no past-only controller can pass, no change |
| 5 | epoch games (hold dodger, hold flooders) | 0.0%, +0.7%, +0.3% (worst +2.4%) | PASS |
| 6 | polluted window (10x joins and leaves at the lane switch) | settles 292 to 334 s, worst gap 17 s | PASS (Kaspa's rule 2,910 to 3,540 s) |
| 7 | block flood (85 blocks/s of PoW-less input) | the target stops at 2^128 after about 2,630 blocks, no panic | FIXED (floor) |
Ways not in the seven, with the bound this lane gives:
| # | way | model | bound | who gains |
|---|---|---|---|---|
| 8 | Red-share gaming: a withholder turns honest blocks red, the estimator counts blue work only (spec 02 2.3 "what this section does not do"), the controller eases | the 60-s rows of 3.1: the blue rate read is 0.86 of true at 20%, 0.76 at 34%, 0.79 at 45%, 0.80 at 51%, 0.86 at 67% | the ease is at most 1/(blue share) - 1: 16 to 32% more blocks per real second for everyone (emission above schedule by the same factor, spec 02 2.5); no relative gain to the attacker beyond 4.1's redirected subsidy; the attacker's own reds cap it | nobody relatively; everyone's emission runs 16 to 32% fast while it lasts |
| 9 | The star collapse (run A): effective delay past k / lambda, red to 67 to 77%, the controller reads a third of the rate and eases, the DAG widens | 3.3's table | a positive feedback with no attacker: the controller must read total work or the fleet must not be a star; lane 5 owns the rule, the fleet lib the topology | an attacker who can slow the hub (DoS) gets the collapse for free |
| 10 | Clock trust after a pruning-proof sync: a header whose selected parent has no stored clock starts from the raw stamp (difficulty-2026-10-03.md section 11, Limits) | not measured | at most one window of bias after a sync; a forger needs to be the first blocks a syncing node sees | a stretcher against fresh nodes only |
| 11 | Partition retarget: each side retargets to its share within 657 s (the 50x step-down figure), so each side keeps 1 block/s; at the heal the heavier side's targets rule and the lighter side's blocks carry less work | spec 02 2.3 measured steps; `finality_v2.py` +daa | consistent by construction; the minority's blocks merge red under merge depth | nobody |
### 4.3 The finality weight
| attack | what it achieves | defence | bound (sim) | rent | earns |
|---|---|---|---|---|---|
| Sybil (many keys) | nothing: weight is blue blocks, every draw is by weight (W6; harness s2: dust keys zero weight, sortition by weight PASS, F17 fixed) | W2, W3, F17 | 0 | 0 | 0 |
| Weight capture by mining (the renter) | share (t/30) A: 1/3 on day 10/A, 2/3 on day 20/A, never under A = 2/3 (`results_v2.md` B to 0.04 points; sweep R) | the 30-day flat window | 51%: veto day 20, 2/3 never while honest miners stay; 67%: day 15 and 30; 90%: 11.1 and 22.2 | 20 d at 51%: USD 6k / 58k / 585k / 5.8M | 22M IGN of subsidy |
| Weight capture by buying or renting keys | a bought key is worth its blocks and decays as the window slides: share = A (1 - t/30) + r t/30 (3.11.5; `results_v2.md` K; sweep K); keys worth 40% hold the veto from day 1 to 20, worth 20% never | W2 decay, W5 succession, equivocation strips a sold key the seller still holds | max(A, r) for a day, r after 30 days; a silent 40% buyer stalls 63k of 86k checkpoints then loses the veto on day 19 to 20 | the price of pools' keys, not hash | a pause of up to 20 days |
| Long-range (private DAG from an old point) | a cold node with no certificate follows the heavier DAG (F5) | F5's trusted certificate (designed, not implemented); the client-shipped checkpoint (proposal 11) | needs more blue work than the public DAG over the window: 30 days of >50% | USD 9k / 88k / 877k / 8.8M | 34M IGN |
| Eclipse (poisoned pool) | 0 conflicting locks, 0 locks on the eclipsed side at 1, 2, 4 h for a 34% attacker and a 20% pool (`results_v2.md` L3, F2); sweep E extends it to 51 and 67% | the 2/3-of-total floor binds whatever the presence window says (3.3.2) | the eclipsed side must hold 2/3 of total: a 47%+ attacker plus a 20% pool (sweep E, see 3.5) | the eclipse plus the weight | nothing under the bound |
| Partition with an equivocator | 0 conflicts to 33%, conflicts from 34% (`results_v2.md` H at 2/3; M5 under v3); sweep P at 51, 67, 90 | two certificates need 4/3 of weight in signatures (3.11.2) | 1/3 of weight, every view, any partition length under one window since the last lock (v3) | the 20-day veto | two finalised histories across a partition, each side's deposits |
| Equivocation alone | strips the key for 30 days, no coin penalty (F6); detection by any carrier block, agreed by every node (F23 fixed) | 3.6 | costs the attacker its weight, nothing else | | nothing |
| The 30-day window edges | (a) the frozen table expires at exactly day 30.00 after the last lock: both sides of a long split lock alone at once (M3); (b) a departed set leaves the sliding table over 30 days and the frozen one at the cliff (M4); (c) the first 30 days have no lock at all (3.8, `min_daa` = window); (d) new honest cohorts are under-weighted t/60 for 30 days (G) | stated in 3.7 items 2, 7, 9 | a partition or departure longer than one window ends with the fork of 3.7 item 9 and a manual F5 | | |
| The pause as a liveness attack | a silent set at or above 1/3 pauses every lock for as long as it stays silent (J, L1; sweep S) at zero marginal cost since it keeps earning | none in the rule; the node reports the pause; exchange guidance treats the chain as PoW with a 12-h depth | the 1/3 veto: 20 days at 51% | 0 once held | nothing directly; enables the 12-h PoW double spend below |
| What an attacker can do during a pause | plain proof of work: reorg up to the finality depth 43,200 DAA (12 h) with a heavier chain; beyond merge depth the honest blocks are abandoned (the 229-block shape); every certified checkpoint before the pause still binds | finality depth; the exchange guidance of 3.9 | 12 h of >50% hash | USD 146 / 1.5k / 15k / 146k | a deposit credited at the PoW depth; 559k IGN of subsidy as a miner |
| Tonight's departure (confirmed, lane 3 `finality-and-weight.md` 3.1 and 4.1) | 20 keys holding 42.7% of the frozen table stopped mining 18:27 to 18:30Z (the rehearsal job); the last lock 6842 at 18:39:40Z; 6843 determined with 53.1% of total signing and never locked; under v2 the stayers' sliding share crossed two thirds at 6912 (19:14:53Z, a 35-min pause) but Q5 held them at 57.3% of the frozen table; expected first lock when that table expires at DAA 216,402, about 20:40Z, or when 9.4 points of departed keys return. Sweep C agrees: 51% leaving pauses 10.5 days (v2) or 30.0 days (v3) at mainnet scale; at tonight's 46.9% (observer's view) 7.7 days under v2, 30 under v3 (lane 3, 4.1) | by design (F21: the project lead chose the pause over the fork); a view cannot tell a departure from a partition | anything over 1/3 of the table leaving at once pauses finality for a window | 0 | 0; what an attacker can do during it is the row above |
### 4.4 Miner signalling (P2)
| game | model | bound | price |
|---|---|---|---|
| 6% holdout blocks a change for ever | the window share has 0.07 points of noise: 94.9% flips with P 0.09, 94.5% never | the floor N6 ends it; nothing else does | USD 18 a day at 1 GH/s, 17.9k at 1 TH/s; the holdout earns 6% of subsidy meanwhile, so net about zero at equilibrium |
| What the floor does | converts the signal into a fixed height at N6: the hazard of 6 October (DAA 198,000 crossed while boxes were still updating: two-sided chain, 229-block reorg) returns for every node not on the object at N6 | the floor should sit no nearer than a week past the publish on a network miners run, and the stale-box list (P1 pass rule) must be empty before it | |
| Signal then defect | a defector's blocks fail PoW under the new program and are refused (`check_header_version` then PoW); a pool with stale workers loses their blocks | the defector pays, nobody else | |
| The one-day window | a renter at 19 N for 24 h with a patched byte forces the flip; for v4 it hurts nobody (the signalling binary is the v4 binary), for a later object it forks every node still on the old one | the share of the fleet not yet on the object at the forced flip | USD 5.3k at 1 GH/s to 5.3M at 1 TH/s, and the market could not supply a TH/s |
| Proposed | require 95% on each of 7 consecutive daily windows (7x the renter's bill, a week of visible share), keep the one-day tally for display | | 3 hours |
### 4.5 Proof records
| attack | today's rule | bound | who gains |
|---|---|---|---|
| Forgery of state | the native-execution veto: a record whose statement differs from the node's own execution of that segment along the carrying block's chain is ignored (7.2 item 5, P11 fixed) | state is never moved by a record; a soundness bug is a light-client problem (P7), a job-output problem for the precompile (D6, contained by R12) | nobody |
| Forgery of the proof (correct statement, random bytes) | NOT checked in consensus (7.7 item 4, 7.8 item 8); the first valid record per shard or segment carried pays | a producer at share H takes at least H of the 20% pool and, since its fake rides its next block while an honest proof takes 9 to 11 s on a 5090 (P9 table), most shards outside the 10-s exclusive window; inside it only the H of slots it is assigned | any block producer: 11,636 IGN/h at 51% of a pool paying 22,815 IGN/h |
| Withholding (an assignee sits on its window) | after 10 DAA s anyone may prove and be paid; a segment unproven after 600 DAA pays nothing (7.8 item 7); mandatory proofs off | one window of latency per absent assignee; nothing waits | nobody |
| Grief (flood the pool with invalid records) | 6,000 invalid records: 0 accepted, 0.31 to 0.68 ms each, node up (redteam row 10) | CPU per record | nobody |
| The aggregator naming itself as every prover | provers committed in the proof's public values and checked (P12 fixed) | 0 | nobody |
| Record ordering race | the first valid record carried wins: a producer can front-run honest provers' records in its own block (the forgery line) | fixed by consensus verification (rank 1), then by aggregator sortition (O-7.3) | |
### 4.6 The execution layer
| attack | today's rule | bound | note |
|---|---|---|---|
| Snapshot poisoning over p2p | `p2p_snapshot_gate` refuses tip 0, below the restart, at or below the own tip, and anything while the executor runs unblocked; a snapshot whose state at the restart block differs from `exec_restart_state_root` is refused (release-0.3.14.md) | a wrong state ABOVE the restart block is not detectable by the node: headers commit to no execution root (spec 02 2.6: blocks carry transactions only), certificates sign (chain id, index, block hash) only (C2) | the poisoned node's native statement then disagrees with every carried record, it pays nothing and sees every honest record as invalid; the signal exists but nothing reads it as an alarm |
| The pin | `exec_restart_number / hash / state_root / trust_daa` arrive by the signed manifest on the devnet (the reddit review's admin-key finding, 1.6 item 3); on mainnet no manifest exists, so the pin is genesis-only or absent | a release-key holder sets execution state on the devnet; on mainnet the same power would need the 95% signal | disclose (the review's key-powers table) |
| Deep reorg never resets execution | a reorg reloads the newest persisted generation at or below the fork (ring 2,048) else blocks loudly and asks a peer; never a genesis replay on a pruned node (0.3.14) | under active finality a reorg is bounded by the lock (90 to 120 s), far inside 2,048; during a pause the 12-h depth is 43,200 blocks, 21x the ring, so a pause-time deep reorg blocks every pruned executor until a peer's snapshot arrives, which is the poisoning path above | the ring should reach the finality depth (proposal 12) |
| Duplicate and nonce games, pgas bombs, malformed bodies | exec-attacks suite 96 of 97 checks, every executed block under B_p, an over-budget transaction refused at the mempool with the pgas metered (redteam row 28) | per block B_p of proving gas and B_e of execution gas; an aborted transaction pays | |
### 4.7 Peer to peer
| attack | what it achieves | defence | bound | price |
|---|---|---|---|---|
| Eclipse of one node | feed it a private chain: its difficulty eases to the attacker's hash within about 11 min (the 50x step-down takes 657 s), so 1% of the network's hash produces a plausible 1 block/s chain for the victim within 20 min; it sees no certificates and reports `finality_active` false | the exchange guidance (treat a pause as PoW with a 12-h depth); a node that holds locks will not follow a chain missing them (F1) | a victim that follows its node's finality flag loses nothing credited under a lock; one that credits at a PoW depth is Kaspa's or Monero's eclipse victim | a few IPs |
| Eclipse or outage of the hands | tonight's pause was NOT this (lane 3, 3.1: locks 6824 to 6842 formed with the hub down, the zero-aggregator fallback carried 64 of 251 certificates); the attack stands in general: a fleet that peers only through two hosts is a star, and a star with its centre down is a partition into n islands, each under 2/3, finality paused until the heal; longer than merge depth (60 min) it is the 3.7 item 9 fork | aggregator fallback (any node aggregates after 15 DAA s); votes ride in blocks (Q2); neither crosses a dead hub | pause for the outage; proposal 4 (peer floor) removes the star | 0 hash: the DoS of two hosts |
| Crash a pruned node from any peer (main, 19:57Z: the hub, a pruned 0.3.14 node, panicked on an `unwrap` over `KeyNotFound` at the devnet genesis when a re-joining peer synced below its retention; `consensus/src/processes/sync/mod.rs:87`, fix in 0.3.15) | any peer takes any pruned node down by asking for history it does not hold | none today; the rule: no `unwrap` or `expect` on a path a peer's request reaches, a `SyncManagerError` instead, and a fuzz of the sync request space (locator low/high, antipast low/high, missing-bodies high, pruning-point anticone) against a pruned node as the CI gate | zero hash; one request per node; repeated, a liveness attack on every pruned node (every mainnet node prunes) | 0 |
| Eclipse of the seeds (3 Hetzner DNS seeds) | a fresh node bootstraps into attacker peers and, with no trusted certificate, follows their DAG (F5 cold start) | none implemented; F5 designed | the long-range attack's price (4.3) for the DAG, zero for the eclipse | USD 9k to 8.8M for the DAG |
| Handshake refusal (params digest) | a peer with another digest is refused before any flow (X18); an attacker cannot make honest peers refuse each other | it is a defence; the only cost is the digest-less allowance still open on devnet and simnet | 0 | |
| The p2p snapshot path | 4.6 | | | |
| Memory under flood | 269 to 780 MB per minute of flood on 0.3.4 (M30), bounded since 0.3.5 to the record window plus pruning depth | | | |
Siblings of the 19:57Z crash class, read-only grep of the fork (main checkout `vendor/igneum-node`, 6 Oct) for `unwrap` and `expect` on paths a peer's request reaches. The sync manager's entry points are called from the request flows (`protocol/flows/src/v10/request_headers.rs`, `request_antipast.rs`, `request_block_locator.rs`, `request_ibd_chain_block_locator.rs`, `request_pruning_point_and_anticone.rs`, `request_block_bodies.rs`) through `consensus/src/consensus/mod.rs:1341` (`get_hashes_between`), `:1624` (`get_missing_block_body_hashes`), `:1647` (`create_block_locator_from_pruning_point`):
| file:line | what panics | reached by |
|---|---|---|
| `consensus/src/processes/sync/mod.rs:87, 88` | `ghostdag_store.get_blue_score(low/high).unwrap()`: the hub's crash when `low` is below retention | `antipast_hashes_between` from a peer's antipast or headers request |
| `sync/mod.rs:94` | `ghostdag_store.get_data(current).unwrap()` on the forward chain walk | the same |
| `sync/mod.rs:117` | `find_highest_common_chain_block(...).expect("because of the pruning rules such block has to exist")`: false once the peer's `low` is pruned | the same |
| `sync/mod.rs:123, 125, 130, 135, 148` | pruning point, selected-chain tip and index lookups `.unwrap()` | `create_virtual_selected_chain_block_locator` from a peer's locator request |
| `sync/mod.rs:162, 172, 182, 194, 196` | status lookups `.unwrap()` along a peer-named `high` | `get_missing_block_body_hashes` from a peer's IBD blocks request |
| `sync/mod.rs:211, 221` | `get_blue_score(low)`, `get_compact_data(current)` `.unwrap()` | `create_block_locator_from_pruning_point` from a peer's IBD chain locator request |
| `protocol/flows/src/v10/request_headers.rs:98` | `hashes.last().expect("caller ensured ...")` | the headers request flow, after `get_hashes_between` |
| `protocol/flows/src/ibd/negotiate.rs:40, 47, 112, 166, 172` | `locator_hashes.last().unwrap()` on a locator the PEER sent | the IBD negotiation (the syncee side, a hostile syncer) |
| `protocol/flows/src/ibd/flow.rs:307, 361, 436, 440, 700, 717` | `async_get_header(...).unwrap()`, `async_validate_pruning_points(...).unwrap()`, `pruning_points.last()/first().unwrap()` on peer-sent pruning points | IBD against a hostile syncer |
The rest of the hits in those files are test code (`request_headers.rs:199 to 222`, `request_pruning_point_and_anticone.rs:197 to 280`, `trusted_data.rs:81 to 149`, `proof.rs:144 to 230`) or channel sends. Bound of the class: zero hash per crash; every pruned node on the network can be taken down by one request each, repeatedly, which during a pause or the first month is a liveness attack on the chain itself and at any time on the hands. Proposal (lane 1, 4 hours): convert the sync manager's unwraps to `SyncManagerError` variants, make the negotiation and IBD paths return `ProtocolError` on a missing block, and add `tools/ci` fuzz `sync-request-fuzz` that drives the six request flows with random and below-retention hashes against a pruned fast-time node and fails on any exit; gate: 10,000 requests, node alive, 0 panics.
### 4.8 The harness run (real DAG, 0.3.14 binary, rule v3, fast time)
`tools/finality-attacks/run.mjs s6 --fast-time`, SCALE 0.4, rule v3 forced on (`finality_v3_activation_daa` 0), the 0.3.14 Mac binary (`vendor/igneum-node/target-0314/release/igneumd`, built 6 Oct 17:56) and its `igneum-miner` (`vmine`), ports 29800 to 29812, suffix 980, `/tmp/igneum-horizon-fin`, run lock held 19:53 to 20:03Z. The 0.3.14 node refuses the master copy of `infra/fast-time/override-60x.json` (unknown fields `program_class_v4_activation_daa` and `program_class_v4_signal_window_daa`, which only the `ca3-v4-node` binary knows), so the harness ran from a scratch copy of `tools/finality-attacks` and `infra/fast-time` with those two fields removed; nothing tracked was edited. Six vmine voters at 6 blocks/s in all (3 per side), window 120 DAA, warm 168 s, split 60 s, heal 60 s.
| scenario | measured | reading |
|---|---|---|
| A, 3/3 split | window DAA ~1,019 at the cut, 6 voters, max locked 34 on both sides; new locks during the 60-s split 12, the first on each side at 30 s; locks resumed after the heal; conflicting certificates 9 / 11 after the heal | the recorded F21 shape at this scale: a side at 3 blocks/s advances its own DAA 3 a second, so the frozen table (one window of 120 DAA after the last common lock) expires 30 to 40 s into the split and the sliding table then locks each side alone, exactly as the redteam's row 18 (36 and 49 s) and the simulator's M3; the harness criterion (`zero new locks`) asserts more than the rule promises past one window |
| B, 4/2 split | window DAA ~900, max locked 30; the 4 side locked 30 to 38 (first at 33 s), the 2 side stayed at 30 for the split; conflicting certificates 1 / 12 after the heal | the 4 side holds 4/6 = two thirds of the frozen table and locks as Q3 allows (inclusive); the 2 side locks nothing while the table stands; the post-heal conflicts are the 2 side's late solo locks after its own table expired (redteam row 18 saw the same 4 late locks), the F21 residual |
What it adds to the sim: the real DAG at fast time reproduces the frozen-table bound within its own DAA clock and the v2 control's shape (`split50-v2.md`: 3 solo locks at 126 s, 4 conflicts); nothing contradicts the simulator. What it does not test: a withholding attacker (vmine has no withhold flag) and any run longer than the window.
## 5. Model: the formulas and what holds
### 5.1 The ordering race against the lock
Inputs: lambda blocks/s (measured 1 on the devnet), k = 18 (cited, Kaspa's table), d (measured 0.34 to 2.3 s), checkpoint interval I = 30 blue score and depth d_cp = 60 (designed), lock latency after determination median 2.5 s, p99 4.6 s (simulated, `results_v2.md` A), 0.8 to 1.08 s on the test network (measured). A deposit at time 0 lies under the checkpoint block mined at most 30 s later, which locks at most 30 + 60 + 3 = 93 s later. An attacker forking before the deposit must win the race before that lock (after it, F1 excludes its chain). From 3.1: wins need H >= 1/2 for any T, or T < k H / ((1 - 2H) lambda) below it; at T = 90 s that is H >= 0.45 with Poisson noise (70 to 85 percent at 45 to 51 percent, 5 to 10 percent at 34 percent). Hence: a majority reorders at most the lock latency; a 34 percent miner at most about 60 s; a 20 percent miner at most the last k blocks. The honest guidance (credit on the lock) makes every case a zero.
### 5.2 Weight
share_renter(t) = (t/30) A (verified, B, sweep R); share_buyer(t) = A (1 - t/30) + r t/30 (verified, K, sweep K); two certificates need 4/3 of total in signatures, so the equivocator bound is 1/3 in every view (3.11.2, H at 2/3, M5 under v3); a partition side's own table reaches 2/3 on day 30 (2/3 - s)/(1 - s) under v2 (L4) and never before day 30 under v3 (M2, M3); a departed share x pauses 30 (1 - 1/(3x)) days under v2 and 30 days under v3 (L2, M4, sweep C). The weight ceiling of a withholder is 3.1's w(H) with the 60-s rows.
### 5.3 Rent
cost = A/(1 - A) x N x hours x 11.7 USD (measured price); earned = 0.8 x A x 31.688 x 3600 x hours IGN (spec 02 2.5); N_eq = 0.8 x 31.688 x 3600 x P / 11.7 GH/s at IGN price P (assumption inputs). At N_eq the veto nets about 0.48 x 480 h of subsidy and locking alone 0.33 x 720 h.
### 5.4 What holds, what breaks
| claim | holds | evidence |
|---|---|---|
| No hash share under 2/3 of weight reverses a certified checkpoint | yes | 3.11.2, H and M5 (0 conflicts under 1/3 in every seed), the DAG sim (the race ends at the lock) |
| 51% of hash never reaches 2/3 of weight while honest miners stay | yes, with margin: 56% ceiling under red-flooding (approx.), 51% honest | B, sweep R, 3.1 |
| A majority cannot forge a proof the nodes re-execute | yes for state; NO for payment: the proof itself is not checked and the pool is capturable | spec 07 7.7 item 4, 7.8 item 8 |
| A rule changes only at 95% signalling | yes for the signal path; the floor is a fixed height that can cross with stale nodes | P2, signalling.py |
| A majority loses more than it earns | for the veto and the lock-alone, yes (net 48% and 33% of the period's subsidy at equilibrium); for the pause-time 12-h double spend and the fake records, NO | cost_model.py |
| The pause is safe | safe for history, costly for liveness, and buyable at zero hash by taking the hub down | tonight; sweep S and C |
## 6. Ranked proposals: the defences we do not have
| rank | proposal | evidence | model | hours | consequence per tier | gate |
|---|---|---|---|---|---|---|
| 1 | Verify the aggregated segment proof in consensus (a record whose proof does not verify against the pinned aggregator key is invalid; the per-shard v0 record stays payout-only until then and is capped at the exclusive window) | 4.5: a producer captures H to most of the 20% pool with fake records; P21 "stated, not fixed" | capture = pool x (H inside the window + most outside); verification cost per record = SP1 light verifier (P3: 1.3 to 2.1 s setup, then per-proof ms, to measure) x 2 records per block | 8 to 12 | home miner (8/12/16 GB): its honest shard records are paid, not front-run; rig and pool: proving income real; prover: the market is honest; holder: 20% of emission is not a miner's bonus; rollup customer: a paid proof is a verified proof; node: +verify CPU per block | fast-time: a correct-statement fake record is refused, an honest one paid, p95 block validation under 50 ms with 2 records |
| 2 | Weight-gated deep fork choice: among candidate tips, a tip whose fork point is older than D (say 10 min of past-median time) is a candidate only if the blocks on it since the fork were produced by keys holding at least 1/3 of the weight table at the fork point (a function of the block's past, deterministic) | 4.3 "during a pause": a renter with fresh keys double-spends at the 12-h depth for USD 146 at 1 GH/s; the DAG sim's 90-s race | rented hash has zero weight for 10 days (W2), so its deep chain is never a candidate; honest partition sides over 1/3 keep today's behaviour; a side under 1/3 cannot reorg the other past D, which is the desired outcome | 10 to 16 (virtual processor candidate filter + weight-at-fork from the finality tables) | home miner and rig: nothing changes; pool: nothing; holder and exchange: a pause-time or first-month deep reorg needs 1/3 of weight, 20 days in public, not 12 h of rent; node: one table lookup per deep candidate; rollup customer: PoW-depth credits become weight-backed | fast-time: a fresh-key renter at 3x the hash forking 2 min back is refused for ever; a 40%-weight honest side forking 2 min back is adopted |
| 3 | Vote-or-burn: a block whose producer key has participation under 0.5 over the presence window in the block's own past burns 20% of its producer share | 4.3 "the pause as a liveness attack": zero marginal cost | the attacker's pause then costs 0.2 x A x 0.8 x 114,077 IGN/h: 7,757 IGN/h at 34% (USD 155/h at 0.02); partition-safe because the test is the block's own past (a side's keys vote their own checkpoints); honest outages (2.2%) leave participation above 0.9 | 6 to 8 (coinbase rule + the finality manager's participation count at the block) | home miner under dust: not a voter, counts 1, unaffected; a miner whose node never revealed a vote key: loses 20% until it does (an incentive); pool: votes or pays; holder: silent weight stops being free | fast-time: a 40% silent set's coinbases shrink 20%, honest ones do not, both sides of a 3/3 split unaffected |
| 4 | Peer floor and mesh for the fleet and the node: the fleet lib dials at least 3 other boxes beside the hands; the node logs an alarm and the app shows it when outbound peers fall under 3 or when no vote has been received for 2 checkpoints | run A's star (321 tips, 77% red); the hands as the fleet's only peers (lane 3 refuted this as tonight's cause; the shape stands) | a star with its hub down is n islands; with 3 extra peers per box the graph stays connected under any single failure | 2 to 4 (fleet lib) + 3 (node alarm) | every tier: finality stays up when a hand dies; home miner: sees "no peers" instead of a silent pause | Devnet 2: kill the hub for 10 min, locks continue; the alarm fires on the known-bad case and not on the known-good |
| 5 | Vote over the execution root too: the vote signs (chain id, index, block hash, post_root of the checkpoint's segment); a certificate then pins the state, snapshots are checked against the last certificate, and the poisoned-snapshot node cannot join the quorum | 4.6 snapshot poisoning: a wrong state above the pin is undetectable | every voter is a full node that executes natively (spec 07); the cost is exec lag added to lock latency (the executor runs seconds behind the tip) | 8 to 12 | holder and exchange: a certified checkpoint carries its state; rollup customer and light client: one object says ordered and executed; node: a divergent executor is visible at once; miner: a vote waits for its executor (lock latency + exec lag) | fast-time: three nodes agree; a fourth with a tampered snapshot votes a different root and is outvoted, alarm raised; lock latency rises by the measured exec lag only |
| 6 | Signalling over 7 consecutive daily windows, floor no nearer than 7 days past the publish, the stale-box list empty before the floor | 4.4 | the renter's bill x7; a week of visible share | 3 | pool and rig: a week more before a class change; home miner: a week to update | fast-time gate: 6 of 7 days at 95% does not flip; 7 does |
| 7 | Detector-driven alarms: the share-pattern detector (counter-asic-3-status, Detector row) and the finality flag feed one node-side `security_alert` (correlated group over 1/3 of a day's blue blocks; pause; conflict) that wallets and the explorer show as "confirm at 12 h" | 4.3, the exchange guidance exists only as text | an alarm when any single party crosses the veto line in public, which the rule says takes 10 to 20 days | 4 to 6 | holder and exchange: a number to act on; home miner: the app shows the chain's state | fast-time: a 34% silent set raises the alarm within 5 min; the honest run never does |
| 8 | The signed departure (LEAVE: lane 3's rank 1, `finality-and-weight.md` section 6; a `leave` item carried in blocks, the key out of every denominator one hour after inclusion, sent by the app and the fleet library on a clean stop) and F5's trusted certificate implemented | tonight's departure (lane 3, 3.1): 42.7% left in three minutes and the frozen table held finality for a window; a view cannot tell a departure from a partition, so no automatic rule re-enables locks without reopening L4/M3 | stripping lowers total; an attacker stripping stolen honest keys is K's bound (needs keys worth 1 - a/(2/3)); lane 3's sim T: first lock 1 h after a 34 to 50% departure, 0 conflicts in every partition row | 6 (lane 3) + 4 | pool and rig: an orderly stop keeps finality up for everyone; holder: no 30-day pause after a planned fleet move; node: the operator's certificate for the disorderly case | fast-time: 45% of weight stops with exits, locks continue; without, the pause |
| 9 | Client-shipped checkpoint: each release carries the latest certified checkpoint (index, hash) and voter-table digest; a cold node refuses a DAG missing it (assumevalid's shape) | 4.3 long-range, 4.7 seeds | the long-range attack must then out-work the public DAG since the release, not since the window | 3 | every tier: a fresh install cannot be bootstrapped onto a private DAG; trust is the release key already trusted for the binary | a cold node offered only a private heavier DAG refuses it |
| 10 | Exec generations spaced geometrically to the finality depth (1, 2, 4 ... 43,200 blocks: about 16 generations) so a pause-time deep reorg never needs a peer's snapshot | 4.6 | 16 x state size on disk (about 1.8 GB today, measured 114.8 MB per snapshot) | 3 | node operator: disk; every tier: no blocked executor after a deep reorg | fast-time: a 5,000-block reorg re-executes from a generation, no snapshot request |
| 11 | Checkpoint anchoring to proof records (the certified checkpoint's hash as a public value of the next aggregated proof; the verifier checks the certificate natively) | asked by the brief | buys light clients and bridges one object (certified and proven); changes nothing a full node does, since the proof chain already commits to block hashes and a reorg already needs new proofs; in-circuit BLS is 40+ hours and not worth it | 6 (public-value commit) | rollup customer and light client: one verification; others: nothing | a light client verifies a proof carrying a certificate hash and the certificate |
| 12 | Prover attestations as a second finality leg (a lock also needs proof records from a quorum over the checkpoint) | asked by the brief | NOT recommended: provers are the miners (same vote keys), so no new party; proving covers 2.4% of blocks today and the pool is "not active" (reddit review 1.4), so every lock would wait on proofs and finality would pause constantly; what it would add (execution validity) rank 5 gives without the liveness cost | 8 if ever | every tier: lock latency becomes proof latency (20 to 60 s target, minutes today) | only once coverage is 100% and rank 1 is in |
| 13 | Time-locked (vesting) weight | asked by the brief | NOT recommended: a bought key transfers vested weight, so K's bound is unchanged; honest new cohorts wait N days longer than G's 20 | 3 if ever | new home miners: later vote; attacker: unchanged | none |
| 14 | Any rule that keeps finality on after a large honest set leaves abruptly without a signed exit | asked by the brief | NOT possible safely: departure and partition are the same observation in one view; re-enabling locks under the frozen table reopens the double lock of L4 and M3 at the same day; the honest options are rank 8 (exit) and a shorter frozen expiry, which trades the partition bound one for one | 0 | | |
One paragraph each on the two that matter most.
Rank 1, proof verification in consensus. Today a record is paid on a signature and a statement match; the statement is computable by every node, so a producer writes the right statement, random proof bytes and its own payout address into its own coinbase and is paid the shard or the aggregator share. The exclusive window limits it to the slots it is assigned (by weight, so H of them) for 10 DAA s; after that the first record carried wins, and the producer's block is first. The only thing that stops it is a verified proof as a condition of payment. SP1's light verifier exists (`igneum-prove-host --mode verify-segment`); the cost to measure is the per-proof verification time on the validation path, and if it is over a few tens of milliseconds the aggregated record (2 per block) is the one to verify in consensus while the per-shard record stays payout-only inside the window. Consequence per tier: an 8 GB home miner that proves on the patched prover is paid for what it proves; a pool's proving income is real; a holder's 20 percent of emission goes to proofs.
Rank 2, weight-gated deep fork choice. Finality's whole argument is that weight cannot be rented; fork choice today ignores weight, so during a pause or the first month a renter's heavier chain reorganises up to 12 hours. The rule: a candidate tip whose fork point is more than D of past-median time behind the node's selected tip is a candidate only if the keys that produced its chain blocks since the fork hold at least a third of the weight table at the fork block (the same `voters_at` the finality manager computes). It is deterministic (a function of the DAG), it leaves every reorg under D to GHOSTDAG as now, it leaves honest partition sides over a third exactly as now, and it makes the pause-time double spend cost the veto (20 days in public) instead of 12 hours of rent. What it costs: a side of a partition under a third of weight that is heavier by work cannot reorganise the other side past D at the heal, which is the outcome the certificate would have produced anyway; and a cold node with no table yet follows F5. Gate: the fast-time run in the table.
## 7. Open questions and what could not be run
| item | why |
|---|---|
| The star's effective relay delay | the model needs the hub's measured relay time at 12 blocks/s to 41 peers; main's run A rows give the outcome (77% red, reorg 55) and this lane gives the curve (3.3); the fleet measures the delay |
| The finality sweep's R row at 90% | the renter's 9x hash makes 905 honest keys fall under dust in the model (B's dust effect), so the simulated share overshoots the formula by 1 to 2 points, as B recorded |
| Proof verification time on the validation path | not measured; P3 gives setup only; rank 1's hours depend on it |
| Weight-gated fork choice against the C4 certificate-driven reorg | a certificate over a deep block must still force the reorg (3.5); the gate must exempt certified tips; not modelled |
| The DAG simulator has equal work per block, no difficulty, no bodies | a withholder also controls its blocks' timestamps and the attack-side difficulty; the 10-s rules bound the clocks, the DAA lane bounds the rest |
| igneumd harness | one s6 run on the 0.3.14 Mac binary (4.8); the node-side numbers for the other scenarios are the recorded runs cited |
| The live pause's end | lane 3's arithmetic (4.1): the frozen table of lock 6842 expires at DAA 216,402, about 20:40Z, unless departed keys holding 9.4 points of it return first; main reads the chain |
## 8. Summary paragraph
The ordering layer and the lock together bound a hash majority to the lock latency: the DAG simulator shows 45 to 51 percent winning the 90-second race 70 to 85 percent of the time and nothing beyond it, 34 percent winning only inside 60 s, 20 percent only the last k blocks; weight cannot be rented faster than 10 days per third, bought keys decay as the window slides, no equivocator under a third splits finality in any view, and the costs in rented hash are USD 6k to 5.8M for the veto and 17k to 17M to lock alone across 1 GH/s to 1 TH/s, of which the attacker earns back half as subsidy. Three things a hash majority does buy today: the proving pool, by writing fake records into its own blocks (the one line that earns more than it costs); a 12-hour proof-of-work double spend during a pause or the first month for USD 146 to 146k; and a pause needs no attacker at all, since a planned 43 percent departure caused tonight's and the frozen table holds it for a window (lane 3, 3.1). The three findings as numbered lines:
1. A 51 percent withholder reorganises at most the lock latency (90 to 120 s; 32 to 46 chain blocks at 1 block/s, 80 percent success), reaches a weight ceiling of about 56 percent by red-flooding (never two thirds), and costs the honest side a quarter of its subsidy while it lasts (`ghostdag_sim.py`).
2. The veto costs 20 days of 51 percent in public (USD 6k at 1 GH/s, 5.8M at 1 TH/s, half earned back) and then holds a pause for free; a pause-time 12-hour double spend costs USD 146 to 146k (`cost_model.py`, `finality_horizon.py` S and C); weight-gated deep fork choice (rank 2) makes it cost the veto instead.
3. Any block producer captures from H to most of the 20 percent proving pool today with correct-statement fake records (11,636 IGN an hour at 51 percent), because consensus does not verify the proof (spec 07 7.7 item 4); proof verification in consensus is rank 1.
## 9. Files and how to run them
| file | run |
|---|---|
| `sim/horizon/consensus-security/ghostdag_sim.py` | `with-lock.sh run nice -n 19 python3 sim/horizon/consensus-security/ghostdag_sim.py --seeds 20 --out ghostdag_results_1bps.md`; `--bps 10 --k 124 --delays 0.35,0.67,2 --holds 10,30,60,90 --warm 60 --post 15` for the 10 bps grid |
| `sim/horizon/consensus-security/finality_horizon.py` | `with-lock.sh run nice -n 19 python3 sim/horizon/consensus-security/finality_horizon.py --out finality_horizon_results.md` (imports `sim/finality_v2.py`) |
| `sim/horizon/consensus-security/signalling.py` | `python3 sim/horizon/consensus-security/signalling.py --out signalling_results.md` |
| `sim/horizon/consensus-security/cost_model.py` | `python3 sim/horizon/consensus-security/cost_model.py --out cost_results.md` |
| results | `ghostdag_results_1bps.md` and `.json`, `ghostdag_results_10bps.md` and `.json`, `finality_horizon_results.md`, `signalling_results.md`, `cost_results.md` in the same directory |

View file

@ -0,0 +1,12 @@
# sim/horizon/consensus-security
Models behind `docs/analysis/horizon/consensus-security.md` and `docs/analysis/51-percent.md` (Horizon lane 1, 6 October 2026). Python 3.10 with numpy; run on the Mac under the main checkout's run lock: `/Users/joshm/Projects/igneum/tools/lock/with-lock.sh run nice -n 19 <cmd>`. Nothing here edits `sim/finality_v2.py`; `finality_horizon.py` imports it.
| file | what | run | output |
|---|---|---|---|
| `ghostdag_sim.py` | GHOSTDAG (as `protocol.rs`) under a withholding attacker: reorg depth, fork age, win rate, blue share, honest blocks turned red, by hash share H, delay d, hold T; 1 or 10 blocks/s | `python3 ghostdag_sim.py --seeds 20 --out ghostdag_results_1bps.md --json ghostdag_results_1bps.json`; `--bps 10 --k 124 --delays 0.35,0.67,2 --holds 10,30,60,90 --warm 60 --post 15` | `ghostdag_results_1bps.md/.json`, `ghostdag_results_10bps.md/.json` |
| `finality_horizon.py` | the finality rule over the adversary's share 20, 34, 51, 67, 90 percent: renter, silent set, bought keys, poisoned eclipse, partition with an equivocator (v2 and v3), abrupt departure (v2 and v3) | `python3 finality_horizon.py --out finality_horizon_results.md` (about 10 min; `--quick` for a smoke run) | `finality_horizon_results.md` |
| `signalling.py` | the P2 miner-signalling game: binomial noise, holdout, forced flip, signal then defect, window alternatives | `python3 signalling.py --out signalling_results.md` | `signalling_results.md` |
| `cost_model.py` | every attack's rented hash, duration, cost at 1 to 1,000 GH/s, subsidy earned, and the rental equilibrium | `python3 cost_model.py --out cost_results.md` | `cost_results.md` |
Price basis USD 11.7 per GH/s-hour, measured 6 October 2026 (`docs/bench-log.md`, "Rental cost of hash"). Seeds and machine for each result are in the result file's first line.

View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""What each attack costs in rented hash and what it earns: the table behind docs/analysis/51-percent.md.
Inputs (every one labelled):
* price: USD 11.7 per GH/s-hour, MEASURED 6 Oct 2026 on RunPod community pods (docs/bench-log.md, "Rental cost of hash,
6 October 2026": 1,748 MH/s for USD 20.44 an hour); approximate above 2 GH/s because the market supplied no more.
* subsidy: 31.688 IGN per DAA second at full ramp before the first halving (spec 02 2.5, BASE_SUBSIDY_PER_SECOND_SOMPI),
80% to the producer, 20% to the proving pool (the attacker earns the pool share only if it proves: not counted).
* IGN price inputs USD 0.005, 0.02, 0.10: the three assumptions of docs/analysis/security-budget.md, NOT predictions.
* durations: from the finality arithmetic (spec 03 3.1: 1/3 on day 10/A, 2/3 on day 20/A for an attacker producing
share A of blocks), the lock latency (spec 03 3.11.3, about 90 to 120 s), the finality depth (spec 02 2.1, 12 h), the
merge depth (3,600 s) and the P2 signalling window (one day). The DAG simulator of this directory gives the hash
share needed to win the lock-latency race (ghostdag_results_1bps.md section 2).
An attacker at share A of the TOTAL hash rents A/(1 - A) times the honest network N.
Run: python3 sim/horizon/consensus-security/cost_model.py [--out file.md]
"""
import argparse
import sys
PRICE = 11.7 # USD per GH/s-hour, measured
SUBSIDY_IGN_PER_S = 31.688 # spec 02 2.5
PRODUCER_SHARE = 0.8
IGN_PRICES = (0.005, 0.02, 0.10) # security-budget.md inputs
NETWORKS = (1, 10, 100, 1000) # GH/s
def ign_per_hour(A):
return PRODUCER_SHARE * A * SUBSIDY_IGN_PER_S * 3600.0
def md(headers, rows):
out = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)]
for r in rows:
out.append("| " + " | ".join(str(x) for x in r) + " |")
return "\n".join(out)
def usd(x):
if x >= 1e6:
return "USD %.1fM" % (x / 1e6)
if x >= 1e3:
return "USD %.0fk" % (x / 1e3)
return "USD %.0f" % x
ATTACKS = [
# (name, share A, hours, what it buys, bound)
("Reorder the last ~20 s (the k-block head start, any share)", 0.20, 30 / 3600.0, "an earlier-ordered conflicting tx inside the last k honest blocks; nothing a lock covers", "k = 18 blocks; the lock covers it"),
("Win the lock-latency race: reorg up to the unlocked checkpoint (90 s)", 0.51, 90 / 3600.0, "a double spend of a deposit credited before the lock (no exchange should)", "the lock, 90 to 120 s; DAG sim: 45% wins half, 34% never at 90 s"),
("PoW double spend at the 12-h finality depth during a PAUSE or the first 30 days", 0.51, 12.0, "a deposit credited at the proof-of-work depth the exchange guidance names", "finality depth 43,200 DAA; only while nothing locks"),
("Orphan an hour of honest blocks (beyond merge depth) during a pause", 0.51, 1.5, "honest blocks of the hour unmergeable, their subsidy lost; the 229-block shape of 6 Oct", "merge depth 3,600 s; refused by F1 once a lock exists inside the hour"),
("The veto: reach 1/3 of 30-day weight at 51% of blocks", 0.51, 20 * 24.0, "pause finality at will, for ever, at no further cost (silent weight keeps earning)", "20 days in public; 51% never reaches 2/3 while honest miners stay"),
("The veto at 67% of blocks", 0.67, 15 * 24.0, "the same, five days sooner", "day 15"),
("The veto at 90% of blocks", 0.90, 11.1 * 24.0, "the same", "day 11.1"),
("Lock alone: 2/3 of weight at 67% of blocks", 0.67, 30 * 24.0, "certify any chain forward of the last honest lock; never undo a certificate an honest node holds", "day 30; 3.11.4"),
("Lock alone at 90% of blocks", 0.90, 22.2 * 24.0, "the same", "day 22.2"),
("Long-range: a private DAG heavier than the public one over the window (cold-start F5)", 0.51, 30 * 24.0, "a cold node with no trusted certificate follows the private DAG", "F5: a configured certificate; the client-shipped checkpoint (proposal)"),
("Signalling holdout: 6% of blocks every day until the floor", 0.06, 24.0, "delay of a class change until N6", "the floor"),
("Forced flip: 95% of one day's blue blocks with a patched byte", 0.95, 24.0, "an activation while part of the fleet lacks the object (a v5 shape, not v4)", "the one-day window; 7 consecutive days proposed"),
]
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="")
args = ap.parse_args(argv)
out = ["# Attack cost in rented hash against what it earns", ""]
out.append("Generated by `sim/horizon/consensus-security/cost_model.py`. Price USD %.1f per GH/s-hour (measured 6 Oct 2026, bench-log); subsidy %.3f IGN/s, producer share %.0f%%; IGN price inputs USD %s (assumptions, security-budget.md). "
"An attacker at share A rents A/(1-A) x N. Earnings are the attacker's own block subsidy over the attack (it mines in public while it accumulates weight)." % (PRICE, SUBSIDY_IGN_PER_S, 100 * PRODUCER_SHARE, ", ".join("%g" % p for p in IGN_PRICES)))
out.append("")
out.append("## 1. Cost by network size")
out.append("")
rows = []
for name, A, hours, buys, bound in ATTACKS:
rented = A / (1 - A)
costs = [rented * N * hours * PRICE for N in NETWORKS]
rows.append([name, "%.0f%%" % (100 * A), "%.1f h" % hours if hours < 48 else "%.1f d" % (hours / 24), "%.2f N" % rented] + [usd(c) for c in costs] + [bound])
out.append(md(["attack", "share A", "duration", "rented hash"] + ["cost at %d GH/s" % N for N in NETWORKS] + ["what bounds it"], rows))
out.append("")
out.append("## 2. What the attacker earns meanwhile (its own blocks, 80% producer share), and the net at each price input")
out.append("")
rows = []
for name, A, hours, buys, bound in ATTACKS:
earn_ign = ign_per_hour(A) * hours
cells = []
for P in IGN_PRICES:
e = earn_ign * P
cells.append(usd(e))
rows.append([name, "%.0f M IGN" % (earn_ign / 1e6) if earn_ign >= 1e6 else "%.0f k IGN" % (earn_ign / 1e3)] + cells + [buys])
out.append(md(["attack", "subsidy earned"] + ["at USD %g" % P for P in IGN_PRICES] + ["what it buys"], rows))
out.append("")
out.append("## 3. The equilibrium network: where rented hash earns its rent")
out.append("")
out.append("If every GH/s is rented at the measured price, hash joins until the hourly producer subsidy equals the hourly rent: N_eq = 0.8 x 31.688 x 3600 x P / 11.7 GH/s. Attack costs at N_eq are multiples of the chain's own hourly income, so the table below is price-independent in shape.")
out.append("")
rows = []
for P in IGN_PRICES:
hourly = ign_per_hour(1.0) * P
neq = hourly / PRICE
veto = 0.51 / 0.49 * neq * 480 * PRICE
veto_earn = ign_per_hour(0.51) * 480 * P
alone = 2.0 * neq * 720 * PRICE
alone_earn = ign_per_hour(0.67) * 720 * P
ds = 0.51 / 0.49 * neq * 12 * PRICE
rows.append(["USD %g" % P, usd(hourly), "%.0f GH/s" % neq, usd(veto), usd(veto_earn), usd(veto - veto_earn), usd(alone), usd(alone_earn), usd(alone - alone_earn), usd(ds)])
out.append(md(["IGN price", "hourly producer subsidy", "N_eq", "veto: 51% for 20 d, rent", "earned", "net", "lock alone: 67% for 30 d, rent", "earned", "net", "12-h double spend in a pause, rent"], rows))
out.append("")
out.append("Reading: at the equilibrium the veto's net cost is about 48% of 20 days of the chain's subsidy (the attacker earns 51% of it back), and locking alone nets about 33% of 30 days of subsidy. Both are public for weeks. The 12-hour double spend during a pause costs about 12.5 hours of the chain's subsidy and is the cheapest line in the table: it is why the pause is the residual risk of docs/analysis/51-percent.md section 4.")
text = "\n".join(out)
if args.out:
with open(args.out, "w") as fh:
fh.write(text + "\n")
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,49 @@
# Attack cost in rented hash against what it earns
Generated by `sim/horizon/consensus-security/cost_model.py`. Price USD 11.7 per GH/s-hour (measured 6 Oct 2026, bench-log); subsidy 31.688 IGN/s, producer share 80%; IGN price inputs USD 0.005, 0.02, 0.1 (assumptions, security-budget.md). An attacker at share A rents A/(1-A) x N. Earnings are the attacker's own block subsidy over the attack (it mines in public while it accumulates weight).
## 1. Cost by network size
| attack | share A | duration | rented hash | cost at 1 GH/s | cost at 10 GH/s | cost at 100 GH/s | cost at 1000 GH/s | what bounds it |
|---|---|---|---|---|---|---|---|---|
| Reorder the last ~20 s (the k-block head start, any share) | 20% | 0.0 h | 0.25 N | USD 0 | USD 0 | USD 2 | USD 24 | k = 18 blocks; the lock covers it |
| Win the lock-latency race: reorg up to the unlocked checkpoint (90 s) | 51% | 0.0 h | 1.04 N | USD 0 | USD 3 | USD 30 | USD 304 | the lock, 90 to 120 s; DAG sim: 45% wins half, 34% never at 90 s |
| PoW double spend at the 12-h finality depth during a PAUSE or the first 30 days | 51% | 12.0 h | 1.04 N | USD 146 | USD 1k | USD 15k | USD 146k | finality depth 43,200 DAA; only while nothing locks |
| Orphan an hour of honest blocks (beyond merge depth) during a pause | 51% | 1.5 h | 1.04 N | USD 18 | USD 183 | USD 2k | USD 18k | merge depth 3,600 s; refused by F1 once a lock exists inside the hour |
| The veto: reach 1/3 of 30-day weight at 51% of blocks | 51% | 20.0 d | 1.04 N | USD 6k | USD 58k | USD 585k | USD 5.8M | 20 days in public; 51% never reaches 2/3 while honest miners stay |
| The veto at 67% of blocks | 67% | 15.0 d | 2.03 N | USD 9k | USD 86k | USD 855k | USD 8.6M | day 15 |
| The veto at 90% of blocks | 90% | 11.1 d | 9.00 N | USD 28k | USD 281k | USD 2.8M | USD 28.1M | day 11.1 |
| Lock alone: 2/3 of weight at 67% of blocks | 67% | 30.0 d | 2.03 N | USD 17k | USD 171k | USD 1.7M | USD 17.1M | day 30; 3.11.4 |
| Lock alone at 90% of blocks | 90% | 22.2 d | 9.00 N | USD 56k | USD 561k | USD 5.6M | USD 56.1M | day 22.2 |
| Long-range: a private DAG heavier than the public one over the window (cold-start F5) | 51% | 30.0 d | 1.04 N | USD 9k | USD 88k | USD 877k | USD 8.8M | F5: a configured certificate; the client-shipped checkpoint (proposal) |
| Signalling holdout: 6% of blocks every day until the floor | 6% | 24.0 h | 0.06 N | USD 18 | USD 179 | USD 2k | USD 18k | the floor |
| Forced flip: 95% of one day's blue blocks with a patched byte | 95% | 24.0 h | 19.00 N | USD 5k | USD 53k | USD 534k | USD 5.3M | the one-day window; 7 consecutive days proposed |
## 2. What the attacker earns meanwhile (its own blocks, 80% producer share), and the net at each price input
| attack | subsidy earned | at USD 0.005 | at USD 0.02 | at USD 0.1 | what it buys |
|---|---|---|---|---|---|
| Reorder the last ~20 s (the k-block head start, any share) | 0 k IGN | USD 1 | USD 3 | USD 15 | an earlier-ordered conflicting tx inside the last k honest blocks; nothing a lock covers |
| Win the lock-latency race: reorg up to the unlocked checkpoint (90 s) | 1 k IGN | USD 6 | USD 23 | USD 116 | a double spend of a deposit credited before the lock (no exchange should) |
| PoW double spend at the 12-h finality depth during a PAUSE or the first 30 days | 559 k IGN | USD 3k | USD 11k | USD 56k | a deposit credited at the proof-of-work depth the exchange guidance names |
| Orphan an hour of honest blocks (beyond merge depth) during a pause | 70 k IGN | USD 349 | USD 1k | USD 7k | honest blocks of the hour unmergeable, their subsidy lost; the 229-block shape of 6 Oct |
| The veto: reach 1/3 of 30-day weight at 51% of blocks | 22 M IGN | USD 112k | USD 447k | USD 2.2M | pause finality at will, for ever, at no further cost (silent weight keeps earning) |
| The veto at 67% of blocks | 22 M IGN | USD 110k | USD 440k | USD 2.2M | the same, five days sooner |
| The veto at 90% of blocks | 22 M IGN | USD 109k | USD 438k | USD 2.2M | the same |
| Lock alone: 2/3 of weight at 67% of blocks | 44 M IGN | USD 220k | USD 880k | USD 4.4M | certify any chain forward of the last honest lock; never undo a certificate an honest node holds |
| Lock alone at 90% of blocks | 44 M IGN | USD 219k | USD 875k | USD 4.4M | the same |
| Long-range: a private DAG heavier than the public one over the window (cold-start F5) | 34 M IGN | USD 168k | USD 670k | USD 3.4M | a cold node with no trusted certificate follows the private DAG |
| Signalling holdout: 6% of blocks every day until the floor | 131 k IGN | USD 657 | USD 3k | USD 13k | delay of a class change until N6 |
| Forced flip: 95% of one day's blue blocks with a patched byte | 2 M IGN | USD 10k | USD 42k | USD 208k | an activation while part of the fleet lacks the object (a v5 shape, not v4) |
## 3. The equilibrium network: where rented hash earns its rent
If every GH/s is rented at the measured price, hash joins until the hourly producer subsidy equals the hourly rent: N_eq = 0.8 x 31.688 x 3600 x P / 11.7 GH/s. Attack costs at N_eq are multiples of the chain's own hourly income, so the table below is price-independent in shape.
| IGN price | hourly producer subsidy | N_eq | veto: 51% for 20 d, rent | earned | net | lock alone: 67% for 30 d, rent | earned | net | 12-h double spend in a pause, rent |
|---|---|---|---|---|---|---|---|---|---|
| USD 0.005 | USD 456 | 39 GH/s | USD 228k | USD 112k | USD 116k | USD 657k | USD 220k | USD 437k | USD 6k |
| USD 0.02 | USD 2k | 156 GH/s | USD 912k | USD 447k | USD 465k | USD 2.6M | USD 880k | USD 1.7M | USD 23k |
| USD 0.1 | USD 9k | 780 GH/s | USD 4.6M | USD 2.2M | USD 2.3M | USD 13.1M | USD 4.4M | USD 8.7M | USD 114k |
Reading: at the equilibrium the veto's net cost is about 48% of 20 days of the chain's subsidy (the attacker earns 51% of it back), and locking alone nets about 33% of 30 days of subsidy. Both are public for weeks. The 12-hour double spend during a pause costs about 12.5 hours of the chain's subsidy and is the cheapest line in the table: it is why the pause is the residual risk of docs/analysis/51-percent.md section 4.

View file

@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Finality weight attacks swept over the adversary's share: 20, 34, 51, 67 and 90 percent.
Horizon lane consensus-security, 6 October 2026. This file IMPORTS sim/finality_v2.py unchanged (the model, its
assumptions and its limits are results_v2.md's: 1,000 Pareto keys, 3 regions, 2-s inter-region delay, 2.2% outage,
perfect retarget, no DAG, keys free) and adds six sweeps the brief asked for. The rule is the one as specified today:
floor 2/3 of total (O-3.15), cert reading, and for the partition and departure rows rule v3 (the frozen table, F21) beside
rule v2 with view-local weights. Nothing in finality_v2.py is edited.
Sweeps (A = the adversary's share):
R renter: a new key with hash share A of the network from a warm 30-day state, signing; the day it reaches 1/3 (veto)
and 2/3 (locks alone). Formula: share(t) = (t/30) A, so day 10/A and 20/A (results_v2.md B verified it to 0.04 points).
S silent: keys holding A of weight stop signing and keep mining for 1 and 6 h; the pause and the resume.
K bought keys: an attacker buys keys worth A of the window and mines at 30% of the network (results_v2.md K's shape);
peak share, veto days, stalls when it withholds votes.
E poisoned eclipse: an attacker holding A feeds a 20% pool a private fork for 2 h and signs both (results_v2.md F2, L3).
P partition: a 50/50 honest split for 150 min with an equivocator holding A reaching both sides, rule v2 (+local) and v3.
C abrupt departure (tonight's pause, 6 Oct 2026 18:42Z): keys holding A stop mining and signing at once; days to the
first lock under v2 and v3, 31 days.
Run (under the main checkout's run lock, about 10 minutes):
/Users/joshm/Projects/igneum/tools/lock/with-lock.sh run nice -n 19 python3 \
sim/horizon/consensus-security/finality_horizon.py --out sim/horizon/consensus-security/finality_horizon_results.md
Options: --seeds 7,11 --shares 0.2,0.34,0.51,0.67,0.9 --sweeps R,S,K,E,P,C --quick
"""
import argparse
import os
import sys
import time
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.abspath(os.path.join(HERE, "..", "..")))
import finality_v2 as f # noqa: E402
f.set_floor(1.0)
def run_renter(seed, share, days, p):
"""A fresh key arrives at day 0 with hash share `share` of the network and signs every checkpoint."""
sim, rng, _ = f.build_honest(p, seed)
att = int(sim.add_keys([0.0], [0], flaky=False)[0])
sim.warm_start()
sim.init_views(warm=True)
sim.run(f.SLOTS_PER_HOUR)
honest = sim.hash.sum()
sim.hash[att] = honest * share / (1.0 - share)
sim.set_uptime()
series = []
d13 = d23 = None
for day in range(1, days + 1):
sim.run(f.SLOTS_PER_DAY)
sh = sim.share([att])
series.append((day, sh))
if d13 is None and sh >= 1.0 / 3.0:
d13 = day
if d23 is None and sh >= 2.0 / 3.0:
d23 = day
recs = sim.recs()
return dict(series=series, d13=d13, d23=d23, stalls=f.stalls_between(recs, 0, sim.slot), conflicts=len(sim.conflicts))
def run_eclipse_share(seed, att_share, dur_h, p, pool=0.2, pre_min=60, post_h=3):
"""results_v2.md F2 with the attacker's share as a parameter: pool 20%, attacker att_share, honest rest."""
rng = np.random.default_rng(seed)
sim = f.Sim(p, rng, n_regions=5)
others = 1.0 - pool - att_share
h = f.pareto_hashrates(rng, f.N_HONEST - 1, total=others)
reg = f.assign_regions(h, f.GEOGRAPHY)
sim.add_keys(h, reg, flaky=True)
K = int(sim.add_keys([pool], [3], flaky=False)[0])
att = int(sim.add_keys([att_share], [4], flaky=False, equiv=True)[0])
sim.warm_start()
sim.init_views(warm=True)
sim.run(pre_min * 2)
t0 = sim.slot
sim.split([[0, 1, 2], [3, 4]])
sim.run(int(dur_h * f.SLOTS_PER_HOUR))
t_end = sim.slot
sim.heal()
sim.run(post_h * f.SLOTS_PER_HOUR)
recs = sim.recs()
res = dict(conflicts=len(sim.conflicts), att_share=sim.share([att]))
res["first_conflict_min"] = (min(c[1] for c in sim.conflicts) - t0) / 2.0 if sim.conflicts else None
res["ecl_locks"] = int(((recs[:, 2] == 2) & (recs[:, 3] >= 0) & (recs[:, 0] >= t0) & (recs[:, 0] < t_end)).sum())
res["hon_locks"] = int(((recs[:, 2] == 1) & (recs[:, 3] >= 0) & (recs[:, 0] >= t0) & (recs[:, 0] < t_end)).sum())
res["honest_stalls"] = f.stalls_between(recs, t0, t_end, sid=1)
res["post_stalls"] = f.stalls_between(recs, t_end, sim.slot)
return res
def fmt_day(x):
return "never" if x is None else "%.1f" % x
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--seeds", default="7,11")
ap.add_argument("--shares", default="0.2,0.34,0.51,0.67,0.9")
ap.add_argument("--sweeps", default="R,S,K,E,P,C")
ap.add_argument("--delay", type=float, default=2.0)
ap.add_argument("--quick", action="store_true")
ap.add_argument("--out", default="")
args = ap.parse_args(argv)
seeds = [int(s) for s in args.seeds.split(",")]
shares = [float(s) for s in args.shares.split(",")]
q = args.quick
t_all = time.time()
out = ["# Finality weight attacks over the adversary's share (20, 34, 51, 67, 90 percent)", ""]
out.append("Generated by `sim/horizon/consensus-security/finality_horizon.py` on %s, importing `sim/finality_v2.py` unchanged; seeds %s; rule %s; "
"inter-region delay %.1f s. Model assumptions and limits: `sim/results_v2.md` (no DAG, perfect retarget, keys free, 2.2%% outage)." % (
time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime()), ",".join(str(s) for s in seeds), f.rule_name(), args.delay))
out.append("")
sweeps = [s.strip().upper() for s in args.sweeps.split(",")]
if "R" in sweeps:
t = time.time()
days = 8 if q else 32
out.append("## R. Renter with hash share A from a warm 30-day state, signing every checkpoint (%d days, seed %d)" % (days, seeds[0]))
out.append("")
out.append("Formula (spec 03 3.1, results_v2.md B): share(t) = (t/30) A; the veto (1/3) on day 10/A, locking alone (2/3) on day 20/A, never when A < 2/3.")
out.append("")
rows = []
for A in shares:
r = run_renter(seeds[0], A, days, f.rule_p(args.delay))
s10 = next((sh for d_, sh in r["series"] if d_ == 10), None)
s20 = next((sh for d_, sh in r["series"] if d_ == 20), None)
rows.append(["%.0f%%" % (100 * A), "%.1f%% / %.1f%%" % (100 * (s10 or r["series"][-1][1]), 100 * min(1.0, 10 * A / 30)) if s10 else "-",
"%.1f%% / %.1f%%" % (100 * s20, 100 * min(1.0, 20 * A / 30)) if s20 else "-",
"%s / %s" % (fmt_day(r["d13"]), "%.1f" % (10 / A) if 10 / A <= days else "> %d" % days),
"%s / %s" % (fmt_day(r["d23"]), ("%.1f" % (20 / A)) if A >= 2 / 3 and 20 / A <= days else "never"),
r["stalls"], r["conflicts"]])
out.append(f.md_table(["attacker hash share A", "share day 10, sim / formula", "share day 20, sim / formula", "reaches 1/3 (veto), day sim / formula",
"reaches 2/3 (locks alone), day sim / formula", "stalled checkpoints", "conflicting locks"], rows))
out.append("")
print("R %.0f s" % (time.time() - t), file=sys.stderr)
if "S" in sweeps:
t = time.time()
hours = (1,) if q else (1, 6)
out.append("## S. Silent set holding A of weight stops signing and keeps mining, then resumes (seeds %s)" % ",".join(str(s) for s in seeds))
out.append("")
rows = []
for A in shares:
for h in hours:
rs = [f.run_silent_resume(sd, A, h, f.rule_p(args.delay)) for sd in seeds]
rows.append(["%.0f%%" % (100 * A), h, f.span(int(round(100 * r["got"])) for r in rs) + "%", f.span_min(r["first_lock"] for r in rs),
f.span(r["stalls"] for r in rs), f.span(int(round(100 * r["locked_share"])) for r in rs) + "%",
f.span((r["gap"] for r in rs), "%.0f"), f.span_min(r["resume"] for r in rs), f.span(r["conflicts"] for r in rs)])
out.append(f.md_table(["silent weight A", "hours", "picked", "first lock while silent, min", "stalled checkpoints", "locked while silent",
"longest gap, min", "first lock after resume, min", "conflicting locks"], rows))
out.append("")
out.append("Reading: at A >= 1/3 the pause lasts exactly as long as the silence (the floor needs two thirds of total signing), the first lock comes 0 minutes after the resume, and no row conflicts. A silent set that keeps mining keeps its subsidy, so the pause costs it nothing.")
out.append("")
print("S %.0f s" % (time.time() - t), file=sys.stderr)
if "K" in sweeps:
t = time.time()
days = 5 if q else 30
out.append("## K. Bought keys worth A of the window, attacker mining at 30%% of the network, %d days (seeds %s)" % (days, ",".join(str(s) for s in seeds)))
out.append("")
out.append("Formula (spec 03 3.11.5): share(t) = A (1 - t/30) + 0.30 t/30. Sellers keep their rigs under fresh keys. 'signs' = the buyer votes; 'silent' = it withholds votes (the pause attack with bought weight).")
out.append("")
rows = []
for A in shares:
for signs in (True, False):
rs = [f.run_acquired(sd, A, 0.30, signs, days, f.rule_p(args.delay)) for sd in seeds]
rows.append(["%.0f%%" % (100 * A), "signs" if signs else "silent", f.span(int(round(100 * r["got"])) for r in rs) + "%",
f.span(int(round(100 * r["max_share"])) for r in rs) + "%", f.span(int(round(100 * r["end_share"])) for r in rs) + "%",
"day %s to %s" % (f.span(r["above13"] for r in rs if r["above13"]) if any(r["above13"] for r in rs) else "-",
f.span(r["last13"] for r in rs if r["last13"]) if any(r["last13"] for r in rs) else "-") if any(r["above13"] for r in rs) else "never",
f.span(r["stalls"] for r in rs), f.span(r["conflicts"] for r in rs)])
out.append(f.md_table(["bought weight A", "buyer", "picked", "peak share", "share at day %d" % days, "holds at least 1/3 (veto)",
"stalled checkpoints of %d" % (2880 * days), "conflicting locks"], rows))
out.append("")
print("K %.0f s" % (time.time() - t), file=sys.stderr)
if "E" in sweeps:
t = time.time()
dur = 1 if q else 2
out.append("## E. Poisoned eclipse: an attacker holding A of weight feeds a 20%% pool a private fork for %d h and signs both sides (seeds %s)" % (dur, ",".join(str(s) for s in seeds)))
out.append("")
out.append("The eclipsed side holds 20% + A of total; the honest side 80% - A. Rule v2 (floor 2/3 of total) as results_v2.md L3; the floor binds whatever the presence window does. A = 90% is not run: the attacker alone is over two thirds and locks alone everywhere, the 20-day public event of spec 03 3.1.")
out.append("")
rows = []
for A in shares:
if A + 0.2 >= 0.999:
rows.append(["%.0f%%" % (100 * A), "%.0f%%" % (100 * (0.2 + A)), "not run (attacker alone over 2/3)", "-", "-", "-", "-"])
continue
rs = [run_eclipse_share(sd, A, dur, f.rule_p(args.delay)) for sd in seeds]
rows.append(["%.0f%%" % (100 * A), "%.0f%%" % (100 * (0.2 + A)), f.span(r["conflicts"] for r in rs), f.span_min(r["first_conflict_min"] for r in rs),
f.span(r["ecl_locks"] for r in rs), f.span(r["hon_locks"] for r in rs), f.span(r["post_stalls"] for r in rs)])
out.append(f.md_table(["attacker weight A", "eclipsed side holds", "conflicting locks", "first conflict, min", "locks on the eclipsed side (%d h)" % dur,
"locks on the honest side (%d h)" % dur, "stalls in 3 h after the heal"], rows))
out.append("")
print("E %.0f s" % (time.time() - t), file=sys.stderr)
if "P" in sweeps:
t = time.time()
dur = 60 if q else 150
out.append("## P. 50/50 honest partition for %d min with an equivocator holding A of total reaching both sides (seeds %s)" % (dur, ",".join(str(s) for s in seeds)))
out.append("")
out.append("Each side holds (1 - A)/2 + A of total: 60% at A = 20%, 67% at 34%, 75.5% at 51%, 83.5% at 67%, 95% at 90%. Two conflicting certificates need two thirds each, so A >= 1/3 is the bound (spec 03 3.11.2). v2 = the rule as specified with view-local weights; v3 = plus the frozen table (F21).")
out.append("")
rows = []
for A in shares:
for name, mk in (("v2", f.rule_v2_local), ("v3", f.rule_v3)):
rs = [f.run_partition2(sd, (0.5, 0.5), A, dur, mk(args.delay)) for sd in seeds]
rows.append(["%.0f%%" % (100 * A), "%.1f%%" % (100 * ((1 - A) / 2 + A)), name, f.span(r["conflicts"] for r in rs),
f.span_min(r["first_conflict_min"] for r in rs),
" / ".join(f.span_min(r["side_first_lock"][i] for r in rs) for i in range(2)),
"yes" if all(r["kept"] for r in rs) else "NO", f.span_min(r["post_first_lock_min"] for r in rs), f.span(r["post_stalls"] for r in rs)])
out.append(f.md_table(["equivocator A", "each side holds", "rule", "conflicting locks", "first conflict, min", "first lock per side, min",
"every pre-heal lock kept", "first lock after heal, min", "stalls in 3 h after"], rows))
out.append("")
print("P %.0f s" % (time.time() - t), file=sys.stderr)
if "C" in sweeps:
t = time.time()
days = 3 if q else 31
out.append("## C. Abrupt departure (tonight's pause): keys holding A of weight stop mining AND signing at once; survivors inherit the block supply (%d days, seeds %s)" % (days, ",".join(str(s) for s in seeds)))
out.append("")
out.append("v2 analytic: the survivors hold 1 - A (30 - t)/30 of the sliding table on day t and reach two thirds on day 30 (1 - 1/(3A)) (never for A <= 1/3). v3: the frozen table at the last certified checkpoint holds the departed keys until it expires one window after that checkpoint, so the first lock comes on day 30 whatever A (results_v2.md M4). On the devnet the window is 7,200 DAA (2 h), so divide the days by 360.")
out.append("")
rows = []
for A in shares:
for name, mk in (("v2", f.rule_v2_local), ("v3", f.rule_v3)):
rs = [f.run_churn(sd, A, days, mk(args.delay)) for sd in seeds]
pred = "never" if A <= 1 / 3 else "%.1f" % (30 * (1 - 1 / (3 * A)))
rows.append(["%.0f%%" % (100 * A), name, f.span(int(round(100 * r["got"])) for r in rs) + "%",
f.span_min([None if r["first_lock_days"] is None else r["first_lock_days"] for r in rs]).replace("never", "never in %d days" % days) if any(r["first_lock_days"] is None for r in rs) else f.span((r["first_lock_days"] for r in rs), "%.2f"),
pred if name == "v2" else ("never" if A <= 1 / 3 else "30.0"),
f.span(r["stalls"] for r in rs), f.span(int(round(100 * r["live_share"])) for r in rs) + "%", f.span(r["conflicts"] for r in rs)])
out.append(f.md_table(["departed weight A", "rule", "picked", "first lock after the departure, days", "analytic", "stalled checkpoints", "live share of total at the end", "conflicting locks"], rows))
out.append("")
print("C %.0f s" % (time.time() - t), file=sys.stderr)
out.append("(%.0f s in all)" % (time.time() - t_all))
text = "\n".join(out)
if args.out:
with open(args.out, "w") as fh:
fh.write(text + "\n")
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,97 @@
# Finality weight attacks over the adversary's share (20, 34, 51, 67, 90 percent)
Generated by `sim/horizon/consensus-security/finality_horizon.py` on 2026-10-06 19:42 UTC, importing `sim/finality_v2.py` unchanged; seeds 7,11; rule active/cert + floor 1.00 (a lock needs 66.7% of total); inter-region delay 2.0 s. Model assumptions and limits: `sim/results_v2.md` (no DAG, perfect retarget, keys free, 2.2% outage).
## R. Renter with hash share A from a warm 30-day state, signing every checkpoint (32 days, seed 7)
Formula (spec 03 3.1, results_v2.md B): share(t) = (t/30) A; the veto (1/3) on day 10/A, locking alone (2/3) on day 20/A, never when A < 2/3.
| attacker hash share A | share day 10, sim / formula | share day 20, sim / formula | reaches 1/3 (veto), day sim / formula | reaches 2/3 (locks alone), day sim / formula | stalled checkpoints | conflicting locks |
|---|---|---|---|---|---|---|
| 20% | 6.7% / 6.7% | 13.3% / 13.3% | never / > 32 | never / never | 0 | 0 |
| 34% | 11.3% / 11.3% | 22.6% / 22.7% | 30.0 / 29.4 | never / never | 0 | 0 |
| 51% | 17.0% / 17.0% | 34.0% / 34.0% | 20.0 / 19.6 | never / never | 0 | 0 |
| 67% | 22.4% / 22.3% | 44.7% / 44.7% | 15.0 / 14.9 | 30.0 / 29.9 | 0 | 0 |
| 90% | 30.0% / 30.0% | 60.0% / 60.0% | 12.0 / 11.1 | 23.0 / 22.2 | 0 | 0 |
## S. Silent set holding A of weight stops signing and keeps mining, then resumes (seeds 7,11)
| silent weight A | hours | picked | first lock while silent, min | stalled checkpoints | locked while silent | longest gap, min | first lock after resume, min | conflicting locks |
|---|---|---|---|---|---|---|---|---|
| 20% | 1 | 20% | 0 | 0 | 100% | 1 | 0 | 0 |
| 20% | 6 | 20% | 0 | 0 | 100% | 1 | 0 to 0 | 0 |
| 34% | 1 | 34% | never | 122 to 123 | 0% | 60 | 0 | 0 |
| 34% | 6 | 34% | never | 720 to 722 | 0% | 360 | 0 to 0 | 0 |
| 51% | 1 | 51% | never | 122 to 123 | 0% | 60 | 0 | 0 |
| 51% | 6 | 51% | never | 720 to 722 | 0% | 360 | 0 to 0 | 0 |
| 67% | 1 | 67% | never | 122 to 123 | 0% | 60 | 0 | 0 |
| 67% | 6 | 67% | never | 720 to 722 | 0% | 360 | 0 to 0 | 0 |
| 90% | 1 | 83 to 90% | never | 122 to 123 | 0% | 60 | 0 | 0 |
| 90% | 6 | 83 to 90% | never | 720 to 722 | 0% | 360 | 0 to 0 | 0 |
Reading: at A >= 1/3 the pause lasts exactly as long as the silence (the floor needs two thirds of total signing), the first lock comes 0 minutes after the resume, and no row conflicts. A silent set that keeps mining keeps its subsidy, so the pause costs it nothing.
## K. Bought keys worth A of the window, attacker mining at 30% of the network, 30 days (seeds 7,11)
Formula (spec 03 3.11.5): share(t) = A (1 - t/30) + 0.30 t/30. Sellers keep their rigs under fresh keys. 'signs' = the buyer votes; 'silent' = it withholds votes (the pause attack with bought weight).
| bought weight A | buyer | picked | peak share | share at day 30 | holds at least 1/3 (veto) | stalled checkpoints of 86400 | conflicting locks |
|---|---|---|---|---|---|---|---|
| 20% | signs | 20% | 30% | 30% | never | 0 | 0 |
| 20% | silent | 20% | 30% | 30% | never | 816 to 1045 | 0 |
| 34% | signs | 34% | 34% | 30% | day 1 to 6 | 0 | 0 |
| 34% | silent | 34% | 34% | 30% | day 1 to 6 | 36334 to 41588 | 0 |
| 51% | signs | 51% | 51% | 30% | day 1 to 24 | 0 | 0 |
| 51% | silent | 51% | 51% | 30% | day 1 to 24 | 74391 to 75691 | 0 |
| 67% | signs | 67% | 66% | 30% | day 1 to 26 | 0 | 0 |
| 67% | silent | 67% | 66% | 30% | day 1 to 26 | 79408 to 79841 | 0 |
| 90% | signs | 83 to 90% | 82 to 89% | 30% | day 1 to 27 | 0 | 0 |
| 90% | silent | 83 to 90% | 82 to 89% | 30% | day 1 to 27 | 81759 to 82557 | 0 |
## E. Poisoned eclipse: an attacker holding A of weight feeds a 20% pool a private fork for 2 h and signs both sides (seeds 7,11)
The eclipsed side holds 20% + A of total; the honest side 80% - A. Rule v2 (floor 2/3 of total) as results_v2.md L3; the floor binds whatever the presence window does. A = 90% is not run: the attacker alone is over two thirds and locks alone everywhere, the 20-day public event of spec 03 3.1.
| attacker weight A | eclipsed side holds | conflicting locks | first conflict, min | locks on the eclipsed side (2 h) | locks on the honest side (2 h) | stalls in 3 h after the heal |
|---|---|---|---|---|---|---|
| 20% | 40% | 0 | never | 0 | 141 to 149 | 0 |
| 34% | 54% | 0 | never | 0 | 108 to 110 | 0 |
| 51% | 71% | 67 to 70 | 2 | 172 to 173 | 67 to 70 | 0 |
| 67% | 87% | 30 to 32 | 4 to 5 | 208 to 209 | 30 to 32 | 0 |
| 90% | 110% | not run (attacker alone over 2/3) | - | - | - | - |
## P. 50/50 honest partition for 150 min with an equivocator holding A of total reaching both sides (seeds 7,11)
Each side holds (1 - A)/2 + A of total: 60% at A = 20%, 67% at 34%, 75.5% at 51%, 83.5% at 67%, 95% at 90%. Two conflicting certificates need two thirds each, so A >= 1/3 is the bound (spec 03 3.11.2). v2 = the rule as specified with view-local weights; v3 = plus the frozen table (F21).
| equivocator A | each side holds | rule | conflicting locks | first conflict, min | first lock per side, min | every pre-heal lock kept | first lock after heal, min | stalls in 3 h after |
|---|---|---|---|---|---|---|---|---|
| 20% | 60.0% | v2 | 0 | never | never / never | yes | 0 | 0 |
| 20% | 60.0% | v3 | 0 | never | never / never | yes | 0 | 0 |
| 34% | 67.0% | v2 | 21 to 70 | 14 to 77 | 11 to 48 / 0 to 76 | yes | 0 | 0 |
| 34% | 67.0% | v3 | 21 to 69 | 14 to 78 | 12 to 50 / 0 to 77 | yes | 0 | 0 |
| 51% | 75.5% | v2 | 299 to 300 | 0 | 0 / 0 | yes | 0 to 0 | 0 |
| 51% | 75.5% | v3 | 299 to 300 | 0 | 0 / 0 | yes | 0 to 0 | 0 |
| 67% | 83.5% | v2 | 301 | 0 | 0 / 0 | yes | 0 to 0 | 0 |
| 67% | 83.5% | v3 | 301 | 0 | 0 / 0 | yes | 0 to 0 | 0 |
| 90% | 95.0% | v2 | 293 to 300 | 0 | 0 to 0 / 0 to 0 | yes | 0 to 0 | 0 |
| 90% | 95.0% | v3 | 293 to 300 | 0 | 0 to 0 / 0 to 0 | yes | 0 to 0 | 0 |
## C. Abrupt departure (tonight's pause): keys holding A of weight stop mining AND signing at once; survivors inherit the block supply (31 days, seeds 7,11)
v2 analytic: the survivors hold 1 - A (30 - t)/30 of the sliding table on day t and reach two thirds on day 30 (1 - 1/(3A)) (never for A <= 1/3). v3: the frozen table at the last certified checkpoint holds the departed keys until it expires one window after that checkpoint, so the first lock comes on day 30 whatever A (results_v2.md M4). On the devnet the window is 7,200 DAA (2 h), so divide the days by 360.
| departed weight A | rule | picked | first lock after the departure, days | analytic | stalled checkpoints | live share of total at the end | conflicting locks |
|---|---|---|---|---|---|---|---|
| 20% | v2 | 20% | 0.00 | never | 39 to 213 | 100% | 0 |
| 20% | v3 | 20% | 0.00 | never | 39 to 213 | 100% | 0 |
| 34% | v2 | 34% | 0.77 to 0.85 | 0.6 | 4265 to 4727 | 100% | 0 |
| 34% | v3 | 34% | 30.00 | 30.0 | 86395 to 86472 | 100% | 0 |
| 51% | v2 | 51% | 10.46 to 10.52 | 10.4 | 31312 to 31739 | 100% | 0 |
| 51% | v3 | 51% | 30.00 | 30.0 | 86326 to 86336 | 100% | 0 |
| 67% | v2 | 67% | 15.15 to 15.24 | 15.1 | 44464 to 45047 | 100% | 0 |
| 67% | v3 | 67% | 30.00 | 30.0 | 86361 to 86411 | 100% | 0 |
| 90% | v2 | 83 to 90% | 17.92 to 18.88 | 18.9 | 51897 to 55628 | 100% | 0 |
| 90% | v3 | 83 to 90% | 30.00 to 30.00 | 30.0 | 86340 to 86481 | 100% | 0 |
(592 s in all)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,103 @@
# GHOSTDAG withholding simulator, 10 blocks/s, k 124, 8 honest miners, 20 seeds per cell
Generated by `sim/horizon/consensus-security/ghostdag_sim.py` on 2026-10-06 19:38 UTC. Every number is this simulator's; the model and its limits are in the file header.
## 1. Honest parallelism alone: natural selected-chain reorg depth at honest miner 0 (chain blocks), 75 s per episode
| one-way delay d, s | blocks in flight (bps x d) | reorg depth p99 (worst seed) | reorg depth max |
|---|---|---|---|
| 0.35 | 3.50 | 11 | 15 |
| 0.67 | 6.70 | 22 | 26 |
| 2 | 20.00 | 99 | 109 |
## 2. Withhold for T seconds, then release everything: what the honest selected chain does
`won` = the attacker's private tip became honest miner 0's selected chain after the release (its earlier-ordered conflicting transaction then precedes the honest copy). `reorg` = chain blocks removed from miner 0's selected chain (median and max over seeds). `fork age` = seconds between the fork point and the release: the window a conflicting transaction can reach back. `att blue` = the attacker's blue blocks over its blocks in miner 0's view (weight and subsidy kept); `hon red` = honest blocks since the fork turned red by the release.
| d, s | attacker share H | hold T, s | blocks withheld (mean) | won | reorg med / max | fork age if won, s (med) | att blue / att blocks | hon red / hon blocks |
|---|---|---|---|---|---|---|---|---|
| 0.35 | 20% | 10 | 20.4 | 25% | 1 / 33 | 10 | 99% | 2% |
| 0.35 | 20% | 30 | 60.3 | 0% | 1 / 3 | - | 41% | 1% |
| 0.35 | 20% | 60 | 118.0 | 0% | 0 / 11 | - | 18% | 0% |
| 0.35 | 20% | 90 | 176.7 | 0% | 1 / 6 | - | 14% | 0% |
| 0.35 | 34% | 10 | 38.0 | 90% | 26 / 30 | 10 | 99% | 1% |
| 0.35 | 34% | 30 | 104.4 | 85% | 77 / 84 | 30 | 92% | 23% |
| 0.35 | 34% | 60 | 202.9 | 0% | 0 / 2 | - | 32% | 1% |
| 0.35 | 34% | 90 | 304.9 | 0% | 1 / 3 | - | 13% | 0% |
| 0.35 | 45% | 10 | 44.5 | 95% | 23 / 29 | 10 | 99% | 2% |
| 0.35 | 45% | 30 | 132.3 | 95% | 71 / 79 | 30 | 96% | 19% |
| 0.35 | 45% | 60 | 267.9 | 95% | 139 / 152 | 60 | 95% | 49% |
| 0.35 | 45% | 90 | 407.1 | 95% | 208 / 225 | 90 | 95% | 62% |
| 0.35 | 51% | 10 | 53.8 | 100% | 23 / 27 | 10 | 99% | 2% |
| 0.35 | 51% | 30 | 153.8 | 100% | 66 / 74 | 30 | 100% | 19% |
| 0.35 | 51% | 60 | 304.5 | 100% | 132 / 144 | 60 | 100% | 51% |
| 0.35 | 51% | 90 | 458.8 | 100% | 199 / 217 | 90 | 100% | 65% |
| 0.35 | 67% | 10 | 69.3 | 100% | 17 / 22 | 10 | 100% | 2% |
| 0.35 | 67% | 30 | 203.1 | 100% | 52 / 62 | 30 | 100% | 29% |
| 0.35 | 67% | 60 | 399.1 | 100% | 104 / 116 | 60 | 100% | 57% |
| 0.35 | 67% | 90 | 602.6 | 100% | 158 / 174 | 90 | 100% | 69% |
| 0.35 | 90% | 10 | 93.8 | 100% | 7 / 11 | 10 | 100% | 2% |
| 0.35 | 90% | 30 | 273.8 | 100% | 24 / 29 | 30 | 100% | 38% |
| 0.35 | 90% | 60 | 539.6 | 100% | 46 / 54 | 60 | 100% | 63% |
| 0.35 | 90% | 90 | 810.1 | 100% | 70 / 80 | 90 | 100% | 74% |
| 0.67 | 20% | 10 | 20.4 | 45% | 10 / 33 | 11 | 97% | 2% |
| 0.67 | 20% | 30 | 60.3 | 0% | 3 / 13 | - | 47% | 2% |
| 0.67 | 20% | 60 | 118.0 | 0% | 3 / 16 | - | 18% | 1% |
| 0.67 | 20% | 90 | 176.7 | 0% | 3 / 20 | - | 13% | 1% |
| 0.67 | 34% | 10 | 38.0 | 100% | 20 / 32 | 11 | 99% | 2% |
| 0.67 | 34% | 30 | 104.4 | 95% | 59 / 64 | 31 | 98% | 27% |
| 0.67 | 34% | 60 | 202.9 | 0% | 2 / 11 | - | 33% | 2% |
| 0.67 | 34% | 90 | 304.9 | 0% | 2 / 12 | - | 13% | 1% |
| 0.67 | 45% | 10 | 44.5 | 100% | 17 / 22 | 10 | 99% | 2% |
| 0.67 | 45% | 30 | 132.3 | 100% | 52 / 58 | 30 | 99% | 23% |
| 0.67 | 45% | 60 | 267.9 | 100% | 102 / 111 | 60 | 100% | 53% |
| 0.67 | 45% | 90 | 407.1 | 100% | 154 / 166 | 90 | 100% | 66% |
| 0.67 | 51% | 10 | 53.8 | 100% | 16 / 26 | 11 | 99% | 3% |
| 0.67 | 51% | 30 | 153.8 | 100% | 50 / 58 | 31 | 99% | 21% |
| 0.67 | 51% | 60 | 304.5 | 100% | 100 / 109 | 61 | 100% | 53% |
| 0.67 | 51% | 90 | 458.8 | 100% | 146 / 163 | 91 | 100% | 66% |
| 0.67 | 67% | 10 | 69.3 | 100% | 13 / 16 | 10 | 100% | 2% |
| 0.67 | 67% | 30 | 203.1 | 100% | 39 / 47 | 30 | 100% | 32% |
| 0.67 | 67% | 60 | 399.1 | 100% | 78 / 89 | 60 | 100% | 59% |
| 0.67 | 67% | 90 | 602.6 | 100% | 118 / 132 | 90 | 100% | 71% |
| 0.67 | 90% | 10 | 93.8 | 100% | 6 / 9 | 10 | 100% | 5% |
| 0.67 | 90% | 30 | 273.8 | 100% | 19 / 23 | 30 | 100% | 41% |
| 0.67 | 90% | 60 | 539.6 | 100% | 38 / 44 | 60 | 100% | 64% |
| 0.67 | 90% | 90 | 810.1 | 100% | 58 / 66 | 90 | 100% | 76% |
| 2 | 20% | 10 | 20.4 | 90% | 29 / 97 | 21 | 96% | 5% |
| 2 | 20% | 30 | 60.3 | 0% | 17 / 75 | - | 62% | 7% |
| 2 | 20% | 60 | 118.0 | 0% | 16 / 100 | - | 16% | 1% |
| 2 | 20% | 90 | 176.7 | 0% | 32 / 69 | - | 12% | 1% |
| 2 | 34% | 10 | 38.0 | 100% | 16 / 29 | 14 | 98% | 4% |
| 2 | 34% | 30 | 104.4 | 85% | 35 / 51 | 32 | 94% | 32% |
| 2 | 34% | 60 | 202.9 | 0% | 17 / 90 | - | 36% | 5% |
| 2 | 34% | 90 | 304.9 | 0% | 14 / 52 | - | 11% | 1% |
| 2 | 45% | 10 | 44.5 | 100% | 13 / 78 | 12 | 98% | 7% |
| 2 | 45% | 30 | 132.3 | 100% | 32 / 44 | 31 | 99% | 31% |
| 2 | 45% | 60 | 267.9 | 100% | 62 / 77 | 61 | 99% | 59% |
| 2 | 45% | 90 | 407.1 | 90% | 92 / 101 | 91 | 93% | 63% |
| 2 | 51% | 10 | 53.8 | 100% | 10 / 16 | 11 | 99% | 6% |
| 2 | 51% | 30 | 153.8 | 100% | 26 / 33 | 31 | 99% | 32% |
| 2 | 51% | 60 | 304.5 | 100% | 57 / 66 | 61 | 99% | 59% |
| 2 | 51% | 90 | 458.8 | 100% | 85 / 98 | 91 | 100% | 71% |
| 2 | 67% | 10 | 69.3 | 100% | 6 / 13 | 10 | 99% | 7% |
| 2 | 67% | 30 | 203.1 | 100% | 22 / 28 | 30 | 99% | 41% |
| 2 | 67% | 60 | 399.1 | 100% | 44 / 52 | 60 | 100% | 65% |
| 2 | 67% | 90 | 602.6 | 100% | 68 / 75 | 90 | 100% | 76% |
| 2 | 90% | 10 | 93.8 | 100% | 3 / 5 | 10 | 100% | 17% |
| 2 | 90% | 30 | 273.8 | 100% | 12 / 14 | 30 | 100% | 53% |
| 2 | 90% | 60 | 539.6 | 100% | 24 / 27 | 60 | 100% | 71% |
| 2 | 90% | 90 | 810.1 | 100% | 37 / 40 | 90 | 100% | 81% |
## 3. Selfish withholding (release when about to lose, or at lead L = 6): blue share against hash share over 300 s, d = 0.67 s
| attacker share H | attacker blue blocks / its blocks (weight kept) | attacker share of ALL blue blocks (weight share; honest mining gives H) | private tip is the chain at the end | max reorg depth seen |
|---|---|---|---|---|
| 20% | 100% | 20.3% | 60% | 5 |
| 34% | 100% | 34.1% | 80% | 7 |
| 45% | 100% | 45.4% | 80% | 5 |
| 51% | 100% | 51.0% | 100% | 3 |
| 67% | 100% | 67.7% | 100% | 5 |
| 90% | 100% | 89.9% | 100% | 0 |
(81 cells, 223 s)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,128 @@
# GHOSTDAG withholding simulator, 1 blocks/s, k 18, 8 honest miners, 20 seeds per cell
Generated by `sim/horizon/consensus-security/ghostdag_sim.py` on 2026-10-06 19:37 UTC. Every number is this simulator's; the model and its limits are in the file header.
## 1. Honest parallelism alone: natural selected-chain reorg depth at honest miner 0 (chain blocks), 150 s per episode
| one-way delay d, s | blocks in flight (bps x d) | reorg depth p99 (worst seed) | reorg depth max |
|---|---|---|---|
| 0.35 | 0.35 | 2 | 3 |
| 0.67 | 0.67 | 2 | 2 |
| 2 | 2.00 | 6 | 7 |
| 5 | 5.00 | 15 | 15 |
## 2. Withhold for T seconds, then release everything: what the honest selected chain does
`won` = the attacker's private tip became honest miner 0's selected chain after the release (its earlier-ordered conflicting transaction then precedes the honest copy). `reorg` = chain blocks removed from miner 0's selected chain (median and max over seeds). `fork age` = seconds between the fork point and the release: the window a conflicting transaction can reach back. `att blue` = the attacker's blue blocks over its blocks in miner 0's view (weight and subsidy kept); `hon red` = honest blocks since the fork turned red by the release.
| d, s | attacker share H | hold T, s | blocks withheld (mean) | won | reorg med / max | fork age if won, s (med) | att blue / att blocks | hon red / hon blocks |
|---|---|---|---|---|---|---|---|---|
| 0.35 | 20% | 30 | 6.0 | 5% | 0 / 21 | 30 | 57% | 3% |
| 0.35 | 20% | 60 | 10.9 | 0% | 0 / 0 | - | 36% | 2% |
| 0.35 | 20% | 90 | 17.9 | 0% | 0 / 0 | - | 22% | 1% |
| 0.35 | 20% | 120 | 23.4 | 0% | 0 / 1 | - | 18% | 1% |
| 0.35 | 34% | 30 | 11.4 | 40% | 0 / 19 | 30 | 73% | 3% |
| 0.35 | 34% | 60 | 21.8 | 40% | 0 / 38 | 61 | 55% | 13% |
| 0.35 | 34% | 90 | 31.4 | 5% | 0 / 42 | 95 | 36% | 3% |
| 0.35 | 34% | 120 | 43.0 | 10% | 0 / 58 | 124 | 28% | 6% |
| 0.35 | 45% | 30 | 14.1 | 75% | 12 / 18 | 31 | 93% | 5% |
| 0.35 | 45% | 60 | 26.8 | 80% | 24 / 34 | 61 | 87% | 25% |
| 0.35 | 45% | 90 | 40.4 | 75% | 39 / 48 | 91 | 84% | 36% |
| 0.35 | 45% | 120 | 53.8 | 75% | 52 / 61 | 121 | 84% | 44% |
| 0.35 | 51% | 30 | 15.6 | 80% | 11 / 16 | 32 | 93% | 5% |
| 0.35 | 51% | 60 | 29.9 | 80% | 22 / 30 | 62 | 86% | 26% |
| 0.35 | 51% | 90 | 44.4 | 75% | 34 / 46 | 91 | 84% | 36% |
| 0.35 | 51% | 120 | 59.7 | 75% | 46 / 57 | 122 | 81% | 43% |
| 0.35 | 67% | 30 | 21.9 | 100% | 10 / 15 | 32 | 98% | 10% |
| 0.35 | 67% | 60 | 40.4 | 100% | 17 / 26 | 62 | 98% | 39% |
| 0.35 | 67% | 90 | 59.4 | 100% | 28 / 35 | 92 | 99% | 55% |
| 0.35 | 67% | 120 | 79.8 | 100% | 37 / 45 | 122 | 99% | 63% |
| 0.35 | 90% | 30 | 28.7 | 100% | 3 / 4 | 32 | 98% | 18% |
| 0.35 | 90% | 60 | 54.0 | 100% | 5 / 9 | 62 | 99% | 49% |
| 0.35 | 90% | 90 | 81.0 | 100% | 7 / 13 | 92 | 99% | 59% |
| 0.35 | 90% | 120 | 106.5 | 100% | 10 / 18 | 122 | 99% | 72% |
| 0.67 | 20% | 30 | 6.0 | 10% | 0 / 18 | 33 | 65% | 4% |
| 0.67 | 20% | 60 | 10.9 | 0% | 0 / 1 | - | 38% | 2% |
| 0.67 | 20% | 90 | 17.9 | 0% | 0 / 1 | - | 21% | 1% |
| 0.67 | 20% | 120 | 23.4 | 0% | 0 / 1 | - | 18% | 1% |
| 0.67 | 34% | 30 | 11.4 | 40% | 0 / 18 | 30 | 77% | 4% |
| 0.67 | 34% | 60 | 21.8 | 40% | 0 / 36 | 61 | 55% | 14% |
| 0.67 | 34% | 90 | 31.4 | 5% | 0 / 39 | 95 | 35% | 4% |
| 0.67 | 34% | 120 | 43.0 | 10% | 0 / 52 | 124 | 28% | 6% |
| 0.67 | 45% | 30 | 14.1 | 70% | 10 / 17 | 31 | 94% | 5% |
| 0.67 | 45% | 60 | 26.8 | 75% | 22 / 32 | 61 | 84% | 25% |
| 0.67 | 45% | 90 | 40.4 | 70% | 33 / 46 | 91 | 80% | 34% |
| 0.67 | 45% | 120 | 53.8 | 70% | 46 / 57 | 121 | 80% | 42% |
| 0.67 | 51% | 30 | 15.6 | 85% | 10 / 15 | 32 | 90% | 6% |
| 0.67 | 51% | 60 | 29.9 | 85% | 20 / 28 | 63 | 88% | 28% |
| 0.67 | 51% | 90 | 44.4 | 80% | 32 / 44 | 92 | 86% | 39% |
| 0.67 | 51% | 120 | 59.7 | 80% | 42 / 53 | 122 | 84% | 46% |
| 0.67 | 67% | 30 | 21.9 | 100% | 8 / 13 | 32 | 98% | 12% |
| 0.67 | 67% | 60 | 40.4 | 100% | 16 / 23 | 62 | 98% | 40% |
| 0.67 | 67% | 90 | 59.4 | 100% | 26 / 31 | 92 | 99% | 56% |
| 0.67 | 67% | 120 | 79.8 | 100% | 34 / 41 | 122 | 99% | 64% |
| 0.67 | 90% | 30 | 28.7 | 100% | 2 / 4 | 32 | 98% | 19% |
| 0.67 | 90% | 60 | 54.0 | 100% | 4 / 9 | 62 | 99% | 51% |
| 0.67 | 90% | 90 | 81.0 | 100% | 6 / 13 | 92 | 99% | 59% |
| 0.67 | 90% | 120 | 106.5 | 100% | 9 / 17 | 122 | 99% | 73% |
| 2 | 20% | 30 | 6.0 | 10% | 0 / 12 | 34 | 69% | 8% |
| 2 | 20% | 60 | 10.9 | 0% | 0 / 2 | - | 35% | 3% |
| 2 | 20% | 90 | 17.9 | 0% | 0 / 3 | - | 19% | 2% |
| 2 | 20% | 120 | 23.4 | 0% | 0 / 3 | - | 17% | 2% |
| 2 | 34% | 30 | 11.4 | 70% | 8 / 12 | 33 | 86% | 11% |
| 2 | 34% | 60 | 21.8 | 55% | 15 / 23 | 64 | 69% | 21% |
| 2 | 34% | 90 | 31.4 | 5% | 0 / 29 | 95 | 40% | 5% |
| 2 | 34% | 120 | 43.0 | 10% | 0 / 38 | 124 | 27% | 7% |
| 2 | 45% | 30 | 14.1 | 90% | 9 / 15 | 32 | 95% | 13% |
| 2 | 45% | 60 | 26.8 | 85% | 16 / 23 | 62 | 90% | 32% |
| 2 | 45% | 90 | 40.4 | 80% | 26 / 31 | 92 | 88% | 42% |
| 2 | 45% | 120 | 53.8 | 65% | 32 / 39 | 123 | 79% | 42% |
| 2 | 51% | 30 | 15.6 | 100% | 9 / 13 | 33 | 98% | 14% |
| 2 | 51% | 60 | 29.9 | 100% | 18 / 23 | 63 | 98% | 40% |
| 2 | 51% | 90 | 44.4 | 95% | 25 / 33 | 93 | 97% | 52% |
| 2 | 51% | 120 | 59.7 | 90% | 32 / 41 | 123 | 93% | 57% |
| 2 | 67% | 30 | 21.9 | 100% | 7 / 9 | 33 | 98% | 21% |
| 2 | 67% | 60 | 40.4 | 100% | 12 / 16 | 63 | 98% | 49% |
| 2 | 67% | 90 | 59.4 | 100% | 20 / 23 | 93 | 99% | 62% |
| 2 | 67% | 120 | 79.8 | 100% | 26 / 30 | 123 | 99% | 70% |
| 2 | 90% | 30 | 28.7 | 100% | 2 / 4 | 32 | 98% | 29% |
| 2 | 90% | 60 | 54.0 | 100% | 4 / 8 | 62 | 99% | 58% |
| 2 | 90% | 90 | 81.0 | 100% | 6 / 13 | 92 | 99% | 61% |
| 2 | 90% | 120 | 106.5 | 100% | 9 / 16 | 122 | 99% | 76% |
| 5 | 20% | 30 | 6.0 | 15% | 2 / 14 | 36 | 67% | 15% |
| 5 | 20% | 60 | 10.9 | 0% | 2 / 8 | - | 32% | 5% |
| 5 | 20% | 90 | 17.9 | 0% | 1 / 5 | - | 19% | 4% |
| 5 | 20% | 120 | 23.4 | 0% | 2 / 5 | - | 14% | 3% |
| 5 | 34% | 30 | 11.4 | 100% | 7 / 13 | 35 | 96% | 31% |
| 5 | 34% | 60 | 21.8 | 35% | 10 / 18 | 68 | 67% | 22% |
| 5 | 34% | 90 | 31.4 | 5% | 1 / 22 | 104 | 42% | 11% |
| 5 | 34% | 120 | 43.0 | 5% | 2 / 29 | 123 | 23% | 9% |
| 5 | 45% | 30 | 14.1 | 100% | 5 / 9 | 35 | 96% | 28% |
| 5 | 45% | 60 | 26.8 | 95% | 10 / 14 | 64 | 94% | 47% |
| 5 | 45% | 90 | 40.4 | 85% | 16 / 19 | 94 | 91% | 54% |
| 5 | 45% | 120 | 53.8 | 65% | 20 / 25 | 124 | 78% | 49% |
| 5 | 51% | 30 | 15.6 | 100% | 6 / 9 | 34 | 96% | 33% |
| 5 | 51% | 60 | 29.9 | 95% | 10 / 15 | 64 | 96% | 51% |
| 5 | 51% | 90 | 44.4 | 95% | 16 / 22 | 94 | 97% | 61% |
| 5 | 51% | 120 | 59.7 | 90% | 20 / 28 | 124 | 93% | 65% |
| 5 | 67% | 30 | 21.9 | 100% | 4 / 7 | 34 | 97% | 39% |
| 5 | 67% | 60 | 40.4 | 100% | 9 / 12 | 64 | 98% | 61% |
| 5 | 67% | 90 | 59.4 | 100% | 13 / 16 | 94 | 99% | 73% |
| 5 | 67% | 120 | 79.8 | 100% | 18 / 22 | 123 | 99% | 77% |
| 5 | 90% | 30 | 28.7 | 100% | 2 / 3 | 32 | 98% | 52% |
| 5 | 90% | 60 | 54.0 | 100% | 4 / 6 | 62 | 99% | 66% |
| 5 | 90% | 90 | 81.0 | 100% | 5 / 10 | 92 | 99% | 74% |
| 5 | 90% | 120 | 106.5 | 100% | 8 / 14 | 122 | 99% | 83% |
## 3. Selfish withholding (release when about to lose, or at lead L = 6): blue share against hash share over 600 s, d = 0.67 s
| attacker share H | attacker blue blocks / its blocks (weight kept) | attacker share of ALL blue blocks (weight share; honest mining gives H) | private tip is the chain at the end | max reorg depth seen |
|---|---|---|---|---|
| 20% | 100% | 19.3% | 0% | 1 |
| 34% | 100% | 34.3% | 60% | 1 |
| 45% | 100% | 44.9% | 60% | 2 |
| 51% | 100% | 51.1% | 80% | 2 |
| 67% | 100% | 67.2% | 100% | 3 |
| 90% | 100% | 90.3% | 100% | 1 |
(106 cells, 11 s)

View file

@ -0,0 +1,440 @@
#!/usr/bin/env python3
"""GHOSTDAG ordering under a withholding attacker: an abstract DAG simulator for the Horizon consensus-security lane.
What it models (6 October 2026):
* Blocks arrive as a Poisson process at rate `bps` (1 or 10 blocks per DAA second); every block has equal work.
* `honest` equal miners publish at once; every other miner sees a published block `d` seconds later (one uniform
one-way propagation delay, the cloud devnet measured p50 0.34 s, p99 0.67 s, max 2.3 s at 1 block/s:
docs/fud-ledger.md F7; Kaspa's k table assumes a 5 s bound: docs/spec/02-consensus.md 2.1).
* One attacker with share H of the hash rate mines on its own private view (its blocks plus the honest blocks it has
seen) and WITHHOLDS its blocks. Two strategies: `hold T` releases everything after T seconds; `selfish L` releases
when its private tip is about to lose the blue-work race or when its lead reaches L blocks.
* GHOSTDAG exactly as the fork runs it (vendor/igneum-node/consensus/src/processes/ghostdag/protocol.rs: selected
parent = max blue work, mergeset in topological order, the two k-cluster conditions through blues_anticone_sizes,
blue work = selected parent's plus the mergeset blues' work). Parents capped at 10 (spec 02 2.1).
* Finality is NOT in the loop. The output says what the ordering layer alone does; the lock of spec 03 (determined
d = 60 blue score after the checkpoint, locked about 3 s later: spec 03 C1, 3.11.3) is then read against it.
What it reports, from the view of honest miner 0:
* the selected-chain reorg depth (chain blocks removed) when the withheld blocks arrive, and the age in seconds of
the fork point: the window in which an earlier-ordered conflicting transaction in the attacker's first private
block would be executed before the honest copy (spec 07: a segment is the mergeset in GHOSTDAG order, the chain
block last, and the EVM nonce rule skips the later copy);
* whether the attacker's private tip became the honest selected chain ("won");
* the attacker's share of blue blocks against its share of blocks (vote weight is blue blocks, spec 03 W2; a red
block's subsidy goes to the honest merger, spec 02 2.5), and the honest blocks turned red by the release.
Not modelled: difficulty (equal work per block), timestamps, mergeset size limit 180, bodies and mass, the
certificate-driven fork choice. Honest parallelism is the only source of natural reorgs.
Run (about 2 minutes at the default grid; the lock is the main checkout's):
/Users/joshm/Projects/igneum/tools/lock/with-lock.sh run nice -n 19 python3 \
sim/horizon/consensus-security/ghostdag_sim.py --out sim/horizon/consensus-security/ghostdag_results.md
Options: --bps 1 --k 18 --seeds 20 --delays 0.35,0.67,2,5 --shares 0.2,0.34,0.45,0.51,0.67,0.9 --holds 30,60,90,120
"""
import argparse
import heapq
import json
import sys
import time
import numpy as np
MAX_PARENTS = 10
class DAG:
"""GHOSTDAG data per block, mirroring protocol.rs. Block ids are dense integers; `past` is a bitset of ancestors."""
def __init__(self, k, rng):
self.k = k
self.rng = rng
self.parents = [()]
self.past = [0]
self.sp = [-1]
self.blues = [[]] # mergeset blues, selected parent first (genesis: none)
self.reds = [[]]
self.bas = [{}] # blues_anticone_sizes
self.blue_score = [0]
self.blue_work = [0]
self.hash = [0] # a random 62-bit tiebreak standing in for the block hash
self.time = [0.0]
self.miner = [-1]
def n(self):
return len(self.parents)
def is_ancestor(self, a, b):
"""a in past(b)"""
return (self.past[b] >> a) & 1
def key(self, b):
return (self.blue_work[b], self.hash[b])
def add(self, parents, t, miner):
parents = tuple(parents)
idx = len(self.parents)
past = 0
for p in parents:
past |= self.past[p] | (1 << p)
sp = max(parents, key=self.key)
# mergeset without the selected parent: past(new) minus past(sp) minus sp, in topological order
x = past & ~self.past[sp] & ~(1 << sp)
ms = []
while x:
lsb = x & -x
ms.append(lsb.bit_length() - 1)
x ^= lsb
ms.sort(key=self.key) # blue work is strictly increasing along ancestry, so this is a topological order
blues = [sp]
bas = {sp: 0}
reds = []
k = self.k
for c in ms:
res = self._check_blue(sp, blues, bas, c, k)
if res is None:
reds.append(c)
else:
size, sizes = res
blues.append(c)
bas[c] = size
for peer, s in sizes.items():
bas[peer] = s + 1
self.parents.append(parents)
self.past.append(past)
self.sp.append(sp)
self.blues.append(blues)
self.reds.append(reds)
self.bas.append(bas)
self.blue_score.append(self.blue_score[sp] + len(blues))
self.blue_work.append(self.blue_work[sp] + len(blues))
self.hash.append(int(self.rng.integers(0, 1 << 62)))
self.time.append(t)
self.miner.append(miner)
return idx
def _blue_anticone_size(self, peer, new_sp, new_bas):
if peer in new_bas:
return new_bas[peer]
cur = new_sp
while True:
b = self.bas[cur]
if peer in b:
return b[peer]
cur = self.sp[cur]
if cur < 0:
raise AssertionError("blue block not found on the selected chain")
def _check_blue(self, new_sp, new_blues, new_bas, cand, k):
"""None = red; (blue anticone size, affected sizes) = blue. protocol.rs check_blue_candidate."""
if len(new_blues) == k + 1:
return None
sizes = {}
size = 0
chain = None # None stands for the new block itself
while True:
if chain is not None and self.is_ancestor(chain, cand):
return size, sizes
peers = new_blues if chain is None else self.blues[chain]
for peer in peers:
if peer == cand or self.is_ancestor(peer, cand):
continue
ps = self._blue_anticone_size(peer, new_sp, new_bas)
sizes[peer] = ps
size += 1
if size > k:
return None
if ps == k:
return None
chain = new_sp if chain is None else self.sp[chain]
if chain < 0:
return size, sizes
def chain(self, tip):
out = []
while tip >= 0:
out.append(tip)
tip = self.sp[tip]
return out
def removed_between(self, old_tip, new_tip):
"""Chain blocks of the old selected chain not on the new one (the reorg depth), and the fork point."""
a, b, steps = old_tip, new_tip, 0
while a != b:
if self.blue_score[a] >= self.blue_score[b] and a != 0:
a = self.sp[a]
steps += 1
else:
b = self.sp[b]
return steps, a
def blue_set(self, tip):
"""Every blue block in the past of `tip` plus tip's own chain blues (walk the selected chain)."""
s = 0
cur = tip
while cur >= 0:
for b in self.blues[cur]:
s |= 1 << b
cur = self.sp[cur]
return s
class View:
def __init__(self):
self.known = 1 # genesis
self.tips = {0}
self.pending = {}
def knows(self, b):
return (self.known >> b) & 1
def add(self, dag, b):
if self.knows(b):
return False
if any(not self.knows(p) for p in dag.parents[b]):
self.pending[b] = True
return False
self.known |= 1 << b
for p in dag.parents[b]:
self.tips.discard(p)
self.tips.add(b)
# retry pending children
for c in [c for c in self.pending if all(self.knows(p) for p in dag.parents[c])]:
del self.pending[c]
self.add(dag, c)
return True
def parents(self, dag):
tips = sorted(self.tips, key=dag.key, reverse=True)
return tips[:MAX_PARENTS]
def virtual(self, dag):
return max(self.tips, key=dag.key)
def episode(bps, k, d, H, strategy, hold, lead, honest_n, warm_s, post_s, seed):
rng = np.random.default_rng(seed)
dag = DAG(k, rng)
n_miners = honest_n + 1
ATT = honest_n
shares = [(1.0 - H) / honest_n] * honest_n + [H]
views = [View() for _ in range(n_miners)]
q = []
seq = 0
for i in range(n_miners):
if shares[i] <= 0:
continue
heapq.heappush(q, (float(rng.exponential(1.0 / (bps * shares[i]))), seq, "mine", i, -1))
seq += 1
t_attack = warm_s
t_release = None
withheld = []
honest_tip_at_fork = None
ref_tip = 0
max_reorg = 0
fork_age = 0.0
won = False
natural = [] # reorg depths before the attack (honest parallelism only)
t_end = warm_s + (hold if strategy == "hold" and H > 0 else (post_s if strategy == "selfish" else 0.0)) + post_s
attack_blocks = []
honest_after = []
t = 0.0
tip_at_release = None
def publish(b, t_now, frm):
nonlocal seq
for j in range(n_miners):
if j == frm:
continue
heapq.heappush(q, (t_now + d, seq, "deliver", j, b))
seq += 1
while q:
t, _, kind, who, payload = heapq.heappop(q)
if t > t_end:
break
if kind == "mine":
v = views[who]
if who == ATT and t >= t_attack and t_release is None and withheld:
# the private chain: the own tip first, honest tips only while they are lighter than it, so the
# attacker's block keeps the attacker's tip as its selected parent (GHOSTDAG picks the heaviest parent)
own = withheld[-1]
others = [h for h in sorted(v.tips, key=dag.key, reverse=True) if h != own and dag.key(h) < dag.key(own)]
parents = [own] + others[:MAX_PARENTS - 1]
else:
parents = v.parents(dag)
b = dag.add(parents, t, who)
v.add(dag, b)
heapq.heappush(q, (t + float(rng.exponential(1.0 / (bps * shares[who]))), seq, "mine", who, -1))
seq += 1
if who == ATT and t >= t_attack and t_release is None:
withheld.append(b)
attack_blocks.append(b)
if honest_tip_at_fork is None:
honest_tip_at_fork = views[0].virtual(dag)
if strategy == "selfish":
if len(withheld) >= lead:
t_release = t
tip_at_release = views[0].virtual(dag)
for w in withheld:
publish(w, t, ATT)
else:
if who != ATT and t >= t_attack:
honest_after.append(b)
publish(b, t, who)
if who == ATT and t_release is not None:
attack_blocks.append(b)
if strategy == "hold" and t_release is None and t >= t_attack + hold and withheld:
t_release = t
tip_at_release = views[0].virtual(dag)
for w in withheld:
publish(w, t, ATT)
else:
v = views[who]
if v.add(dag, payload) and who == 0:
new_tip = v.virtual(dag)
if new_tip != ref_tip:
if t < t_attack:
steps, fork = dag.removed_between(ref_tip, new_tip)
natural.append(steps)
elif tip_at_release is not None:
# the reorg is measured against the chain miner 0 held when the attacker released
steps, fork = dag.removed_between(tip_at_release, new_tip)
if steps > max_reorg:
max_reorg = steps
fork_age = t_release - dag.time[fork]
ref_tip = new_tip
if who == ATT and t_release is None and strategy == "selfish" and withheld and t >= t_attack:
# about to lose the race: release when the honest tip's blue work reaches ours minus one
h_tip = max((x for x in views[ATT].tips if dag.miner[x] != ATT), key=dag.key, default=None)
a_tip = views[ATT].virtual(dag)
if h_tip is not None and dag.blue_work[h_tip] >= dag.blue_work[a_tip] - 1:
t_release = t
tip_at_release = views[0].virtual(dag)
for w in withheld:
publish(w, t, ATT)
# outcome from honest miner 0's final view
v0 = views[0]
tip = v0.virtual(dag)
chain_set = set(dag.chain(tip))
if withheld:
won = withheld[-1] in chain_set
blues = dag.blue_set(tip)
known = v0.known
att_known = [b for b in attack_blocks if (known >> b) & 1]
att_blue = sum(1 for b in att_known if (blues >> b) & 1)
hon_known = [b for b in honest_after if (known >> b) & 1]
hon_red = sum(1 for b in hon_known if not (blues >> b) & 1)
hon_blue = len(hon_known) - hon_red
return dict(reorg=max_reorg, fork_age=fork_age, won=bool(won), withheld=len(withheld), att_blocks=len(att_known),
att_blue=att_blue, hon_blocks=len(hon_known), hon_red=hon_red, hon_blue=hon_blue,
natural_p99=float(np.percentile(natural, 99)) if natural else 0.0,
natural_max=max(natural) if natural else 0, release_s=(t_release - t_attack) if t_release else None)
def md(headers, rows):
out = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)]
for r in rows:
out.append("| " + " | ".join(str(x) for x in r) + " |")
return "\n".join(out)
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--bps", type=float, default=1.0)
ap.add_argument("--k", type=int, default=18)
ap.add_argument("--seeds", type=int, default=20)
ap.add_argument("--delays", default="0.35,0.67,2,5")
ap.add_argument("--shares", default="0.2,0.34,0.45,0.51,0.67,0.9")
ap.add_argument("--holds", default="30,60,90,120")
ap.add_argument("--honest", type=int, default=8)
ap.add_argument("--warm", type=float, default=120.0)
ap.add_argument("--post", type=float, default=30.0)
ap.add_argument("--selfish-run", type=float, default=600.0, help="seconds of the selfish-lead strategy per episode")
ap.add_argument("--out", default="")
ap.add_argument("--json", default="")
args = ap.parse_args(argv)
delays = [float(x) for x in args.delays.split(",")]
shares = [float(x) for x in args.shares.split(",")]
holds = [float(x) for x in args.holds.split(",")]
t0 = time.time()
out = ["# GHOSTDAG withholding simulator, %s blocks/s, k %d, %d honest miners, %d seeds per cell" % (
("%g" % args.bps), args.k, args.honest, args.seeds), ""]
out.append("Generated by `sim/horizon/consensus-security/ghostdag_sim.py` on %s. Every number is this simulator's; the model and its limits are in the file header." % time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime()))
out.append("")
results = {"bps": args.bps, "k": args.k, "cells": []}
# 1. honest-only baseline per delay
out.append("## 1. Honest parallelism alone: natural selected-chain reorg depth at honest miner 0 (chain blocks), %g s per episode" % (args.warm + args.post))
out.append("")
rows = []
for d in delays:
eps = [episode(args.bps, args.k, d, 0.0, "hold", 0.0, 0, args.honest, args.warm + args.post, 0.0, 1000 + s) for s in range(args.seeds)]
p99 = max(e["natural_p99"] for e in eps)
mx = max(e["natural_max"] for e in eps)
rows.append(["%g" % d, "%.2f" % (args.bps * d), "%.0f" % p99, mx])
results["cells"].append(dict(kind="baseline", d=d, p99=p99, max=mx))
out.append(md(["one-way delay d, s", "blocks in flight (bps x d)", "reorg depth p99 (worst seed)", "reorg depth max"], rows))
out.append("")
# 2. hold T then release
out.append("## 2. Withhold for T seconds, then release everything: what the honest selected chain does")
out.append("")
out.append("`won` = the attacker's private tip became honest miner 0's selected chain after the release (its earlier-ordered conflicting transaction then precedes the honest copy). `reorg` = chain blocks removed from miner 0's selected chain (median and max over seeds). `fork age` = seconds between the fork point and the release: the window a conflicting transaction can reach back. `att blue` = the attacker's blue blocks over its blocks in miner 0's view (weight and subsidy kept); `hon red` = honest blocks since the fork turned red by the release.")
out.append("")
rows = []
for d in delays:
for H in shares:
for T in holds:
eps = [episode(args.bps, args.k, d, H, "hold", T, 0, args.honest, args.warm, args.post, 2000 + s) for s in range(args.seeds)]
won = np.mean([e["won"] for e in eps])
reorg = [e["reorg"] for e in eps]
age = [e["fork_age"] for e in eps if e["won"]]
ab = sum(e["att_blue"] for e in eps)
at = sum(e["att_blocks"] for e in eps)
hr = sum(e["hon_red"] for e in eps)
ht = sum(e["hon_blocks"] for e in eps)
cell = dict(kind="hold", d=d, H=H, T=T, won=float(won), reorg_med=float(np.median(reorg)), reorg_max=int(max(reorg)),
fork_age_med=float(np.median(age)) if age else 0.0, att_blue_share=(ab / at if at else 0.0), hon_red_share=(hr / ht if ht else 0.0),
withheld_mean=float(np.mean([e["withheld"] for e in eps])))
results["cells"].append(cell)
rows.append(["%g" % d, "%.0f%%" % (100 * H), "%g" % T, "%.1f" % cell["withheld_mean"], "%.0f%%" % (100 * won),
"%.0f / %d" % (cell["reorg_med"], cell["reorg_max"]), "%.0f" % cell["fork_age_med"] if age else "-",
"%.0f%%" % (100 * cell["att_blue_share"]), "%.0f%%" % (100 * cell["hon_red_share"])])
out.append(md(["d, s", "attacker share H", "hold T, s", "blocks withheld (mean)", "won", "reorg med / max", "fork age if won, s (med)", "att blue / att blocks", "hon red / hon blocks"], rows))
out.append("")
# 3. selfish-lead strategy over a longer run
out.append("## 3. Selfish withholding (release when about to lose, or at lead L = 6): blue share against hash share over %g s, d = %g s" % (args.selfish_run, delays[1] if len(delays) > 1 else delays[0]))
out.append("")
d = delays[1] if len(delays) > 1 else delays[0]
rows = []
for H in shares:
eps = [episode(args.bps, args.k, d, H, "selfish", 0, 6, args.honest, 60.0, args.selfish_run, 3000 + s) for s in range(max(3, args.seeds // 4))]
ab = sum(e["att_blue"] for e in eps)
at = sum(e["att_blocks"] for e in eps)
hb = sum(e["hon_blue"] for e in eps)
won = np.mean([e["won"] for e in eps])
reorg = max(e["reorg"] for e in eps)
cell = dict(kind="selfish", d=d, H=H, att_blue_share=(ab / at if at else 0.0), blue_share_of_all=(ab / (ab + hb) if ab + hb else 0.0),
won=float(won), reorg_max=int(reorg))
results["cells"].append(cell)
rows.append(["%.0f%%" % (100 * H), "%.0f%%" % (100 * cell["att_blue_share"]), "%.1f%%" % (100 * cell["blue_share_of_all"]), "%.0f%%" % (100 * won), reorg])
out.append(md(["attacker share H", "attacker blue blocks / its blocks (weight kept)", "attacker share of ALL blue blocks (weight share; honest mining gives H)", "private tip is the chain at the end", "max reorg depth seen"], rows))
out.append("")
out.append("(%d cells, %.0f s)" % (len(results["cells"]), time.time() - t0))
text = "\n".join(out)
if args.out:
with open(args.out, "w") as f:
f.write(text + "\n")
if args.json:
with open(args.json, "w") as f:
json.dump(results, f, indent=1)
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""The miner-signalling game (P2, docs/plans/counter-asic-3-node.md section 6): what a holdout, a renter and a
signal-then-defect miner can do against the 95 percent threshold over a one-day window with a floor height.
Rule as proposed (6 October 2026): epoch e is the new class when (a) its start is at or past the floor N6, or (b) the
previous epoch was, or (c) the window of W = 86,400 DAA below the epoch's seed block is full and at least 9,500 bps of
its blue blocks carry the new object byte. Weight = blue blocks (the finality walk). The byte is set by the node that
builds the template, so a pool's hashers cannot change it and a renter needs only a patched node to set it.
This file is arithmetic, not a stochastic simulation: with 86,400 blocks in the window the binomial noise on the share is
sqrt(p (1 - p) / 86,400) = 0.07 points at p = 0.95, so "95 percent" is a hard line, not a coin flip. Cost basis: the
measured rental price USD 11.7 per GH/s-hour (docs/bench-log.md, "Rental cost of hash, 6 October 2026": 1,748 MH/s for
USD 20.44 an hour on RunPod community pods, approximate at scale because the market could not supply a TH/s).
Run: python3 sim/horizon/consensus-security/signalling.py [--out file.md]
"""
import argparse
import math
import sys
PRICE_PER_GHS_HOUR = 11.7 # USD, measured 6 Oct 2026 (bench-log line "Rental cost of hash")
W_BLOCKS = 86_400
THRESHOLD = 0.95
NETWORKS_GHS = [1, 10, 100, 1000]
def binom_flip_prob(p, n=W_BLOCKS, thr=THRESHOLD):
"""P(share >= thr) for a true signalling fraction p over n blocks, normal approximation."""
if p <= 0 or p >= 1:
return 1.0 if p >= thr else 0.0
mu, sd = p, math.sqrt(p * (1 - p) / n)
z = (thr - mu) / sd
return 0.5 * math.erfc(z / math.sqrt(2))
def md(headers, rows):
out = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)]
for r in rows:
out.append("| " + " | ".join(str(x) for x in r) + " |")
return "\n".join(out)
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="")
args = ap.parse_args(argv)
out = ["# Miner signalling: the 95 percent game, one-day window, floor height", ""]
out.append("Generated by `sim/horizon/consensus-security/signalling.py`. Arithmetic on the P2 rule; price USD %.1f per GH/s-hour (measured, bench-log 6 Oct 2026)." % PRICE_PER_GHS_HOUR)
out.append("")
out.append("## 1. The threshold is a hard line: binomial noise over 86,400 blocks")
out.append("")
rows = []
for p in (0.93, 0.94, 0.945, 0.949, 0.95, 0.951, 0.955, 0.96):
rows.append(["%.1f%%" % (100 * p), "%.2f points" % (100 * math.sqrt(p * (1 - p) / W_BLOCKS)), "%.4f" % binom_flip_prob(p)])
out.append(md(["true signalling share p", "std of the window share", "P(window share >= 95%) on a given day"], rows))
out.append("")
out.append("Reading: a 94.5% fleet never flips by luck (P = 0.0000); a 95.1% fleet flips on its first full day (P = 0.91). A holdout of 5.1% of blue blocks blocks the signal path for ever; only the floor height ends it.")
out.append("")
out.append("## 2. The holdout: what 6 percent of the hash costs to hold until the floor")
out.append("")
out.append("A holdout needs 5% plus one block of the blue blocks of every day until N6. Rented on top of a network of N GH/s that otherwise signals 100%: h/(1 - h) x N for h = 6%.")
out.append("")
rows = []
for N in NETWORKS_GHS:
hr = 0.06 / 0.94 * N
rows.append(["%d GH/s" % N, "%.3f GH/s" % hr, "USD %.0f" % (hr * PRICE_PER_GHS_HOUR * 24), "USD %.0f" % (hr * PRICE_PER_GHS_HOUR * 24 * 14), "USD %.0f" % (hr * PRICE_PER_GHS_HOUR * 24 * 30)])
out.append(md(["network hash", "rented hash for a 6% holdout", "per day", "14 days (a devnet-scale floor)", "30 days"], rows))
out.append("")
out.append("What the holdout gains: nothing but delay, bounded by the floor. The holdout earns block subsidy on its hash like any miner (6% of blocks), so its net cost is the rental premium over revenue, which at a rental market in equilibrium is near zero. The floor is therefore the whole defence against a veto, and the floor is a fixed height: the hazard of 6 October 2026 (the DAA 198,000 crossing while the fleet was still updating: a two-sided chain and a 229-block reorg, CLAUDE.md 6 Oct rules) returns at N6 for any node not yet on the new object.")
out.append("")
out.append("## 3. The renter: forcing the flip early with one day of 95 percent")
out.append("")
out.append("The window is one day of blue blocks and the byte is the template builder's. A renter who patches the byte and holds 95% of a day's blue blocks flips the class at the next epoch boundary whether or not the real fleet has the object. Hash needed: 0.95/0.05 x N = 19 N for 24 h (the window must be full and 95% signalling; a lower share over more days never reaches 95% because the window slides).")
out.append("")
rows = []
for N in NETWORKS_GHS:
hr = 19.0 * N
rows.append(["%d GH/s" % N, "%.0f GH/s" % hr, "USD %.0f" % (hr * PRICE_PER_GHS_HOUR * 24), "%s" % ("rentable tonight (1.75 GH/s on RunPod)" if hr <= 1.75 else "the market could not supply a TH/s on 6 Oct (bench-log)")])
out.append(md(["network hash", "rented hash for one day", "cost of the day", "supply"], rows))
out.append("")
out.append("What it buys: an activation while part of the fleet lacks the object. For the v4 cut nothing: the signalling binary IS the v4 binary, so every node that reads the high byte can mine v4 (section 6.1 item 1), and a pre-signalling node already refuses the high-byte headers. For a later object (v5) it is the fork of the nodes still on v4, the same shape as a fixed height crossed early. Bound: the share of the fleet not on the new object at the forced flip, which the renter chooses. Defence that costs nothing in liveness: require the threshold on each of D consecutive daily windows (D = 7 makes the renter's bill 7x and gives a week of public warning: the chain shows the share every epoch in `programClassV4SignalBps`), or weight the signal by the 30-day finality window, which a one-day rental cannot fill (W2: a day is 1/30 of the table).")
out.append("")
out.append("## 4. Signal then defect")
out.append("")
out.append("A node that signals (byte 4) and after the flip runs the old class produces headers whose PoW fails under the new program: refused by every node, its blocks are lost, nobody else is touched (`check_header_version` then the PoW check). A pool that signals with its template and whose hashers run old workers loses the hashers' blocks after the flip (the worker computes the program from the template's class; a stale worker's blocks fail PoW). Bound: the defector's own income, zero to anyone else. Signal-then-defect is not an attack on the chain; it is a mis-upgraded miner.")
out.append("")
out.append("## 5. The one-day window against the 30-day finality window")
out.append("")
rows = [["one day (P2 as proposed)", "19 N for 24 h", "USD %.0f at 1 GH/s, %.0f at 100 GH/s" % (19 * PRICE_PER_GHS_HOUR * 24, 1900 * PRICE_PER_GHS_HOUR * 24), "flips within a day of the last upgrade", "a renter forces it in a day"],
["7 consecutive days at 95%", "19 N for 7 x 24 h", "USD %.0f at 1 GH/s, %.0f at 100 GH/s" % (19 * PRICE_PER_GHS_HOUR * 24 * 7, 1900 * PRICE_PER_GHS_HOUR * 24 * 7), "flips a week after the last upgrade", "7x the bill and a week of visible share"],
["30-day blue-block window (the finality table)", "the renter must mine 95% of 30 days of blocks: 19 N for 30 days", "USD %.0f at 1 GH/s, %.0f at 100 GH/s" % (19 * PRICE_PER_GHS_HOUR * 24 * 30, 1900 * PRICE_PER_GHS_HOUR * 24 * 30), "flips 30 days after the fleet is ready (the status file's objection)", "same bound as finality's own 2/3: the 20-day public event"]]
out.append(md(["window", "renter's hash", "renter's cost", "honest latency", "what it buys the defence"], rows))
out.append("")
out.append("Recommendation to the lane that owns P2: keep the one-day tally for the live share display, require the threshold on D = 7 consecutive daily windows for the flip, keep the floor as the backstop but set it no nearer than 7 days past the publish on any network miners run (14,400 DAA = 4 h was a devnet value), and never let a floor cross while under 95% is signalling without a node-side line that says which boxes are not on the object (the stale-box list is already a P1 pass rule).")
text = "\n".join(out)
if args.out:
with open(args.out, "w") as fh:
fh.write(text + "\n")
print(text)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,58 @@
# Miner signalling: the 95 percent game, one-day window, floor height
Generated by `sim/horizon/consensus-security/signalling.py`. Arithmetic on the P2 rule; price USD 11.7 per GH/s-hour (measured, bench-log 6 Oct 2026).
## 1. The threshold is a hard line: binomial noise over 86,400 blocks
| true signalling share p | std of the window share | P(window share >= 95%) on a given day |
|---|---|---|
| 93.0% | 0.09 points | 0.0000 |
| 94.0% | 0.08 points | 0.0000 |
| 94.5% | 0.08 points | 0.0000 |
| 94.9% | 0.07 points | 0.0908 |
| 95.0% | 0.07 points | 0.5000 |
| 95.1% | 0.07 points | 0.9133 |
| 95.5% | 0.07 points | 1.0000 |
| 96.0% | 0.07 points | 1.0000 |
Reading: a 94.5% fleet never flips by luck (P = 0.0000); a 95.1% fleet flips on its first full day (P = 0.91). A holdout of 5.1% of blue blocks blocks the signal path for ever; only the floor height ends it.
## 2. The holdout: what 6 percent of the hash costs to hold until the floor
A holdout needs 5% plus one block of the blue blocks of every day until N6. Rented on top of a network of N GH/s that otherwise signals 100%: h/(1 - h) x N for h = 6%.
| network hash | rented hash for a 6% holdout | per day | 14 days (a devnet-scale floor) | 30 days |
|---|---|---|---|---|
| 1 GH/s | 0.064 GH/s | USD 18 | USD 251 | USD 538 |
| 10 GH/s | 0.638 GH/s | USD 179 | USD 2509 | USD 5377 |
| 100 GH/s | 6.383 GH/s | USD 1792 | USD 25093 | USD 53770 |
| 1000 GH/s | 63.830 GH/s | USD 17923 | USD 250928 | USD 537702 |
What the holdout gains: nothing but delay, bounded by the floor. The holdout earns block subsidy on its hash like any miner (6% of blocks), so its net cost is the rental premium over revenue, which at a rental market in equilibrium is near zero. The floor is therefore the whole defence against a veto, and the floor is a fixed height: the hazard of 6 October 2026 (the DAA 198,000 crossing while the fleet was still updating: a two-sided chain and a 229-block reorg, CLAUDE.md 6 Oct rules) returns at N6 for any node not yet on the new object.
## 3. The renter: forcing the flip early with one day of 95 percent
The window is one day of blue blocks and the byte is the template builder's. A renter who patches the byte and holds 95% of a day's blue blocks flips the class at the next epoch boundary whether or not the real fleet has the object. Hash needed: 0.95/0.05 x N = 19 N for 24 h (the window must be full and 95% signalling; a lower share over more days never reaches 95% because the window slides).
| network hash | rented hash for one day | cost of the day | supply |
|---|---|---|---|
| 1 GH/s | 19 GH/s | USD 5335 | the market could not supply a TH/s on 6 Oct (bench-log) |
| 10 GH/s | 190 GH/s | USD 53352 | the market could not supply a TH/s on 6 Oct (bench-log) |
| 100 GH/s | 1900 GH/s | USD 533520 | the market could not supply a TH/s on 6 Oct (bench-log) |
| 1000 GH/s | 19000 GH/s | USD 5335200 | the market could not supply a TH/s on 6 Oct (bench-log) |
What it buys: an activation while part of the fleet lacks the object. For the v4 cut nothing: the signalling binary IS the v4 binary, so every node that reads the high byte can mine v4 (section 6.1 item 1), and a pre-signalling node already refuses the high-byte headers. For a later object (v5) it is the fork of the nodes still on v4, the same shape as a fixed height crossed early. Bound: the share of the fleet not on the new object at the forced flip, which the renter chooses. Defence that costs nothing in liveness: require the threshold on each of D consecutive daily windows (D = 7 makes the renter's bill 7x and gives a week of public warning: the chain shows the share every epoch in `programClassV4SignalBps`), or weight the signal by the 30-day finality window, which a one-day rental cannot fill (W2: a day is 1/30 of the table).
## 4. Signal then defect
A node that signals (byte 4) and after the flip runs the old class produces headers whose PoW fails under the new program: refused by every node, its blocks are lost, nobody else is touched (`check_header_version` then the PoW check). A pool that signals with its template and whose hashers run old workers loses the hashers' blocks after the flip (the worker computes the program from the template's class; a stale worker's blocks fail PoW). Bound: the defector's own income, zero to anyone else. Signal-then-defect is not an attack on the chain; it is a mis-upgraded miner.
## 5. The one-day window against the 30-day finality window
| window | renter's hash | renter's cost | honest latency | what it buys the defence |
|---|---|---|---|---|
| one day (P2 as proposed) | 19 N for 24 h | USD 5335 at 1 GH/s, 533520 at 100 GH/s | flips within a day of the last upgrade | a renter forces it in a day |
| 7 consecutive days at 95% | 19 N for 7 x 24 h | USD 37346 at 1 GH/s, 3734640 at 100 GH/s | flips a week after the last upgrade | 7x the bill and a week of visible share |
| 30-day blue-block window (the finality table) | the renter must mine 95% of 30 days of blocks: 19 N for 30 days | USD 160056 at 1 GH/s, 16005600 at 100 GH/s | flips 30 days after the fleet is ready (the status file's objection) | same bound as finality's own 2/3: the 20-day public event |
Recommendation to the lane that owns P2: keep the one-day tally for the live share display, require the threshold on D = 7 consecutive daily windows for the flip, keep the floor as the backstop but set it no nearer than 7 days past the publish on any network miners run (14,400 DAA = 4 h was a devnet value), and never let a floor cross while under 95% is signalling without a node-side line that says which boxes are not on the object (the stale-box list is already a P1 pass rule).