igneum/sim/horizon/consensus-security/ghostdag_sim.py
igneum-labs 2e405311a4 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>
2026-10-06 20:07:43 +00:00

440 lines
20 KiB
Python

#!/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())