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

253 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Selfish / private-chain block withholding, and its interaction with TSI.
§6.5 modelled withholding as *abstention* — a coalition that discards its own blocks to deflate the
active-stake estimate — and found it strictly unprofitable (the forfeit is a dead loss). The classic
"block withholding" attack is different and stronger: the coalition mines a **private chain** and
**releases** it to orphan honest blocks (Eyal & Sirer, *Majority is not Enough*, 2013), recovering
the forfeit. This module models that adversary and its TSI-specific coupling.
Why a global longest-chain race (not the per-node arrival engine): TSI reads density from a window
buried far past k-finality, where every node provably agrees on the canonical chain (§3.1). The
selfish/honest outcome is decided by that single finalized chain, so the standard block-level race —
with a network tie-break parameter ``gamma`` (fraction of honest miners that build on the
adversary's block in a same-length race) — is the faithful, exact tool for the reward and density.
Two quantities matter:
* **revenue share** ``adv/(adv+hon)`` — the adversary's fraction of *canonical* blocks. Above the
selfish threshold (``alpha > (1-gamma)/(3-2*gamma)``; 1/3 at gamma=0) it exceeds the stake
share ``alpha`` — i.e. private-chain withholding IS profitable, unlike §6.5's abstention.
* **canonical density fraction** ``(adv+hon)/events`` < 1 — orphaned honest blocks go uncounted,
so TSI measures a low density and **deflates ``D̂`` to ``D*``·(density fraction)**. Uncle
references (§6.36.4) recover orphaned honest blocks into the count, blunting the deflation.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
def selfish_revenue_closed_form(alpha: float, gamma: float) -> float:
"""EyalSirer relative revenue of the selfish pool (fraction of canonical blocks).
``R = [a(1-a)^2 (4a + g(1-2a)) - a^3] / [1 - a(1 + (2-a)a)]`` (a=alpha, g=gamma).
Exceeds ``alpha`` above the profitability threshold ``alpha > (1-gamma)/(3-2gamma)``.
"""
a, g = float(alpha), float(gamma)
num = a * (1 - a) ** 2 * (4 * a + g * (1 - 2 * a)) - a ** 3
den = 1 - a * (1 + (2 - a) * a)
return num / den
def selfish_threshold(gamma: float) -> float:
"""Stake fraction above which selfish mining out-earns honest: ``(1-gamma)/(3-2gamma)``."""
return (1 - gamma) / (3 - 2 * gamma)
@dataclass
class RaceResult:
adv: int # adversary blocks on the canonical chain
hon: int # honest blocks on the canonical chain
orphan_hon: int # honest blocks orphaned by adversary overrides (recoverable via uncles)
orphan_adv: int # adversary private blocks that lost a race (dead)
events: int # total block-finding events
@property
def revenue_share(self) -> float:
c = self.adv + self.hon
return self.adv / c if c else 0.0
@property
def density_fraction(self) -> float:
"""Counted canonical blocks / all mined blocks — the factor TSI deflates ``D̂`` by."""
return (self.adv + self.hon) / self.events if self.events else 1.0
@property
def orphan_hon_runs(self) -> int:
"""Orphaned honest *chains* — the number of them the countable model can reference.
The countable uncle model (§2.1) can reference only the first block of a fork, so a
discarded chain of ``h`` honest blocks yields one countable uncle, not ``h``. **Under SM1
this equals** ``orphan_hon``: the rule acts the moment the honest branch reaches length 1
(match at a 1-lead, override at a 2-lead, publish-one above it), so it never buries a
second block behind the first and every orphan it makes is the first block of its fork.
The optimal policy *waits* and does bury them — see
:func:`tsi_sim.selfish_mdp.optimal_policy_stats`, where the ratio drops well below 1.
"""
return self.orphan_hon
@property
def countable_recovery(self) -> float:
"""Ceiling on the uncle-recovery fraction ``eta`` under the first-fork rule: 1.0 for SM1.
Stated as a property rather than a constant so the SM1 claim is checkable in one place
(``tests/test_countable_selfish.py``) against the same quantity computed for the optimum.
"""
return self.orphan_hon_runs / self.orphan_hon if self.orphan_hon else 1.0
def simulate_selfish(is_adv: np.ndarray, gamma: float, rng: np.random.Generator) -> RaceResult:
"""EyalSirer SM1 selfish-mining race over a stream of block-finding events.
``is_adv[i]`` marks whether event ``i`` was found by the adversary pool. Honest miners build on
the public head; the adversary builds a private chain and reveals it per SM1. ``gamma`` is the
tie-race bias. Reward is assigned to the canonical (surviving) branch at each resolution.
State ``k`` = adversary's hidden lead over the public branch; ``tie`` = the 11 race state 0'.
"""
k = 0 # adversary private lead
tie = False # in the 0' (11 race) state
adv = hon = orphan_hon = orphan_adv = 0
for x in np.asarray(is_adv, dtype=bool):
if tie: # resolving a 11 race (adversary revealed 1)
if x: # adversary extends its branch -> it wins
adv += 2
orphan_hon += 1 # the honest matching block is orphaned
elif rng.random() < gamma: # honest builds on adversary block -> adv wins
adv += 1
hon += 1
orphan_hon += 1 # the honest matcher is orphaned by the race
else: # honest builds on honest block -> honest wins
hon += 2
orphan_adv += 1 # the adversary's revealed block is orphaned
tie = False
k = 0
continue
if x: # adversary found a block: extend private chain
k += 1
elif k == 0: # honest found, adversary no lead -> honest wins
hon += 1
elif k == 1: # honest matches a 1-lead -> reveal 1, race 0'
tie = True
k = 0
elif k == 2: # honest chips a 2-lead -> reveal all, override
adv += 2
orphan_hon += 1 # the honest block is orphaned
k = 0
else: # k > 2: reveal one to stay ahead, orphan honest
adv += 1
orphan_hon += 1
k -= 1
# flush: adversary reveals any remaining private lead (longer -> canonical)
if tie:
hon += 1 # unresolved tie: honest keeps its block (H),
orphan_adv += 1 # the adversary's revealed matcher is lost
adv += k
return RaceResult(adv=adv, hon=hon, orphan_hon=orphan_hon, orphan_adv=orphan_adv,
events=int(np.asarray(is_adv).size))
def race_from_alpha(alpha: float, n_events: int, gamma: float,
rng: np.random.Generator) -> RaceResult:
"""Convenience: a Bernoulli(``alpha``) event stream of ``n_events`` blocks through the race."""
is_adv = rng.random(n_events) < alpha
return simulate_selfish(is_adv, gamma, rng)
@dataclass
class RewardParams:
"""A configurable block/uncle reward schedule (all rewards as a fraction of a block reward = 1).
``w_uncle`` — paid to the *producer* of an orphaned block referenced as an uncle (GHOST/Ethereum
style). Compensates a miner whose block was orphaned by latency (§3.2).
``w_nephew`` — paid to the block that *references* an uncle (per uncle). Incentivises inclusion.
The reference game is strategic — who references whom decides who is compensated — so three rate
knobs model it (all fractions in [0,1]):
``p_ref`` — honest orphans that get referenced. Under the SOFT (reward-weighted)
inclusion rule (§6.8) this is EMERGENT and high: an honest orphan is
published, so any honest canonical block within ``W`` references it for the
nephew reward; the attacker only suppresses on its own blocks. Rises toward 1
with ``W``; driven low only by deep reorgs whose orphans age out (residual).
``p_ref_adv`` — the attacker's OWN revealed-but-lost blocks (``orphan_adv``) that it
self-uncles to recover reward. A rational attacker → 1.
``adv_nephew`` — of the honest orphans that are referenced, the fraction whose *nephew* reward
the attacker captures (its canonical block did the referencing — e.g. forced
to under a mandate). 0 without a mandate (honest blocks reference); >0 with.
Safety constraint (§6.7): self-uncling a block pays ``w_uncle + w_nephew``; orphaning a block
you could have made canonical to farm that pays iff ``w_uncle + w_nephew > 1``, so a sound
schedule needs **w_uncle + w_nephew < 1**.
"""
w_uncle: float = 0.0
w_nephew: float = 0.0
p_ref: float = 1.0
p_ref_adv: float = 0.0
adv_nephew: float = 0.0
@classmethod
def mandatory(cls, w_uncle: float, w_nephew: float = 0.0,
adv_region_frac: float = 0.5) -> RewardParams:
"""The ``p_ref = 1`` limit — every in-window uncle referenced. NOT a deployed *hard mandate*
(a validity rule cannot prove what forks a producer saw, §6.8 — rejected for fork-safety);
this is the large-``W`` / full-honest-referencer limit that the SOFT rule *approaches*. The
resulting share is ``≈ α`` with a small residual premium that grows with alpha (~0 near 1/3,
+0.006 at 0.4, +0.014 at 0.46). Two-
type model: no ``W``/``U`` queue, so ``p_ref`` is a knob, not derived — real coverage is
``< 1``, set by ``W``/visibility (see the ``p_ref`` sweep in scripts/reward_mandate.py)."""
return cls(w_uncle=w_uncle, w_nephew=w_nephew, p_ref=1.0, p_ref_adv=1.0,
adv_nephew=adv_region_frac)
@dataclass
class RewardSplit:
adv_reward: float
hon_reward: float
@property
def adv_reward_share(self) -> float:
t = self.adv_reward + self.hon_reward
return self.adv_reward / t if t else 0.0
def reward_shares(race: RaceResult, rp: RewardParams) -> RewardSplit:
"""Reward-weighted split under an uncle-reward schedule (block reward = 1 per canonical block).
Four reference flows (uncle -> producer, nephew -> referencer): honest orphans referenced
(uncle -> honest; nephew -> honest, or -> attacker for the ``adv_nephew`` fraction it must
reference); and the attacker's own lost blocks self-uncled (uncle **and** nephew -> attacker).
``adv_reward_share`` is the attacker's fraction of *total issued reward*.
"""
adv, hon = race.adv, race.hon
if adv + hon == 0:
return RewardSplit(0.0, 0.0)
ref_hon = rp.p_ref * race.orphan_hon # honest orphans compensated (uncle->honest)
ref_adv = rp.p_ref_adv * race.orphan_adv # attacker self-uncles its own lost blocks
hon_reward = (hon + rp.w_uncle * ref_hon # honest uncle producers
+ rp.w_nephew * ref_hon * (1.0 - rp.adv_nephew)) # honest nephews
adv_reward = (adv + rp.w_nephew * ref_hon * rp.adv_nephew # attacker-captured nephews
+ (rp.w_uncle + rp.w_nephew) * ref_adv) # self-uncle: producer + nephew
return RewardSplit(adv_reward=adv_reward, hon_reward=hon_reward)
def honest_reward_recovery(race: RaceResult, rp: RewardParams) -> float:
"""Fraction of honest miners' *mined* value they actually collect (fairness metric).
Without uncle rewards an honest miner loses everything for a latency-orphaned block; with them
it recovers ``w_uncle`` per referenced orphan. Returns
``(hon + w_uncle·p_ref·orphan_hon) / (hon + orphan_hon)`` — 1.0 means fully compensated.
"""
mined = race.hon + race.orphan_hon
if mined == 0:
return 1.0
return (race.hon + rp.w_uncle * rp.p_ref * race.orphan_hon) / mined
def tsi_dhat_ratio(race: RaceResult, uncle_recovery: float) -> float:
"""Equilibrium ``D̂/D*`` under a selfish attack with uncle recovery.
TSI drives the *counted* density to ``f``; the counted density is the canonical fraction plus a
recovered fraction ``uncle_recovery ∈ [0,1]`` of the orphaned HONEST blocks (referenced back as
uncles — adversary blocks reference none, §6.4). ``D̂/D* = (adv + hon + u*orphan_hon)/events``.
``uncle_recovery`` is a free knob. Against **SM1** the deployed counting rules admit its full
range — every orphan SM1 makes is countable (``race.countable_recovery == 1``). Against the
**optimal** policy they do not: use
:meth:`tsi_sim.selfish_mdp.OptimalPolicyStats.dhat_ratio`, which caps recovery at one uncle per
orphaned chain (§6.6).
"""
u = float(np.clip(uncle_recovery, 0.0, 1.0))
counted = race.adv + race.hon + u * race.orphan_hon
return counted / race.events if race.events else 1.0