#!/usr/bin/env python3 """Mission item 12, the eclipse bound of the weight-backed peer directory (docs/analysis/mission/invent.md 7.1, model E). A fresh node draws its outbound peers from the directory WITHOUT replacement, each draw proportional to the listing key's weight at the latest checkpoint (the fork's `FinalityManager::draw_peers`). An attacker holding share `a` of the weight lists its addresses under `m` keys of equal weight (each above dust: the dust threshold is what bounds `m`, 100 blocks a window on mainnet). The node is eclipsed when every one of its `n` outbound peers is the attacker's. Model E's closed form is a^n (with replacement); without replacement and with few attacker keys the odds are lower, and with fewer than n attacker keys an eclipse is impossible. Honest keys: `h` keys with Zipf weights (a few large miners, a long tail), the shape of a public chain's weight table. python3 sim/peer-directory/eclipse.py [--starts 1000] [--share 0.34] [--peers 8,16] [--attacker-keys 8,64,1000] [--honest-keys 200] [--seed 7] """ import argparse import random def draw(pool, n, rng): """Weighted draw without replacement: pool is a list of (is_attacker, weight).""" pool = list(pool) out = [] while len(out) < n and pool: total = sum(w for _, w in pool) r = rng.uniform(0, total) i = 0 while i + 1 < len(pool) and r >= pool[i][1]: r -= pool[i][1] i += 1 out.append(pool.pop(i)[0]) return out def run(starts, share, peers, attacker_keys, honest_keys, rng): honest_total = 1.0 - share zipf = [1.0 / (k + 1) for k in range(honest_keys)] z = sum(zipf) honest = [(False, honest_total * w / z) for w in zipf] attacker = [(True, share / attacker_keys)] * attacker_keys pool = honest + attacker eclipsed = 0 majority = 0 for _ in range(starts): got = draw(pool, peers, rng) k = sum(got) if k == len(got): eclipsed += 1 if k * 2 > len(got): majority += 1 return eclipsed, majority def main(): ap = argparse.ArgumentParser() ap.add_argument("--starts", type=int, default=1000) ap.add_argument("--share", type=float, default=0.34) ap.add_argument("--peers", default="8,16") ap.add_argument("--attacker-keys", default="8,64,1000") ap.add_argument("--honest-keys", type=int, default=200) ap.add_argument("--seed", type=int, default=7) a = ap.parse_args() rng = random.Random(a.seed) print(f"starts {a.starts}, attacker share {a.share:.2f} of the weight, {a.honest_keys} honest keys (Zipf), seed {a.seed}") print(f"{'peers':>5} {'attacker keys':>13} {'eclipsed':>9} {'rate':>10} {'a^n (model E)':>14} {'attacker majority':>17}") for n in (int(x) for x in a.peers.split(",")): for m in (int(x) for x in a.attacker_keys.split(",")): e, maj = run(a.starts, a.share, n, m, a.honest_keys, rng) print(f"{n:>5} {m:>13} {e:>9} {e / a.starts:>10.2e} {a.share ** n:>14.2e} {maj / a.starts:>17.3f}") if __name__ == "__main__": main()