From 3708d03e224f67d5fc58230d4eb5326440fd869c Mon Sep 17 00:00:00 2001 From: Marcin Pawlowski Date: Thu, 6 Aug 2026 12:46:57 +0200 Subject: [PATCH] Private-chain (SM1) adversary in the per-node engine Sec 6.8 recorded that "the per-node engine has no private-chain strategy", which is why every selfish result came from the global race model with uncle recovery as a free knob eta -- and why open item 5 (does the uncle cap need margin under attack-inflated orphaning?) could not be sized: a knob has no queue to overflow. adversary_strategy="selfish" adds it. The coalition mines one shared private chain and releases under the classic SM1 rules in (a, h) form: adopt when the public chain wins, match at equal length, override at a one-block lead, else wait. Only VISIBILITY is modelled -- the coalition's mining needs no special case, because a member's fork choice already builds on the private tip whenever it leads (that tip has the greatest height among blocks the member can see) and falls back to the public chain exactly when the public chain overtakes, which is the adopt branch. So the private chain forms, extends and is abandoned emergently, and the code that had to be written is the arrival matrix. Design notes worth keeping: - Private blocks reuse the sentinel `withhold` already had (never-arrives), so the existing exclusions from canonical-tip selection apply unchanged; release flips it back and gossips DIRECTLY from the producer, bypassing Blend, since an adversary has no privacy budget to respect and wants the race won. - A private chain breaks the windowed horizon's premise (a hidden block is old enough to look fully-propagated while no honest node has it, and it becomes visible LATER, which the one-way frontier pointer cannot revisit), so selfish forces the exact full scan and full matrix. - Blocks still hidden at epoch end are abandoned and hidden from the coalition too, or the canonical-tip search would crown a chain no honest node saw. Validated against Eyal-Sirer at sub-slot latency: revenue share 0.0356 vs an exact 0.0356 at alpha = 0.1, and above the closed form at higher alpha by just the margin the alpha_eff fork-amplification correction predicts (0.498 vs 0.484 at alpha = 0.4, with fork rate 0.38). Adds p_ref_honest: the reference rate over orphans produced OUTSIDE the coalition. Under a private-chain attack this diverges sharply from p_ref, and only the honest one measures the repair the report credits to uncle counting -- an attacker's own discarded blocks are its loss to bear. test_fork unpacks fork_stats positionally, so its three call sites take the new fifth value. 247 tests pass. Co-Authored-By: Claude Fable 5 --- .../scripts/selfish_uncle_margin.py | 127 +++++++++++++ .../tsi-sim-pernode/src/tsi_sim/blocktree.py | 114 +++++++++++- .../tsi/tsi-sim-pernode/src/tsi_sim/config.py | 11 +- .../tsi/tsi-sim-pernode/src/tsi_sim/epoch.py | 6 +- .../tsi/tsi-sim-pernode/src/tsi_sim/fork.py | 24 ++- .../tsi-sim-pernode/src/tsi_sim/metrics.py | 1 + .../tsi/tsi-sim-pernode/tests/test_fork.py | 6 +- .../tests/test_selfish_engine.py | 174 ++++++++++++++++++ 8 files changed, 449 insertions(+), 14 deletions(-) create mode 100644 tools/simulators/tsi/tsi-sim-pernode/scripts/selfish_uncle_margin.py create mode 100644 tools/simulators/tsi/tsi-sim-pernode/tests/test_selfish_engine.py diff --git a/tools/simulators/tsi/tsi-sim-pernode/scripts/selfish_uncle_margin.py b/tools/simulators/tsi/tsi-sim-pernode/scripts/selfish_uncle_margin.py new file mode 100644 index 0000000..a398f4d --- /dev/null +++ b/tools/simulators/tsi/tsi-sim-pernode/scripts/selfish_uncle_margin.py @@ -0,0 +1,127 @@ +"""Does the uncle cap need margin under a private-chain attack? — REPORT §8.3 item 5. + +Item 5: "Under attack-inflated orphaning the honest-load cap may need extra margin (owed uncles +beyond `U` defer and can age out of `W`); this report does not size it." It could not be sized +before, because the per-node engine had no private-chain strategy (§6.8) — the selfish results +came from a global race model in which uncle recovery is a free knob, not a queue with a cap. + +With `adversary_strategy="selfish"` in the engine, the whole loop is present: the attack orphans +honest blocks in runs, the survivors queue for the `U` uncle slots of each canonical block, and +whatever does not drain within `W` ages out. This sweeps the cap against the attack to find the +smallest `U` that still recovers, and compares it to the honest rule `U = ceil(rho) + 1`. + +Three quantities separate the two failure modes the item conflates: + + * ``p_ref_honest`` — of the honest blocks the attacker orphaned, how many got referenced at + all. Falls for TWO different reasons, which is why the next column matters. + * ``deep_ref_share`` — the share of examined references rejected by the first-fork rule. An + override discards a *chain*, and only its first block is countable (§2.1), so this isolates + "unreferenceable by construction" from "queue too small". + * ``D_hat/D`` — what the estimator actually lands on, the thing the cap is sized to protect. + +If raising `U` lifts recovery, the cap is the binding constraint and item 5 needs a bigger +number. If it does not, the loss is structural and no cap buys it back. + +Run: python scripts/selfish_uncle_margin.py (writes runs/selfish_uncle_margin.parquet) +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pandas as pd +from joblib import Parallel, delayed + +from tsi_sim.config import SimConfig +from tsi_sim.engine import run_trajectory +from tsi_sim.memguard import ArrivalMatrixTooLarge + +HERE = Path(__file__).resolve().parent.parent +RUNS = HERE / "runs" +RUNS.mkdir(exist_ok=True) + +EPOCHS = 16 +REPS = 8 +N_JOBS = 6 + +BASE = dict(n_nodes=1000, stake_dist="pareto", topology="blend", degree=6, + link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3, + k=256, epochs=EPOCHS, genesis_d_factor=0.5, early_stop=False, + adversary_strategy="selfish") + +ALPHAS = [0.0, 0.2, 0.3, 0.4] +DELAYS = [8.0, 16.0] # rho ~ 0.56 (design point) and ~1.0 (the load boundary) +CAPS = [1, 2, 3, 4] # spec allows up to MAX_UNCLES = 4 +WINDOWS = [10, 20] # W = 10/f (recommended) and the 20/f widening of §3.4 + + +def _cell(alpha: float, delay: float, u: int, w: int, rep: int) -> dict: + cfg = SimConfig(**BASE, blend_delay_max=delay, max_uncles=u, window_absorption=w, + adversary_frac=alpha, replicate=rep) + row = dict(alpha=alpha, blend_delay_max=delay, max_uncles=u, window_absorption=w, rep=rep) + try: + t = pd.DataFrame(run_trajectory(cfg)) + t = t[t.epoch >= EPOCHS // 2] + row |= dict(collapsed=False, + mean_ratio=float(t.mean_ratio.mean()), + fork_rate=float(t.fork_rate.mean()), + p_ref=float(t.p_ref.mean()), + p_ref_honest=float(t.p_ref_honest.mean()), + deep_ref_share=float(t.deep_ref_share.mean()), + max_reorg_depth=int(t.max_reorg_depth.max()), + adv_share=float(t.adv_blocks.sum() + / max(t.adv_blocks.sum() + t.honest_blocks.sum(), 1))) + except ArrivalMatrixTooLarge: + row |= dict(collapsed=True) + return row + + +def sweep() -> pd.DataFrame: + jobs = [(a, d, u, w, r) for a in ALPHAS for d in DELAYS for u in CAPS + for w in WINDOWS for r in range(REPS)] + df = pd.DataFrame(Parallel(n_jobs=N_JOBS, backend="loky", inner_max_num_threads=1)( + delayed(_cell)(a, d, u, w, r) for a, d, u, w, r in jobs)) + df.to_parquet(RUNS / "selfish_uncle_margin.parquet", index=False) + return df + + +BAR = 0.98 # the §3.6 recovery bar, as a fraction of the true stake + + +def report(df: pd.DataFrame) -> None: + ok = df[~df.collapsed] + for w in WINDOWS: + print(f"\n=== W = {w} block-intervals ===") + print(f"{'delta':>6} {'alpha':>6} | " + " ".join(f"U={u}" for u in CAPS) + + " | smallest U >= bar p_ref_h deep_ref fork") + for d in DELAYS: + for a in ALPHAS: + g = ok[(ok.window_absorption == w) & (ok.blend_delay_max == d) & (ok.alpha == a)] + if g.empty: + continue + cells, best = [], None + for u in CAPS: + gu = g[g.max_uncles == u] + m = gu.mean_ratio.mean() if len(gu) else np.nan + cells.append(f"{m:.3f}") + if best is None and m >= BAR: + best = u + ref = g[g.max_uncles == max(CAPS)] + print(f"{d:6.1f} {a:6.2f} | " + " ".join(cells) + + f" | {str(best):>4} {ref.p_ref_honest.mean():7.3f}" + + f" {ref.deep_ref_share.mean():8.3f} {ref.fork_rate.mean():5.3f}") + n_col = int(df.collapsed.sum()) + if n_col: + print(f"\n{n_col} of {len(df)} runs collapsed into the §6.2 branch (excluded above)") + + +def main() -> None: + print(f"=== selfish uncle-margin sweep ({len(ALPHAS)*len(DELAYS)*len(CAPS)*len(WINDOWS)*REPS}" + f" runs; recovery bar {BAR}) ===") + report(sweep()) + print(f"\nwrote {RUNS}/selfish_uncle_margin.parquet") + + +if __name__ == "__main__": + main() diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/blocktree.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/blocktree.py index f20d8d3..2993371 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/blocktree.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/blocktree.py @@ -174,6 +174,76 @@ def _max_span_blocks(active_slots: np.ndarray, counts: np.ndarray, span: float) return best +class _SelfishCoalition: + """Eyal–Sirer SM1 private-chain state, driven from the coalition's shared view. + + The coalition mines one private chain and releases it under the classic SM1 rules, in terms + of ``a`` = unreleased private blocks since the fork and ``h`` = public blocks since the fork + as the coalition sees them: + + h > a adopt — the public chain won; the private blocks are dead + h == a (a > 0) match — release all; the two chains race at equal length + h == a - 1 (a>=2) override — release all; the public ``h`` blocks are orphaned + h < a - 1 wait — stay hidden and keep the lead + + Only *visibility* is modelled here; the coalition's **mining** needs no special case. A + coalition member's fork choice already builds on the private tip whenever the private chain + leads, because that tip has the greatest height among the blocks that member can see — and + it falls back to the public chain exactly when the public chain overtakes, which is the + "adopt" branch. So the private chain forms, extends and is abandoned emergently. + + The coalition is treated as one entity that shares its view instantly: a member learns a + public block as soon as the *earliest* member does, and sees every private block at its + production slot. Both are best-case-for-the-adversary, which is the direction we want for a + bound on the damage. + """ + + def __init__(self, coal_idx: np.ndarray, n_blocks: int, E: int) -> None: + self.coal = coal_idx + self.priv: list[int] = [] # unreleased private blocks, oldest first + self.fork_height = 0 # height of the block the private chain forked from + self.unreleased = np.zeros(n_blocks, dtype=bool) + self.coal_arr = np.full(n_blocks, float(E) + 1.0) # when the coalition learns of a block + self.coal_arr[0] = 0.0 + self.n_released = 0 # blocks made public by a release + self.n_abandoned = 0 # private blocks the coalition gave up on + self.n_override = 0 # releases that orphaned >=1 honest block + + def note_block(self, b: int, arrival_at_coalition: float) -> None: + self.coal_arr[b] = arrival_at_coalition + + def add_private(self, b: int, t: int, parent_height: int) -> None: + if not self.priv: # opening a new private chain: record its fork height + self.fork_height = parent_height + self.priv.append(b) + self.unreleased[b] = True + self.coal_arr[b] = float(t) # shared inside the coalition immediately + + def public_height(self, t: int, height: np.ndarray, nb: int) -> int: + """Best height the coalition can see on the PUBLIC chain (private blocks excluded).""" + vis = (self.coal_arr[:nb] <= t) & (~self.unreleased[:nb]) + return int(height[:nb][vis].max()) if vis.any() else 0 + + def decide(self, t: int, height: np.ndarray, nb: int) -> list[int]: + """Apply the SM1 rule; return the private blocks to release now (possibly empty).""" + a = len(self.priv) + if a == 0: + return [] + h = self.public_height(t, height, nb) - self.fork_height + if h > a: # adopt: the public chain won outright + self.n_abandoned += a + self.priv.clear() + return [] + if h == a or (h == a - 1 and a >= 2): # match / override: publish the whole chain + out = self.priv + self.priv = [] + self.n_released += len(out) + if h >= 1: + self.n_override += 1 + return out + return [] # wait + + def build_tree_pernode( active_slots: np.ndarray, winners_per_slot: list[np.ndarray], @@ -237,7 +307,12 @@ def build_tree_pernode( key[0] = np.int64(0) * c1 - np.int64(-1) * c2 - np.int64(0) NEG = np.iinfo(np.int64).min - windowed = bool(config.windowed_fork_choice) + selfish = (adversary_mask is not None and config.adversary_frac > 0.0 + and config.adversary_strategy == "selfish") + # A private chain breaks the windowed horizon's premise: an unreleased block is old enough to + # be "fully propagated" while no honest node has it, and it becomes visible LATER (on release), + # which the one-way frontier pointer can never revisit. So selfish runs the exact full scan. + windowed = bool(config.windowed_fork_choice) and not selfish if not windowed: horizon = float(E) # full scan (gb unused) elif config.topology == "blend": @@ -264,6 +339,7 @@ def build_tree_pernode( withholding = (adversary_mask is not None and config.adversary_frac > 0.0 and config.adversary_strategy == "withhold") if config.prune_arrival and windowed and config.jitter_mean == 0.0 and not withholding: + # (selfish already cleared `windowed`, so it never reaches the pruned path either) return _build_pruned(active_slots, winners_per_slot, path_latency, config, rng, slot, parent, height, leader, uncles, key, c1, c2, float(horizon), n_blocks, E, n, adversary_mask) @@ -289,6 +365,8 @@ def build_tree_pernode( gb_id = 0 fp_idx = 1 # frontier pointer over fully-propagated blocks + coalition = _SelfishCoalition(np.nonzero(adversary_mask)[0], n_blocks, E) if selfish else None + nb = 1 for si in range(active_slots.shape[0]): t = int(active_slots[si]) @@ -338,12 +416,46 @@ def build_tree_pernode( if hide: A[:, b] = float(E) + 1.0 # withheld: never arrives -> orphan withheld[b] = True + elif coalition is not None and adv: + # Private: visible to the whole coalition at once, invisible to everyone else + # until released. Kept off the honest side by the same sentinel `withhold` uses. + A[:, b] = float(E) + 1.0 + A[coalition.coal, b] = max(float(t), float(A[v, p_id])) + withheld[b] = True # flipped back on release + coalition.add_private(b, t, int(height[p_id])) else: np.maximum(col, A[:, p_id], out=col) A[:, b] = col A[v, b] = max(float(t), float(A[v, p_id])) # producer sees own block at its slot + if coalition is not None: + coalition.note_block(b, float(A[coalition.coal, b].min())) nb += 1 + if coalition is not None: + for rb in coalition.decide(t, height, nb): + # Release by DIRECT gossip from the producer, bypassing the Blend cascade: the + # adversary has no privacy budget to respect and wants the race won, so this is + # its fastest legal publication. Oldest first, so each block's parent arrival is + # already final when the no-earlier-than-parent clamp is applied. + prod = int(leader[rb]) + rel = float(t) + path_latency[prod] + np.maximum(rel, A[:, int(parent[rb])], out=rel) + np.minimum(rel, A[:, rb], out=rel) # coalition already had it privately + A[:, rb] = rel + withheld[rb] = False + coalition.unreleased[rb] = False + + if coalition is not None and coalition.priv: + # Private blocks still hidden when the epoch ends are abandoned: the race they were held + # for is over, so they can never be cashed in. Hide them from the coalition too, or the + # canonical-tip search (which takes the best tip ANY node holds) would crown a chain no + # honest node ever saw and credit it phantom blocks. + stranded = np.array(coalition.priv, dtype=np.int64) + A[:, stranded] = float(E) + 1.0 + withheld[stranded] = True + coalition.n_abandoned += len(coalition.priv) + coalition.priv.clear() + tree = BlockTree(slot=slot, parent=parent, height=height, leader=leader, uncles=uncles) return tree, A diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py index 62968cc..d754313 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/config.py @@ -30,7 +30,12 @@ InitDest = Literal["common", "heterogeneous"] # "withhold" — never gossips its blocks (they are orphaned, its won slots become gaps in the # canonical chain), so the counted density drops ~adversary_frac and TSI deflates D_est toward # the reduced ACTIVE stake. Stronger, but the withheld blocks earn nothing (griefing/grinding). -AdversaryStrategy = Literal["suppress", "withhold"] +# "selfish" — mines a PRIVATE chain and releases it to orphan honest blocks (Eyal-Sirer SM1). +# Unlike "withhold" (which discards its blocks — abstention, a dead loss), this recovers the +# forfeit by displacing honest work, and is the one profitable lever (report §6.6). Its +# estimator damage is what the countable uncle rule can only partly repair, because an +# override discards a CHAIN of honest blocks and only the first is referenceable (§2.1). +AdversaryStrategy = Literal["suppress", "withhold", "selfish"] # WHICH nodes make up that coalition, at the same total stake: # "random" — a uniformly random set grown until its stake reaches adversary_frac (the default; the # block share is then smooth in adversary_frac, which is all the density levers depend on); @@ -270,8 +275,8 @@ class SimConfig: if self.adversary_selection not in ("random", "whale"): raise ValueError(f"adversary_selection must be random|whale, got " f"{self.adversary_selection!r}") - if self.adversary_strategy not in ("suppress", "withhold"): - raise ValueError(f"adversary_strategy must be suppress|withhold, got " + if self.adversary_strategy not in ("suppress", "withhold", "selfish"): + raise ValueError(f"adversary_strategy must be suppress|withhold|selfish, got " f"{self.adversary_strategy!r}") checks = { "n_nodes": self.n_nodes >= 1, diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/epoch.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/epoch.py index d6e37bf..d29d113 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/epoch.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/epoch.py @@ -30,6 +30,7 @@ class EpochResult: max_reorg_depth: int # deepest maximal orphan branch (blocks a reorg would discard) mean_reorg_depth: float # mean maximal-orphan-branch depth p_ref: float # emergent reference rate: in-window orphans referenced as uncles + p_ref_honest: float # ...restricted to orphans produced OUTSIDE the coalition deep_ref_share: float # share of examined references rejected by the parent-on-chain # (first-fork) counting rule; 0 under the old model @@ -112,7 +113,8 @@ def simulate_epoch( attribution = coalition_mask if coalition_mask is not None else adversary_mask adv_blocks, honest_blocks = _canonical_producer_split(tree, A, attribution, T, E) - fork_rate, max_reorg_depth, mean_reorg_depth, p_ref = fork.fork_stats(tree, A, T, cutoff=E) + fork_rate, max_reorg_depth, mean_reorg_depth, p_ref, p_ref_honest = fork.fork_stats( + tree, A, T, cutoff=E, coalition_mask=attribution) ref_total = int(ms.ref_total.sum()) deep_ref_share = (int(ms.ref_deep.sum()) / ref_total) if ref_total else 0.0 @@ -123,5 +125,5 @@ def simulate_epoch( mean_orphan_rate=float(ms.orphan_rate.mean()), adv_blocks=adv_blocks, honest_blocks=honest_blocks, fork_rate=fork_rate, max_reorg_depth=max_reorg_depth, mean_reorg_depth=mean_reorg_depth, - p_ref=p_ref, deep_ref_share=deep_ref_share, + p_ref=p_ref, p_ref_honest=p_ref_honest, deep_ref_share=deep_ref_share, ) diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/fork.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/fork.py index 51b9f6e..1a2e887 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/fork.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/fork.py @@ -18,17 +18,24 @@ import numpy as np from .blocktree import BlockTree -def fork_stats(tree: BlockTree, A, T: int, cutoff: int) -> tuple[float, int, float, float]: - """Return ``(fork_rate, max_reorg_depth, mean_reorg_depth, p_ref)`` over in-window blocks. +def fork_stats(tree: BlockTree, A, T: int, cutoff: int, + coalition_mask=None) -> tuple[float, int, float, float, float]: + """Return ``(fork_rate, max_reorg_depth, mean_reorg_depth, p_ref, p_ref_honest)``. ``p_ref`` is the emergent **reference rate**: the fraction of in-window orphans that some canonical block references as an uncle — the quantity the §6.8 soft-inclusion argument assumes is high. ``A`` is the arrival matrix (full ``np.ndarray`` or pruned): only used to exclude withheld blocks (which reach no node) from canonical-tip selection. + + ``p_ref_honest`` restricts that to orphans produced by nodes OUTSIDE ``coalition_mask``. + Under a private-chain attack the two diverge and only the honest one measures the repair the + report credits to uncle counting: an attacker's own discarded blocks are its loss to bear, + and counting them would flatter `p_ref` with orphans nobody is owed. Equal to ``p_ref`` when + no mask is given. """ nb = tree.n_blocks if nb <= 1: - return 0.0, 0, 0.0, 1.0 + return 0.0, 0, 0.0, 1.0, 1.0 ids = np.arange(nb) if isinstance(A, np.ndarray): arrived = (A <= cutoff).any(axis=0) @@ -48,7 +55,7 @@ def fork_stats(tree: BlockTree, A, T: int, cutoff: int) -> tuple[float, int, flo in_win = (tree.slot >= 0) & (tree.slot < T) total = int(in_win.sum()) if total == 0: - return 0.0, 0, 0.0, 1.0 + return 0.0, 0, 0.0, 1.0, 1.0 # depth[b] = length of the non-canonical run ending at b (0 if canonical). Parent-before-child # holds because a block\'s parent has a strictly smaller id (built earlier). @@ -75,4 +82,11 @@ def fork_stats(tree: BlockTree, A, T: int, cutoff: int) -> tuple[float, int, flo referenced[u] = True ref_orphans = int((orphan_in_win & referenced).sum()) p_ref = ref_orphans / n_orphan if n_orphan else 1.0 - return fork_rate, max_depth, mean_depth, p_ref + + if coalition_mask is None: + p_ref_honest = p_ref + else: + honest_orphan = orphan_in_win & ~np.asarray(coalition_mask)[tree.leader] + n_ho = int(honest_orphan.sum()) + p_ref_honest = (int((honest_orphan & referenced).sum()) / n_ho) if n_ho else 1.0 + return fork_rate, max_depth, mean_depth, p_ref, p_ref_honest diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py index c6ad532..42e6b6a 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/metrics.py @@ -64,6 +64,7 @@ def divergence_row( max_reorg_depth=er.max_reorg_depth, mean_reorg_depth=er.mean_reorg_depth, p_ref=er.p_ref, + p_ref_honest=er.p_ref_honest, deep_ref_share=er.deep_ref_share, ) return row diff --git a/tools/simulators/tsi/tsi-sim-pernode/tests/test_fork.py b/tools/simulators/tsi/tsi-sim-pernode/tests/test_fork.py index 2c6f3db..d41554e 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/tests/test_fork.py +++ b/tools/simulators/tsi/tsi-sim-pernode/tests/test_fork.py @@ -23,14 +23,14 @@ def make_tree(slots, parents, heights): def test_no_forks(): # a straight chain 1->2->3, no orphans tree = make_tree([-1, 0, 1, 2], [-1, 0, 1, 2], [0, 1, 2, 3]) - fr, mx, mn, pr = fork_stats(tree, None, T=10, cutoff=100) + fr, mx, mn, pr, _ = fork_stats(tree, None, T=10, cutoff=100) assert fr == 0.0 and mx == 0 and mn == 0.0 def test_single_orphan_depth_one(): # canonical 1(s0),2(s1),4(s3); orphan 3(s2) hangs off block1 -> branch depth 1 tree = make_tree([-1, 0, 1, 2, 3], [-1, 0, 1, 1, 2], [0, 1, 2, 2, 3]) - fr, mx, mn, pr = fork_stats(tree, None, T=10, cutoff=100) + fr, mx, mn, pr, _ = fork_stats(tree, None, T=10, cutoff=100) assert mx == 1 assert abs(fr - 1 / 4) < 1e-9 # 1 orphan of 4 in-window blocks @@ -39,7 +39,7 @@ def test_deep_orphan_branch(): # canonical spine 1..3 (heights 1,2,3); a 2-deep orphan branch 4->5 off block1 # blocks: 0 gen; 1(s0,h1),2(s1,h2),3(s2,h3) canonical; 4(s1,h2)->1, 5(s2,h3)->4 orphan tree = make_tree([-1, 0, 1, 2, 1, 2], [-1, 0, 1, 2, 1, 4], [0, 1, 2, 3, 2, 3]) - fr, mx, mn, pr = fork_stats(tree, None, T=10, cutoff=100) + fr, mx, mn, pr, _ = fork_stats(tree, None, T=10, cutoff=100) assert mx == 2 # branch 4->5 is 2 deep assert abs(fr - 2 / 5) < 1e-9 # 2 orphans of 5 diff --git a/tools/simulators/tsi/tsi-sim-pernode/tests/test_selfish_engine.py b/tools/simulators/tsi/tsi-sim-pernode/tests/test_selfish_engine.py new file mode 100644 index 0000000..f8efab4 --- /dev/null +++ b/tools/simulators/tsi/tsi-sim-pernode/tests/test_selfish_engine.py @@ -0,0 +1,174 @@ +"""The private-chain (SM1) adversary inside the per-node engine (§6.6, open item 5). + +§6.8 recorded that "the per-node engine has no private-chain strategy", which is why the +selfish results came from the global race model with uncle recovery as a free knob. These +tests pin the engine version: that it leaves every honest result untouched, that its blocks +are conserved, and that it actually orphans honest work rather than merely hiding its own. +""" + +import numpy as np +import pytest + +from tsi_sim.blocktree import build_tree_pernode +from tsi_sim.config import SimConfig +from tsi_sim.engine import _adversary_mask, run_trajectory +from tsi_sim.rng import rng_for +from tsi_sim.stake import make_stake + +BASE = dict(n_nodes=200, stake_dist="pareto", topology="blend", degree=6, + link_latency_mean=0.5, link_latency_dist="geo", blend_hops=3, blend_delay_max=8.0, + max_uncles=2, k=32, epochs=4, genesis_d_factor=0.5, early_stop=False) + + +def _traj(**over): + return run_trajectory(SimConfig(**{**BASE, **over})) + + +def test_selfish_at_zero_stake_keeps_the_honest_fast_paths(): + """With no coalition, `selfish` must not disturb the honest engine at all. + + Note it is NOT bit-identical to `suppress` at frac = 0: adversary_strategy sits in the base + RNG key, so switching it reseeds the run even though the field is inert without a coalition. + That is pre-existing and harmless (both are valid honest runs), so the invariant worth + pinning is the one that protects committed results — that the windowed fork choice and the + arrival prune, which `selfish` disables when it IS active, stay enabled and stay exact here. + """ + exact = _traj(adversary_strategy="selfish", adversary_frac=0.0, + windowed_fork_choice=False, prune_arrival=False) + fast = _traj(adversary_strategy="selfish", adversary_frac=0.0) + assert [r["mean_ratio"] for r in fast] == [r["mean_ratio"] for r in exact] + assert max(r["range_ratio"] for r in fast) == 0.0 # honest run: nodes agree exactly + + +def test_selfish_key_is_distinct_from_the_other_strategies(): + # adversary_strategy already sits in the base key, so no historical seed moves; this just + # pins that the new value is not silently aliased onto an existing stream. + keys = {s: SimConfig(**BASE, adversary_frac=0.3, adversary_strategy=s).key() + for s in ("suppress", "withhold", "selfish")} + assert len(set(keys.values())) == 3 + + +def test_selfish_is_deterministic(): + a = _traj(adversary_frac=0.3, adversary_strategy="selfish") + b = _traj(adversary_frac=0.3, adversary_strategy="selfish") + assert [r["mean_ratio"] for r in a] == [r["mean_ratio"] for r in b] + + +def _tree(**over): + cfg = SimConfig(**{**BASE, **over}) + stake = make_stake(cfg, rng_for(cfg)) + mask = _adversary_mask(cfg, stake) + from tsi_sim import lottery, topology + root = __import__("tsi_sim.rng", fromlist=["seedseq_for"]).seedseq_for(cfg) + kids = root.spawn(cfg.epochs + 3) + pl = topology.build_path_latency(cfg, np.random.default_rng(kids[1])) + d_est = np.full(cfg.n_nodes, cfg.genesis_d_factor * stake.sum()) + p = lottery.win_probs(stake, d_est, cfg.f) + ws, wn = lottery.sample_wins(p, cfg.epoch_len, np.random.default_rng(kids[3])) + slots, groups = lottery.group_by_slot(ws, wn) + tree, A = build_tree_pernode(slots, groups, pl, cfg, np.random.default_rng(kids[4]), + adversary_mask=mask) + return cfg, tree, A, mask + + +def test_private_blocks_are_invisible_to_honest_nodes_while_hidden(): + cfg, tree, A, mask = _tree(adversary_frac=0.3, adversary_strategy="selfish") + E = cfg.epoch_len + honest = ~mask + # Every block is either public (some honest node has it) or hidden from ALL honest nodes. + reaches_honest = (A[honest] <= E).any(axis=0) + hidden = ~reaches_honest + hidden[0] = False + # a hidden block is never a partial leak: no honest node holds it + assert not (A[honest][:, hidden] <= E).any() + # and every hidden block was produced by the coalition, never by an honest node + assert mask[tree.leader[hidden]].all() + + +def test_released_blocks_never_precede_their_parent(): + # The release path applies its own no-earlier-than-parent clamp; a violation would let a + # node build on a child before its parent and corrupt the tree. + cfg, tree, A, mask = _tree(adversary_frac=0.3, adversary_strategy="selfish") + for b in range(1, tree.n_blocks): + p = int(tree.parent[b]) + assert (A[:, b] >= A[:, p] - 1e-9).all(), f"block {b} precedes parent {p}" + + +def _honest_orphans_in_window(cfg, tree, A, mask) -> int: + """In-window orphans produced by NON-coalition nodes — the displaced honest work.""" + E, T = cfg.epoch_len, cfg.period_T + nb = tree.n_blocks + ids = np.arange(nb) + arrived = (A <= E).any(axis=0) + arrived[0] = True + h = np.where(arrived, tree.height, np.iinfo(np.int64).min) + best = int(np.lexsort((-ids, -tree.slot, h))[-1]) + canonical = np.zeros(nb, dtype=bool) + b = best + while b > 0: + canonical[b] = True + b = int(tree.parent[b]) + in_win = (tree.slot >= 0) & (tree.slot < T) + return int((in_win & ~canonical & ~mask[tree.leader]).sum()) + + +def test_selfish_displaces_honest_work_where_withholding_only_hides_its_own(): + # This is the distinction between the two levers, and the reason only one of them is + # profitable: withholding discards the coalition's OWN blocks (a dead loss, and honest + # blocks keep their places), while a private chain overrides HONEST blocks off the chain. + # Compare the honest orphan count at matched stake -- not fork_rate, which counts the + # withholder's own vanished blocks as orphans too and so runs high for the wrong reason. + kw = dict(adversary_frac=0.4, max_uncles=0) + cfg_s, tree_s, A_s, mask_s = _tree(adversary_strategy="selfish", **kw) + cfg_w, tree_w, A_w, mask_w = _tree(adversary_strategy="withhold", **kw) + assert (_honest_orphans_in_window(cfg_s, tree_s, A_s, mask_s) + > _honest_orphans_in_window(cfg_w, tree_w, A_w, mask_w)) + + +def test_selfish_deflates_the_estimate_below_the_honest_baseline(): + tail = slice(2, None) + honest = np.mean([r["mean_ratio"] for r in _traj(max_uncles=0)[tail]]) + selfish = np.mean([r["mean_ratio"] for r in + _traj(adversary_frac=0.35, adversary_strategy="selfish", + max_uncles=0)[tail]]) + assert selfish < honest + + +def test_uncle_counting_repairs_part_of_the_selfish_deflation(): + # The §6.6 claim, now measurable in the engine rather than through the free knob eta: + # uncles recover some of the loss, and (per §6.6/fig36) not all of it. + tail = slice(2, None) + d0 = np.mean([r["mean_ratio"] for r in + _traj(adversary_frac=0.35, adversary_strategy="selfish", max_uncles=0)[tail]]) + d2 = np.mean([r["mean_ratio"] for r in + _traj(adversary_frac=0.35, adversary_strategy="selfish", max_uncles=2)[tail]]) + assert d2 > d0 + + +def test_p_ref_honest_defaults_to_p_ref_without_a_coalition(): + from tsi_sim.fork import fork_stats + cfg, tree, A, _ = _tree() + *_, p_ref, p_ref_h = fork_stats(tree, A, cfg.period_T, cutoff=cfg.epoch_len) + assert p_ref == p_ref_h + + +@pytest.mark.parametrize("strategy", ["selfish", "withhold"]) +def test_hidden_blocks_are_excluded_from_the_canonical_chain(strategy): + # A chain no honest node ever saw must not be crowned canonical, or it would collect + # phantom rewards and phantom density. + from tsi_sim.epoch import _canonical_producer_split + cfg, tree, A, mask = _tree(adversary_frac=0.4, adversary_strategy=strategy) + E, T = cfg.epoch_len, cfg.period_T + adv, hon = _canonical_producer_split(tree, A, mask, T, E) + reaches_honest = (A[~mask] <= E).any(axis=0) + # walk the chosen canonical tip: every block on it is public + ids = np.arange(tree.n_blocks) + arrived = (A <= E).any(axis=0) + arrived[0] = True + h = np.where(arrived, tree.height, np.iinfo(np.int64).min) + best = int(np.lexsort((-ids, -tree.slot, h))[-1]) + b = best + while b > 0: + assert reaches_honest[b], f"canonical block {b} was never public" + b = int(tree.parent[b]) + assert adv + hon > 0