igneum/sim/horizon/consensus-security/finality_horizon.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

250 lines
16 KiB
Python

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