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>
250 lines
16 KiB
Python
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())
|