igneum/sim/peer-directory/eclipse.py

73 lines
3.1 KiB
Python

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