igneum/tools/finality-attacks/vote-timing.py

77 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""F22 measurement: vote issue time against the certificate cut-off on the 12-node cloud devnet (4 Oct 2026).
Sources: nodes/igneum-XX/miner.log (VOTE lines, unix ms) and igneumd.log.gz (determined / certificate built / LOCKED)."""
import gzip, re, sys, statistics as st
from datetime import datetime, timezone
base = sys.argv[1]
t_from = datetime.fromisoformat(sys.argv[2]).replace(tzinfo=timezone.utc).timestamp()
t_to = datetime.fromisoformat(sys.argv[3]).replace(tzinfo=timezone.utc).timestamp()
nodes = [f"igneum-{i:02d}" for i in range(1, 13)]
det, built, lock, vote = {}, {}, {}, {}
re_node = re.compile(r"(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d+)\+00:00 \[INFO \] Finality: (.*)")
for n in nodes:
det[n], built[n], lock[n], vote[n] = {}, {}, {}, {}
with gzip.open(f"{base}/nodes/{n}/igneumd.log.gz", "rt", errors="replace") as f:
for line in f:
m = re_node.search(line)
if not m: continue
t = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S.%f").replace(tzinfo=timezone.utc).timestamp()
msg = m.group(2)
mm = re.match(r"checkpoint (\d+) determined", msg)
if mm: det[n].setdefault(int(mm.group(1)), t); continue
mm = re.match(r"certificate built for checkpoint (\d+) .* by (\d+) of (\d+) voters", msg)
if mm: built[n].setdefault(int(mm.group(1)), (t, int(mm.group(2)), int(mm.group(3)))); continue
mm = re.match(r"checkpoint (\d+) LOCKED: .* signed (\d+) = ([\d.]+)% of active, ([\d.]+)% of total", msg)
if mm: lock[n].setdefault(int(mm.group(1)), (t, float(mm.group(4)))); continue
with open(f"{base}/nodes/{n}/miner.log", errors="replace") as f:
for line in f:
mm = re.search(r"(\d+\.\d+) VOTE index=(\d+)", line)
if mm: vote[n].setdefault(int(mm.group(2)), float(mm.group(1)))
idxs = sorted(set.union(*[set(det[n]) for n in nodes]))
rows = []
for i in idxs:
d = [det[n][i] for n in nodes if i in det[n]]
if not d: continue
t0 = min(d)
if t0 < t_from or t0 > t_to: continue
v = sorted(vote[n][i] for n in nodes if i in vote[n])
b = sorted((built[n][i][0], built[n][i][1], n) for n in nodes if i in built[n])
if len(v) < 12 or not b: continue
tc, nc, who = b[0]
before = sum(1 for x in v if x <= tc)
# the certificate every node locked: the signer fraction from the LOCKED lines (fraction of total), median over nodes
fr = [lock[n][i][1] for n in nodes if i in lock[n]]
rows.append(dict(i=i, t0=t0, det_spread=max(d) - t0, v_first=v[0] - t0, v_med=v[len(v)//2] - t0, v_last=v[-1] - t0,
tc=tc - t0, nc=nc, before=before, builders=len(b), frac=st.median(fr) if fr else None, locked_nodes=len(fr)))
def q(xs, p):
xs = sorted(xs); return xs[min(len(xs)-1, int(p * len(xs)))]
print(f"indices measured: {len(rows)} (first {rows[0]['i']}, last {rows[-1]['i']}), window {sys.argv[2]} to {sys.argv[3]} UTC")
print()
print("| quantity (seconds after the earliest determination of the index) | median | p90 | p99 | max |")
print("|---|---|---|---|---|")
for k, lab in [("det_spread", "determination spread across the 12 nodes"), ("v_first", "first vote issued"), ("v_med", "median vote issued"), ("v_last", "last of the 12 votes issued"), ("tc", "first certificate built (the cut-off)")]:
xs = [r[k] for r in rows]
print(f"| {lab} | {st.median(xs):.2f} | {q(xs,0.9):.2f} | {q(xs,0.99):.2f} | {max(xs):.2f} |")
print()
nc = [r["nc"] for r in rows]; bf = [r["before"] for r in rows]
from collections import Counter
print("signers in the first-built certificate:", dict(sorted(Counter(nc).items())), "mean %.2f of 12" % st.mean(nc))
print("votes issued at or before the first build:", dict(sorted(Counter(bf).items())), "mean %.2f of 12" % st.mean(bf))
print("builders per index (nodes that built their own certificate):", dict(sorted(Counter(r['builders'] for r in rows).items())))
fr = [r["frac"] for r in rows if r["frac"] is not None]
print("locked fraction of total (median over nodes): min %.1f%% median %.1f%% max %.1f%%; indices under 70%%: %d of %d" % (min(fr), st.median(fr), max(fr), sum(1 for x in fr if x < 70), len(fr)))
# how long a hold after quorum would have been needed to carry k of 12 votes
print()
print("| hold after the first build (s) | indices where all 12 votes were issued by then | 11 or more |")
print("|---|---|---|")
for h in (0, 0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0):
all12 = sum(1 for r in rows if r["v_last"] <= r["tc"] + h)
ge11 = sum(1 for r in rows if sorted([x for x in [vote[n][r['i']] - r['t0'] for n in nodes if r['i'] in vote[n]]])[-2] <= r["tc"] + h)
print(f"| {h} | {all12} of {len(rows)} ({100*all12/len(rows):.0f}%) | {ge11} ({100*ge11/len(rows):.0f}%) |")
# vote issue lag relative to the voter's own node's determination (the miner's 1-s poll)
own = []
for r in rows:
for n in nodes:
if r['i'] in vote[n] and r['i'] in det[n]: own.append(vote[n][r['i']] - det[n][r['i']])
print()
print("vote issue lag after the voter's own node determined the checkpoint (the miner's 1-s poll): median %.2f p90 %.2f p99 %.2f max %.2f s, n=%d" % (st.median(own), q(own,0.9), q(own,0.99), max(own), len(own)))