2026-07-30 18:57:10 +02:00

137 lines
5.8 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 withholding vs TSI — REPORT §6.6, and the per-block issuance question.
Two panels (fig13):
LEFT — revenue share adv/(adv+hon) vs stake alpha, for gamma in {0, 0.5, 1}, with the Eyal-Sirer
closed form overlaid and the profitability thresholds marked. Above threshold the share
exceeds the diagonal (share = stake), so private-chain withholding IS profitable — the
opposite of §6.5's abstention withholding.
RIGHT — TSI coupling: the counted canonical density deflates D_hat to D*·(density fraction);
uncle references recover orphaned honest blocks back into the count, lifting D_hat toward
D* (uncle_recovery u in {0, 0.5, 1}). The mechanism that fixes the honest under-count
(§3.2) also blunts the selfish attacker's estimator deflation.
Issuance / absolute-reward note (the §6.5 GAP-2 question): TSI targets *counted* density = f, so the
canonical block rate is held at ~f regardless of the attack — the canonical "pie" does not inflate
when D_hat deflates (the extra lottery wins are orphans that earn no canonical reward). Hence for a
per-block reward schedule the adversary's ABSOLUTE reward per unit stake equals revenue_share/alpha,
identical to the share metric: §6.5's "unprofitable" abstention result is robust to per-block
issuance, and §6.6's selfish premium (revenue_share/alpha > 1 above threshold) is the real profit.
Run: python scripts/selfish_mining.py (writes runs/selfish_*.parquet + fig13)
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
from tsi_sim.plotting import style
from tsi_sim.selfish import (
race_from_alpha,
selfish_revenue_closed_form,
selfish_threshold,
tsi_dhat_ratio,
)
HERE = Path(__file__).resolve().parent.parent
RUNS = HERE / "runs"
FIGS = HERE / "report-figures"
RUNS.mkdir(exist_ok=True)
FIGS.mkdir(exist_ok=True)
N_EVENTS = 6_000_000 # per (alpha, gamma) cell; MC noise ~ 1e-3 on the share
GAMMAS = [0.0, 0.5, 1.0]
ALPHAS = [0.05, 0.10, 0.15, 0.20, 0.25, 1 / 3, 0.40, 0.45, 0.49]
def sweep() -> pd.DataFrame:
rng = np.random.default_rng(20240719)
rows = []
for gamma in GAMMAS:
for alpha in ALPHAS:
r = race_from_alpha(alpha, N_EVENTS, gamma, rng)
rows.append(dict(
alpha=alpha, gamma=gamma,
share=r.revenue_share,
closed_form=selfish_revenue_closed_form(alpha, gamma),
reward_per_stake=r.revenue_share / alpha, # absolute per-block NPV ratio
density_fraction=r.density_fraction, # D_hat/D* at u=0
dhat_u0=tsi_dhat_ratio(r, 0.0),
dhat_u50=tsi_dhat_ratio(r, 0.5),
dhat_u100=tsi_dhat_ratio(r, 1.0),
orphan_hon_frac=r.orphan_hon / r.events,
))
out = pd.DataFrame(rows)
out.to_parquet(RUNS / "selfish_sweep.parquet")
return out
def report(df: pd.DataFrame) -> None:
print(f"{'gamma':>5} {'thresh':>7} " + " ".join(f"a={a:.2f}" for a in [0.2, 1 / 3, 0.4]))
for gamma in GAMMAS:
g = df[df.gamma == gamma]
cells = []
for a in (0.2, 1 / 3, 0.4):
row = g[np.isclose(g.alpha, a)].iloc[0]
cells.append(f"{row.share:.3f}({row.reward_per_stake:.2f}x)")
print(f"{gamma:5.1f} {selfish_threshold(gamma):7.3f} " + " ".join(cells))
print("(share(reward/stake x); >1x = profitable). D_hat/D* deflation at alpha=0.4, gamma=0:")
r = df[(df.gamma == 0.0) & np.isclose(df.alpha, 0.4)].iloc[0]
print(f" u=0: {r.dhat_u0:.3f} u=0.5: {r.dhat_u50:.3f} u=1: {r.dhat_u100:.3f} "
f"(orphaned honest {r.orphan_hon_frac*100:.1f}% of blocks)")
def fig13(df: pd.DataFrame) -> None:
import matplotlib.pyplot as plt
style.apply_style()
fig, axes = plt.subplots(1, 2, figsize=(9.6, 3.8))
# LEFT: revenue share vs alpha, per gamma, with closed form + diagonal + thresholds
ax = axes[0]
aa = np.array(ALPHAS)
ax.plot(aa, aa, color="0.5", lw=0.9, ls="--", label="honest (share = stake)")
for i, gamma in enumerate(GAMMAS):
g = df[df.gamma == gamma].sort_values("alpha")
c = style.OKABE_ITO[i]
ax.plot(g.alpha, g.share, "o", ms=4, color=c)
fine = np.linspace(0.02, 0.49, 200)
ax.plot(fine, [selfish_revenue_closed_form(a, gamma) for a in fine], "-", lw=1.3,
color=c, label=rf"$\gamma={gamma}$ (EyalSirer)")
thr = selfish_threshold(gamma)
if 0 < thr < 0.5:
ax.axvline(thr, color=c, lw=0.7, ls=":")
ax.set_xlabel(r"adversary stake $\alpha$")
ax.set_ylabel("revenue share (canonical blocks)")
ax.set_title("Private-chain withholding is profitable above threshold")
ax.legend(fontsize=7, loc="upper left")
# RIGHT: TSI D_hat deflation vs alpha and uncle recovery (gamma=0, worst-case connectivity)
ax = axes[1]
g0 = df[df.gamma == 0.0].sort_values("alpha")
for u, col, lab in [("dhat_u0", style.OKABE_ITO[1], r"no uncles ($\eta$=0)"),
("dhat_u50", style.OKABE_ITO[4], r"$\eta$=0.5"),
("dhat_u100", style.OKABE_ITO[2], r"honest-orphan recovery ($\eta$=1)")]:
ax.plot(g0.alpha, g0[u], "-o", ms=4, color=col, label=lab)
ax.axhline(1.0, color="0.5", lw=0.9, ls="--", label=r"honest $D^*$")
ax.set_xlabel(r"adversary stake $\alpha$")
ax.set_ylabel(r"$\hat D / D^*$ (estimator deflation)")
ax.set_title("Selfish orphaning deflates $\\hat D$; uncles recover it")
ax.legend(fontsize=7, loc="lower left")
style.save(fig, FIGS / "fig13_selfish", provenance="scripts/selfish_mining.py")
plt.close(fig)
def main() -> None:
print("=== selfish-mining sweep (validated vs Eyal-Sirer) ===")
df = sweep()
report(df)
fig13(df)
print("wrote fig13_selfish")
if __name__ == "__main__":
main()