253 lines
13 KiB
Python
Raw Normal View History

2026-07-30 18:57:10 +02:00
"""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
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
@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
2026-07-30 18:57:10 +02:00
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``.
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
``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).
2026-07-30 18:57:10 +02:00
"""
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