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 <noreply@anthropic.com>
2026-08-06 12:46:57 +02:00
|
|
|
"""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
|
Re-measure the W pairing paired, and correct my own severity numbers
The W = 12 pairing is now measured the way a ~0.001 claim has to be: every
integer W from 8 to 15, 32 replicates, and paired_streams so the whole grid runs
on common random numbers (_base_key excludes both uncle_window_anchor and
window_absorption, so a replicate draws one stake vector, one graph and one
lottery for every cell). The earlier unpaired sweep reported +0.0008 against a
standard error of 0.0009 — it could not resolve its own headline.
Paired against today's recipe (uncle-anchored, W = 10):
parent W=10 -0.0056 +- 0.0005 t = -10.4
parent W=11 -0.0024 +- 0.0005 t = -4.5
parent W=12 +0.00004 +- 0.00050 t = 0.1 <- parity
parent W=14 +0.0016 +- 0.0004 t = 3.5
W = 12 is the smallest window reaching parity, and the parity is exact rather
than marginal: W = 11, one interval short, is still resolvably worse. p_ref
agrees at the same window (0.938 vs 0.939) instead of lagging to W = 15 as the
unpaired edition had it. Also states what the sweep makes visible: widening
today's uncle-anchored rule buys +0.0018 on its own, so W = 12 makes the swap
cost-neutral against the CURRENT recipe rather than optimal in absolute terms.
CORRECTIONS to the previous commit, which measured contamination on the wrong
RNG stream. The engine draws stake from seedseq_for(config).spawn(...)[0]; I
used rng_for(config), the root. Both are valid stake draws, neither is the same
vector. Redone properly:
- The capstone draw was NOT contaminated: 0 of 8 replicates over 1.25x its
label, worst 0.369 against 0.30, no majority. My "2 of 8, one a 61%
majority" was wrong and is withdrawn from §8.4 and §9.
- The finding that survives is sharper: on that same mild overshoot the spec's
rule moved 0.001 and the parent-anchored variant moved 0.016. A rule leaning
harder on the reference window is far more sensitive to an oversized
suppressing coalition.
- Genuinely contaminated: §6.12's 12-replicate W sweep (2 majorities, worst
0.720) and §6.8's selfish margin at a=0.3 and a=0.4 (2 and 1 majorities).
§6.5's variants and §6.8's a=0.2 arm are clean; §8.3 item 20 narrowed to the
one sweep that still needs re-running.
- The general severity is worse than first stated, not better: at the report's
geometry a nominal 0.3 realised a majority in 12% of replicates.
Two more defects found on the way:
- stake_for(config) added, because scripts used rng_for and the engine uses
the spawned child — so every script that rebuilt a tree was analysing a
different network than the trajectory it was compared against. All scripts
and tests now use it.
- A coalition member could receive a private block BEFORE its parent: the
arrival was clamped against the PRODUCER's view of the parent and applied to
the whole coalition, so a member still awaiting a public parent got the child
first. Now clamped per member. Caught by the existing arrival-order test once
the stake derivation was corrected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:37:21 +02:00
|
|
|
from tsi_sim.stake import stake_for
|
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 <noreply@anthropic.com>
2026-08-06 12:46:57 +02:00
|
|
|
|
|
|
|
|
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})
|
Re-measure the W pairing paired, and correct my own severity numbers
The W = 12 pairing is now measured the way a ~0.001 claim has to be: every
integer W from 8 to 15, 32 replicates, and paired_streams so the whole grid runs
on common random numbers (_base_key excludes both uncle_window_anchor and
window_absorption, so a replicate draws one stake vector, one graph and one
lottery for every cell). The earlier unpaired sweep reported +0.0008 against a
standard error of 0.0009 — it could not resolve its own headline.
Paired against today's recipe (uncle-anchored, W = 10):
parent W=10 -0.0056 +- 0.0005 t = -10.4
parent W=11 -0.0024 +- 0.0005 t = -4.5
parent W=12 +0.00004 +- 0.00050 t = 0.1 <- parity
parent W=14 +0.0016 +- 0.0004 t = 3.5
W = 12 is the smallest window reaching parity, and the parity is exact rather
than marginal: W = 11, one interval short, is still resolvably worse. p_ref
agrees at the same window (0.938 vs 0.939) instead of lagging to W = 15 as the
unpaired edition had it. Also states what the sweep makes visible: widening
today's uncle-anchored rule buys +0.0018 on its own, so W = 12 makes the swap
cost-neutral against the CURRENT recipe rather than optimal in absolute terms.
CORRECTIONS to the previous commit, which measured contamination on the wrong
RNG stream. The engine draws stake from seedseq_for(config).spawn(...)[0]; I
used rng_for(config), the root. Both are valid stake draws, neither is the same
vector. Redone properly:
- The capstone draw was NOT contaminated: 0 of 8 replicates over 1.25x its
label, worst 0.369 against 0.30, no majority. My "2 of 8, one a 61%
majority" was wrong and is withdrawn from §8.4 and §9.
- The finding that survives is sharper: on that same mild overshoot the spec's
rule moved 0.001 and the parent-anchored variant moved 0.016. A rule leaning
harder on the reference window is far more sensitive to an oversized
suppressing coalition.
- Genuinely contaminated: §6.12's 12-replicate W sweep (2 majorities, worst
0.720) and §6.8's selfish margin at a=0.3 and a=0.4 (2 and 1 majorities).
§6.5's variants and §6.8's a=0.2 arm are clean; §8.3 item 20 narrowed to the
one sweep that still needs re-running.
- The general severity is worse than first stated, not better: at the report's
geometry a nominal 0.3 realised a majority in 12% of replicates.
Two more defects found on the way:
- stake_for(config) added, because scripts used rng_for and the engine uses
the spawned child — so every script that rebuilt a tree was analysing a
different network than the trajectory it was compared against. All scripts
and tests now use it.
- A coalition member could receive a private block BEFORE its parent: the
arrival was clamped against the PRODUCER's view of the parent and applied to
the whole coalition, so a member still awaiting a public parent got the child
first. Now clamped per member. Caught by the existing arrival-order test once
the stake derivation was corrected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:37:21 +02:00
|
|
|
stake = stake_for(cfg)
|
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 <noreply@anthropic.com>
2026-08-06 12:46:57 +02:00
|
|
|
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()
|
2026-08-07 11:15:55 +02:00
|
|
|
# Unpack by position, not with a splat: fork_stats has grown a field twice now, and a
|
|
|
|
|
# trailing `*_, a, b` silently re-binds to different quantities each time it does.
|
|
|
|
|
(_fork_rate, _max_d, _mean_d, p_ref, p_ref_honest,
|
|
|
|
|
deep_orphan_share) = fork_stats(tree, A, cfg.period_T, cutoff=cfg.epoch_len)
|
|
|
|
|
assert p_ref == p_ref_honest # no coalition -> the two coincide
|
|
|
|
|
assert 0.0 <= deep_orphan_share <= 1.0
|
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 <noreply@anthropic.com>
2026-08-06 12:46:57 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|