mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-09 04:33:15 +00:00
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>
253 lines
13 KiB
Python
253 lines
13 KiB
Python
"""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.3–6.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:
|
||
"""Eyal–Sirer 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:
|
||
"""Eyal–Sirer 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 1–1 race state 0'.
|
||
"""
|
||
k = 0 # adversary private lead
|
||
tie = False # in the 0' (1–1 race) state
|
||
adv = hon = orphan_hon = orphan_adv = 0
|
||
for x in np.asarray(is_adv, dtype=bool):
|
||
if tie: # resolving a 1–1 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
|