research/tools/simulators/tsi/tsi-sim-pernode/tests/test_countable_selfish.py
Marcin Pawlowski 0510b20684
Countable recovery under a selfish adversary: SM1 hides the first-fork cost
The countable model can reference only the first block of a fork, so a
discarded chain of h honest blocks yields one countable uncle, not h. Sec 6.6
reads the estimator repair off a free knob eta and quotes it at eta = 1 --
attainable under SM1, which acts the moment the honest branch reaches length 1
and so never buries a second block. The optimal SSZ policy waits and does bury
them, and there the deployed counting rules cap eta at 0.44 (alpha = 0.4,
gamma = 0), landing D-hat at 0.81 rather than the 0.94 an unrestricted count
gives -- and the ceiling degrades with alpha while the unrestricted value
improves. So SM1 is a faithful proxy for selfish-mining revenue (0.484 vs
0.489) but not for TSI's estimator damage.

selfish_mdp: carry per-branch orphan counts on the transition table so the
accounting cannot drift from the race logic; optimal_policy_stats solves the
policy's stationary distribution for per-event canonical/orphan rates. The
per-event rates sum to 1 (every block is canonical or orphaned), which the
tests assert as an independent check on the whole derivation.

reorg: the same ceiling for the depth-maximising adversary -- 0.52 at
alpha = 0.30 with the measured honest fork rate -- reached from the other
direction. Neither adversary optimises deflation directly, so both ceilings
are upper bounds on eta; that gap is logged as open item 16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 15:29:57 +02:00

87 lines
3.9 KiB
Python

"""Countable (first-fork) uncle recovery under a selfish adversary (§6.6).
The countable model can reference only the first block of a fork, so a discarded *chain* of
honest blocks yields one countable uncle however long it is. These tests pin the two ends of
that: SM1 never buries a second block (so the restriction costs nothing), while the optimal
policy waits and does (so it costs a factor of ~2 in recoverable orphans).
"""
import numpy as np
import pytest
from tsi_sim.selfish import race_from_alpha, selfish_threshold
from tsi_sim.selfish_mdp import optimal_policy_stats
FAST = dict(cap=16, iters=1500)
@pytest.mark.parametrize("gamma", [0.0, 0.5, 1.0])
@pytest.mark.parametrize("alpha", [0.2, 1 / 3, 0.4, 0.45])
def test_sm1_orphans_are_all_countable(alpha, gamma):
# SM1 acts as soon as the honest branch reaches length 1, so every orphan it makes is the
# first block of its fork: the first-fork restriction costs SM1 exactly nothing.
r = race_from_alpha(alpha, 200_000, gamma, np.random.default_rng(3))
assert r.orphan_hon_runs == r.orphan_hon
assert r.countable_recovery == 1.0
@pytest.mark.parametrize("gamma", [0.0, 0.5])
def test_optimal_policy_block_conservation(gamma):
# Every block-finding event yields exactly one block, which ends up canonical or orphaned.
# Per-event rates must therefore sum to 1 — the same invariant test_selfish asserts for SM1.
s = optimal_policy_stats(0.4, gamma, **FAST)
total = s.density_fraction + s.orphan_hon_blocks + s.orphan_adv_blocks
assert abs(total - 1.0) < 1e-9
@pytest.mark.parametrize("gamma", [0.0, 0.5])
def test_optimal_policy_buries_orphans(gamma):
# Above the profitability threshold the optimum waits before overriding, so it discards
# multi-block honest chains that the first-fork rule cannot recover.
s = optimal_policy_stats(0.4, gamma, **FAST)
assert s.deviates
assert s.orphan_hon_runs < s.orphan_hon_blocks
assert s.countable_recovery < 0.7 # measured ~0.44 (gamma=0) / ~0.55 (gamma=0.5)
def test_below_threshold_does_not_deviate():
# Below the threshold the optimum is honest mining; the MDP is indifferent across policies
# there, so the orphan structure of an arbitrary greedy tie-break must not be reported.
alpha = 0.25
assert alpha < selfish_threshold(0.0)
s = optimal_policy_stats(alpha, 0.0, **FAST)
assert not s.deviates
assert s.orphan_hon_blocks == 0.0
assert s.density_fraction == 1.0
def test_countable_dhat_is_below_unrestricted():
s = optimal_policy_stats(0.4, 0.0, **FAST)
# With no references the two models agree; with them, countable recovers strictly less.
assert s.dhat_ratio(p_ref=0.0, countable=True) == s.dhat_ratio(p_ref=0.0, countable=False)
assert s.dhat_ratio(p_ref=1.0, countable=True) < s.dhat_ratio(p_ref=1.0, countable=False)
# and both are bounded by the no-attack value
assert s.dhat_ratio(p_ref=1.0, countable=False) <= 1.0
# monotone in the reference rate
assert (s.dhat_ratio(p_ref=0.0, countable=True)
< s.dhat_ratio(p_ref=0.5, countable=True)
< s.dhat_ratio(p_ref=1.0, countable=True))
def test_attacker_self_uncle_is_capped_too():
# The attacker's abandoned secret chain is also one chain, so it can self-uncle only its
# first block — the §6.7(a) farming channel is narrower than the block count suggests.
s = optimal_policy_stats(0.4, 0.0, **FAST)
assert s.orphan_adv_runs < s.orphan_adv_blocks
assert 0.5 < s.countable_recovery_adv < 1.0
@pytest.mark.slow
def test_cap_convergence():
# The orphan shape converges more slowly in cap than the revenue does; check the drift is
# small where the report quotes numbers.
a = optimal_policy_stats(0.4, 0.0, cap=48)
b = optimal_policy_stats(0.4, 0.0, cap=64)
assert abs(a.countable_recovery - b.countable_recovery) < 2e-3
assert abs(a.revenue - b.revenue) < 1e-3