diff --git a/reports/tsi/README.md b/reports/tsi/README.md index 33457a3..6f9a6d9 100644 --- a/reports/tsi/README.md +++ b/reports/tsi/README.md @@ -72,7 +72,7 @@ 1. **All nodes always agree.** In every run, at every network size and block rate tested (1 000–10 000 nodes at the production security parameter; to 32 000 in the size-scaling study, [§3.7](#s3-7); blocks every 10–30 s), all nodes computed *exactly* the same estimate — even though they constantly disagree about the newest blocks. The reason is structural: TSI measures a window buried deep in the finalized past, where every honest node sees identical history ([§3.1](#s3-1)). Two caveats keep this honest: agreement is not accuracy — nodes can unanimously agree on a wrong value (see next point) — and agreement is inherited from the common genesis value, not rebuilt: TSI maintains agreement but cannot repair a divergence that somehow arose ([Appendix C](#sC)). 2. **Network delay makes the estimate too low; uncle references fix it.** When blocks race, the losers ("orphans") drop off the chain and out of the count, so the network under-counts its own stake — by roughly a quarter to a third under Blend (the mix-network transport) delays, deepening with network size. The error is strictly one-sided: the equilibrium estimate is **bounded above by the true stake** — `D̂/D` cannot exceed 1 (no tested cell sits above 1 beyond sampling noise, which is why the accuracy plots are capped at 1; [§2.2](#s2-2)), so delay can only deflate the estimate, never inflate it. Letting each block also reference recent orphans ("uncles") puts them back into the count — counting *slots*, so a slot with two winners still counts once — and a single uncle reference per block restores the estimate to **exactly the true stake** (the recommended cap adds a one-uncle margin, [§8](#s8)) (the only residual is an optional ~1 % from on-chain rounding of the target rate, [§2.2](#s2-2)/[Appendix A](#sA)) ([§3.2](#s3-2)). 3. **Two sizing rules cover the deployments tested.** How many uncle slots a block needs (`U`) is dictated by the **load** — the number of blocks the whole network produces during the time one block needs to reach everyone (written `ρ`; formally `ρ = f·D_vis`, [§3.3](#s3-3)). The load counts the concurrent blocks each new block must be able to reference, and one uncle slot drains one of them per block — so `U` must be at least the load rounded up, plus one spare. How far back a block may reach for an orphan (`W`) is dictated by block spacing, not delay: about ten block intervals. The formal equations, their calibration, and worked examples are in [§4](#s4). -4. **Cheating doesn't pay.** Hiding blocks to deflate the estimate costs the attacker more than anyone else, heals within one epoch, and — for a full withdrawal — the lower estimate is simply the *correct* count of who is still participating ([§6.4](#s6-4)–[§6.5](#s6-5)). The one genuinely profitable attack is classic selfish mining, which needs roughly one-third of all stake and afflicts every Nakamoto-style chain, not TSI specifically; the damage it does to the estimate is repaired by the same uncle mechanism ([§6.6](#s6-6)). Rewarding uncles compensates the honest victims of delay and makes hiding strictly worse — but the reward must remain a bonus (a *soft* rule, never a validity requirement, which an attacker could turn into a tool for censoring or forking the chain), and the uncle + nephew rewards (the nephew reward pays the block that *includes* the reference) must together stay below one block reward, or deliberately orphaning one's own blocks becomes a business ([§6.7](#s6-7)–[§6.8](#s6-8)). +4. **Cheating doesn't pay.** Hiding blocks to deflate the estimate costs the attacker more than anyone else, heals within one epoch, and — for a full withdrawal — the lower estimate is simply the *correct* count of who is still participating ([§6.4](#s6-4)–[§6.5](#s6-5)). The one genuinely profitable attack is classic selfish mining, which needs roughly one-third of all stake and afflicts every Nakamoto-style chain, not TSI specifically; the damage it does to the estimate is *partly* repaired by the same uncle mechanism — about half of it against an optimally-played attack, because the counting rule can reach only the first block of each discarded fork ([§6.6](#s6-6)). Rewarding uncles compensates the honest victims of delay and makes hiding strictly worse — but the reward must remain a bonus (a *soft* rule, never a validity requirement, which an attacker could turn into a tool for censoring or forking the chain), and the uncle + nephew rewards (the nephew reward pays the block that *includes* the reference) must together stay below one block reward, or deliberately orphaning one's own blocks becomes a business ([§6.7](#s6-7)–[§6.8](#s6-8)). 5. **Reorganisations stay shallow if the load stays low.** Forks are the cost centre: a deep reorg discards confirmed blocks. Fork depth grows with delay, and uncles keep it shallow (without them the deflated estimate over-produces blocks, and honest reorgs reach ~17 deep; with them, ~4). Against a private-chain attacker holding 10–30 % of stake trying to force the deepest reorg, keeping the load below one (few honest forks) keeps reorgs bounded and shallow (single digits to low tens of blocks even at 30 % stake) — but past that, at 30 % stake and heavy delay, fork-induced orphaning tips the attacker over an effective majority and reorgs become unbounded. The same "keep `ρ < 1`" rule that keeps the estimate accurate keeps reorgs shallow ([§6.10](#s6-10)). *Method note: results come from the per-node simulator at the production security parameter k = 2160, with the robustness and size-scaling studies at a reduced k = 256 where noted; the sizing rules are semi-empirical (their form follows from the fork/orphan mechanism, their constants are fitted to the sweeps) and are derived under honest, deterministic-delay conditions — noise, attacks, and incentives are treated in [§6](#s6).* @@ -474,7 +474,7 @@ Read this before using the numbers: the regime they hold in, and the margins tha - **[§6.3](#s6-3)** uncle-suppression grinding — a bounded attack. - **[§6.4](#s6-4)** static block withholding — deflates `D̂` but faithfully tracks *active* stake. - **[§6.5](#s6-5)** dynamic withhold-rejoin grinding — unprofitable, non-persistent. -- **[§6.6](#s6-6)** selfish / private-chain withholding — the one profitable lever (above the classic stake threshold, quantified with the optimal-strategy MDP); uncle-*counting* still restores the estimate. +- **[§6.6](#s6-6)** selfish / private-chain withholding — the one profitable lever (above the classic stake threshold, quantified with the optimal-strategy MDP); uncle-*counting* restores about half of the estimator damage, bounded by the countable rule's one-uncle-per-fork ceiling. - **[§6.7](#s6-7)** block/uncle *rewards* — robustly compensate honest orphans and disincentivise hiding, but a *voluntary* reward can backfire on selfish mining. - **[§6.8](#s6-8)** a *soft* (reward-weighted) inclusion rule — the fork-safe choice (a validity rule cannot prove which forks a producer saw). The emergent reference rate stays high (honest blocks reference the published orphans), so the backfire mostly vanishes; the residual is set by the uncle window `W` (how far back a block may reach to reference an orphan; recommended 300 slots = 10/f, [§3.4](#s3-4)) and visibility. - **[§6.9](#s6-9)** multiple coalitions and bribery — the abstention commons is bounded and structure-independent; multi-coalition *selfish* mining is flagged, not solved. @@ -592,9 +592,21 @@ The curve is **within `0.09` for `β = 0.5` and `β = 1.0`** at every duty — p ![Fig 13 — selfish mining earns more than its stake above the classic threshold (left, vs the Eyal–Sirer closed form); its orphaning deflates D̂, which uncle-counting recovers (right).](report-figures/fig13_selfish.png) +**But `η` is not free — the first-fork restriction caps it, and SM1 hides that (`fig36`).** `fig13`'s recovery curve reads `η` as a knob over `[0,1]`, and against **SM1** the full range is attainable: SM1 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, every orphan it makes is the first block of its fork, and the countable model ([§2.1](#s2-1)) can reference all of them — measured `η = 1.000000` exactly, at every `α` and `γ` tested. The **optimal** policy — the one invoked above as the true profit frontier — does not oblige. It *waits*, then overrides a run of `h` honest blocks at once; that run is one chain, so only its first block has a parent on the referencing chain and the deployed rules recover **one** uncle from it, not `h`. Solving the SSZ MDP for its stationary orphan structure (`scripts/countable_selfish.py`, cap = 64) gives the ceiling directly (`γ = 0`): + +| `α` | `η` ceiling (countable) | `D̂/D*` unrestricted, `p_ref` = 1 | `D̂/D*` countable, `p_ref` = 1 | +|---|---|---|---| +| 0.34 | 0.585 | 0.926 | **0.856** | +| 0.40 | 0.441 | 0.945 | **0.811** | +| 0.46 | 0.105 | 0.955 | **0.619** | + +So **SM1 is a faithful proxy for selfish-mining revenue but not for TSI's estimator damage**: at `α = 0.4, γ = 0` its revenue share (0.484) sits within 1 % of the optimum's (0.489), while its recoverable-orphan fraction (1.00) is more than double (0.44). The repair is real but roughly **half** of what the unrestricted model shows — `0.81`, not `0.94`, at `α = 0.4` — and it moves the *wrong way* with `α`: the unrestricted recovery improves as the attacker grows (0.926 → 0.955) because a stronger attacker orphans a larger share of a shrinking honest chain, while the countable recovery **degrades** (0.856 → 0.619) because a stronger attacker waits longer and buries more blocks per fork. (Read the `α ≥ 0.42` cells as indicative: the orphan *shape* converges more slowly in the MDP's lead cap than the revenue does — `η` at `α = 0.45` still moves by ~3 % relative between cap 48 and 64, against ~0.1 % at `α = 0.4`.) The depth-maximising adversary of [§6.10](#s6-10) agrees on the magnitude from the other direction — `η = 0.52` at `α = 0.30` with the measured honest fork rate `o = 0.35` (`reorg.countable_recovery_from_depths`). Neither figure is a worst case: both adversaries optimise something else (revenue, depth), and one optimising *deflation* directly would trade revenue for fork depth. The attacker's own channel is capped by the same rule — its abandoned secret chain is also one chain, so it can self-uncle only the first block of it (`η_adv = 0.750` at `α = 0.4`), which narrows the [§6.7](#s6-7)(a) farming payoff below what the block count suggests. + +![Fig 36 — the countable recovery ceiling under a selfish adversary: SM1 leaves every orphan referenceable (flat 1.0) while the optimal policy buries them (left); the resulting D̂ is about half the repair the unrestricted model shows, and degrades with α (right).](report-figures/fig36_countable_selfish.png) + **Issuance model.** Does deflating `D̂` inflate the reward pie? No, for a per-canonical-block schedule: TSI targets *counted* density `= f`, so the canonical block rate is held at `≈ f` regardless of the attack (the extra lottery wins a deflated `D̂` produces are orphans that earn nothing). Absolute reward per unit stake therefore equals the revenue *share* metric — **[§6.5](#s6-5)'s "abstention is unprofitable" is robust under per-block issuance**, and [§6.6](#s6-6)'s selfish premium is the genuine profit channel. MEV (value extracted from transaction ordering) or a per-*raw*-block schedule would reopen this — an issuance-design question, not a TSI-estimation one. *(This clean equivalence assumes the block-only schedule; once [§6.7](#s6-7) pays and **counts** uncles, the counted density held at `f` includes recovered uncles, so the canonical rate sits slightly below `f` and the pie's size and split both shift. [§6.7](#s6-7)'s profitability is then read directly off the reward *share*, not off a fixed pie — the two subsections use different issuance premises, reconciled only in the `w_u = 0` limit.)* -**Scope.** Modelled: SM1 *and* the optimal-MDP selfish strategy with a network tie-break `γ`, a single coalition, and uncle density-recovery as the `[0,1]` knob `η`. Multi-coalition races are discussed (not solved) in [§6.9](#s6-9). The headline: **private-chain withholding is the real profit threat, bounded by the classic stake threshold; its *estimator* deflation is undone by uncle-counting, while its *profit* premium is only conditionally reduced by rewarding uncles ([§6.7](#s6-7)–[§6.8](#s6-8)).** +**Scope.** Modelled: SM1 *and* the optimal-MDP selfish strategy with a network tie-break `γ`, a single coalition, uncle density-recovery as the `[0,1]` knob `η`, and — for the optimal policy — the ceiling the countable rule puts on `η`. Multi-coalition races are discussed (not solved) in [§6.9](#s6-9); their orphan *shape*, and hence their own `η` ceiling, is unmodelled for the same reason. The reward analysis of [§6.7](#s6-7)–[§6.8](#s6-8) runs on the SM1 race, where every orphan is countable, so its numbers are unaffected by the restriction — but they inherit SM1's optimism about *which* orphans exist to be compensated. The headline: **private-chain withholding is the real profit threat, bounded by the classic stake threshold; its *estimator* deflation is roughly halved by uncle-counting rather than undone, while its *profit* premium is only conditionally reduced by rewarding uncles ([§6.7](#s6-7)–[§6.8](#s6-8)).** ### 6.7 Block/uncle rewards: robust for fairness and anti-hiding, but a *voluntary* reward can backfire on selfish mining (`fig14`, `fig15`) @@ -668,6 +680,8 @@ Everything above assumed a single coalition; this section asks what changes when **Result 3 — a confirmation-depth rule for finality.** Turning the tail into a wait-time: at the recommended operating point (`o ≈ 0.35`), to hold `P(reorg ≥ d) < 10⁻³` a receiver waits **d ≈ 4 blocks (~2 min) against a 10 % adversary, 8 (~4 min) against 20 %, 17 (~8.5 min) against 30 %**; for a `10⁻⁶` guarantee, roughly double those (8 / 15 / 34 blocks). These are the confirmation depths for probabilistic settlement. The protocol's *deterministic* finality at `k = 2160` blocks is vastly deeper than any of these, so the finality-driven `k` ([§3.1](#s3-1)) is consistent with — and far more conservative than — the reorg tail; the confirmation-depth rule matters only for parties wanting faster-than-final soft settlement. These 4/8/17 depths are the **N = 1 000, degree-6** point; the scale study below shows how they move with size and connectivity. +**Result 4 — what a reorg costs the *estimator*, and why depth is the wrong thing to be relaxed about.** Uncle references do not change the longest-chain race, so they do not change reorg *depth* — but depth changes what the references can recover. A depth-`d` reorg discards `d` **consecutive** public blocks: one chain, whose first block alone has a parent on the surviving chain, so the countable rule ([§2.1](#s2-1)) reads exactly one of the `d` back into the density and `d−1` are lost for good. Over the excursions of the deepest-reorg walk that ceiling is `attacks / blocks` (`reorg.countable_recovery_from_depths`), and at the recommended operating point (`o = 0.35`) it falls with stake: **0.85** at `α = 0.10`, **0.70** at 0.20, **0.52** at 0.30 (mean depths 1.18 / 1.43 / 1.93). This is the same ceiling [§6.6](#s6-6) measures for the revenue-optimal adversary (`η = 0.44` at `α = 0.4`), reached from the other direction — and it is why "reorgs stay shallow" is load-bearing for *accuracy* and not only for settlement: every block of extra depth is a block the estimator can never count. + **Scale (N × degree).** The honest fork rate `o`, and through it the reorg depths, rises with network size and falls with peering degree. The 99.9 % confirmation depths (blocks) against a 10/20/30 % adversary, measured over `N × degree` (`scripts/reorg_depth.py --measure-scale`, `runs/fork_rate_vs_scale.parquet`): | N | degree | fork rate `o` | d(10 %) | d(20 %) | d(30 %) | @@ -745,7 +759,7 @@ Every parameter a protocol designer or operator can set, in three groups: **prot | private-chain reorg (`α` = 0/10/20/30 %) | deepest-reorg longest-chain attack | `α` = the attacking coalition's stake fraction — the selfish-mining notation for `β_adv` above, and distinct from the TSI learning rate `β` ([§6.3](#s6-3)); reorg-depth tail `(α_eff/(1−α_eff))^d` with the *effective* share `α_eff=α/(α+(1−α)(1−o))` (`o` = honest fork rate); shallow at ρ<1, unbounded at 30 % once forks push `α_eff>½` | [§6.10](#s6-10), figs 27, 28 | | `fork_rate`, `max_reorg_depth` (engine outputs) | measured fork structure | honest fork rate 0.2→0.6 over δ=2→32 s; max reorg depth 2→4 (U=2), up to 17 at U=0 | [§6.10](#s6-10), fig28 | | `α`, `γ` (selfish race) | private-chain withholding | profitable above `(1−γ)/(3−2γ)` (⅓ at γ = 0); the optimal MDP barely beats SM1 at γ = 0 | [§6.6](#s6-6), figs 13, 14 | -| `η` — uncle-recovery fraction | estimator repair under selfish orphaning | `D̂` 0.70 → 0.82 → 0.94 as η 0 → 0.5 → 1 (α = 0.4) | [§6.6](#s6-6), fig13 | +| `η` — uncle-recovery fraction | estimator repair under selfish orphaning | `D̂` 0.70 → 0.82 → 0.94 as η 0 → 0.5 → 1 (α = 0.4). **Not a free knob:** the countable rule caps it at one uncle per discarded fork — `η = 1` exactly under SM1, but **0.44** under the optimal policy (α = 0.4, γ = 0), landing `D̂` at 0.81 | [§6.6](#s6-6), fig13, fig36 | | `p_ref`, `p_ref_adv`, `adv_nephew` (`RewardParams`) | the strategic reference game under rewards | selfish share falls below block-only once `p_ref ≳ 0.3` (α ≥ 0.4); attacker self-uncling lowers its own share (0.406 → 0.394) | [§6.7](#s6-7)/6.8, fig15 | | `windowed_fork_choice`, `prune_arrival` | engine exactness | bit-exact at jitter = 0; [§6.1](#s6-1) re-ran the exact oracle (both off) under jitter | [§6.1](#s6-1) | @@ -770,7 +784,7 @@ Everything below is assembled from this report's own measurements; each row cite | 6 | operating point | **`ρ = f·D_vis < 1` with margin** | the fitted static feedback map folds near ρ ≈ 1 (provisioning warning — the full dynamics never reach it); grinding is weak at ρ < 1 (1.04×) but cheap at ρ > 1 (1.43×); chronic ρ > 1 under-count persists after any attack ends. ρ < 1 is simultaneously the honest-accuracy and the adversarial safeguard | [§6.2](#s6-2) fig7; [§6.3](#s6-3) fig8; [§6.5](#s6-5)(iv) fig12 | | 7 | genesis / initialization | **genesis `D̂` is a single protocol constant, identical at every node** — never client-configurable | consensus is an *initialization* property, not a restoring force: an injected per-node spread is conserved forever (max/min ≈ 3.0, flat), while a *common* wrong guess self-corrects in ~2 epochs | [Appendix C](#sC) (fig21); [§3.2](#s3-2) fig1 | | 8 | uncle validity precondition | **a counted uncle must be a real lottery winner for its slot — already enforced on-chain by the spec's ZK Proof-of-Leadership** | fabrication is cryptographically impossible (the ZK Proof-of-Leadership cannot be forged); this precondition underlies every [§6.3](#s6-3)–[§6.5](#s6-5) bound and caps uncle issuance at `≤ U·(w_u + w_n)` per block | [§6.3](#s6-3), [§6.7](#s6-7)(a) | -| 9 | uncles in the density | **recovered uncles enter the TSI counted density** (independent of rewarding them) | restores `D̂` under honest latency (0.64–0.74 → 1.0) *and* under selfish orphaning (0.70 → 0.94 at η = 1) — a safety win with no reward-schedule dependence | [§3.2](#s3-2) fig2; [§6.6](#s6-6) fig13 | +| 9 | uncles in the density | **recovered uncles enter the TSI counted density** (independent of rewarding them) | restores `D̂` under honest latency (0.64–0.74 → 1.0) *and*, partially, under selfish orphaning — a safety win with no reward-schedule dependence. Size the *partial*: honest recovery is full because latency orphans are single blocks, but an optimally-played selfish attack discards multi-block forks of which the countable rule reaches only the first, so recovery there is `0.70 → 0.81` at α = 0.4 (not the `0.94` an unrestricted count would give) and falls further as α grows | [§3.2](#s3-2) fig2; [§6.6](#s6-6) fig13, fig36 | | 10 | inclusion rule | **soft (reward-weighted) — never a validity rule** | a hard mandate cannot be encoded fork-safely: the objective encoding is a censorship weapon, the subjective one forks consensus with no adversary; under the soft rule `p_ref` is emergent and high — at `p_ref ≈ 0.85` the selfish share (0.420 at α = 0.4) is already below block-only (0.484) and near stake | [§6.8](#s6-8) fig15 | | 11 | reward schedule | **substantial `w_u` (Ethereum's GHOST-style uncle rewards are the precedent); `w_u + w_n < 1` strictly enforced** | farming a real leadership win into a self-uncle + self-nephew pays `w_u + w_n` against an honest block's 1 — the binding invariant is the *sum* (0.875 + 0.03125 = 0.906 is safe; 0.875 + 0.15 = 1.025 is not); `w_u` compensates honest orphans (recovery → 1.0) and makes hiding forfeit both rewards | [§6.7](#s6-7)(a), figs 14, 15 | | 12 | peering degree | **degree 4 is acceptable for smaller networks — up to N ≈ 2×10⁵ it keeps the load under 0.9 at the 8-s budget; use ≥ 6 beyond, and scale with the target size** | degree sets the gossip-diameter slope `ℓ_mean ≈ a_d·ln N`, and at a fixed blending budget that slope decides when `U = 1` runs out: at δ = 8 s, degree 4 exhausts U = 1 near **N* ≈ 8×10⁵** (its ladder curve already touches the recovery bar at N = 32 000), degree 6 holds to N ~ 4×10⁹, degree 8 to ≈ 6×10¹² — one degree step (4 → 6) buys ~5 000× more network at the same budget. Degree and the uncle cap are exchangeable defences against growth: densify the graph or add an uncle slot, whichever is cheaper. Plus the small-N benefit of better U = 0 recovery (tip agreement is comparable: deg 6 0.985–0.993 vs deg 4 0.988–0.994) | [§3.7](#s3-7), figs 23, 24; fig19; [Appendix C](#sC) (fig18) | @@ -787,13 +801,13 @@ Everything below is assembled from this report's own measurements; each row cite **Why soft, not hard, inclusion.** "Owed" cannot be proven — no node can prove which forks a producer saw. An objective slot-gap encoding lets an attacker withhold-then-late-release an uncle to retroactively invalidate honest blocks (censorship); a visibility encoding is subjective and forks consensus with no adversary at all ([§6.8](#s6-8)). The soft rule concedes only a small residual (quantified in [§8.3](#s8-3) item 3): a bounded near-threshold backfire that vanishes as the emergent `p_ref` rises, and a per-α premium that stays ≤ +0.014 even at full inclusion. -**What TSI defends, and what it measures.** Abstention-style withholding is strictly unprofitable (reward/stake 0.95 → 0.31 for duty 0.10 → 0.75 at β_adv = 0.3), bounded as griefing (deflation = β_adv·ψ), non-persistent, and does not stack across coalitions ([§6.5](#s6-5), [§6.9](#s6-9)) — and a full withdrawal is *correctly measured*, not mis-measured: `D̂ → (1−β_adv)` is the right answer for the participating stake ([§6.4](#s6-4)). The one profitable lever is classic selfish mining above `(1−γ)/(3−2γ)` — a general Nakamoto-PoS result, not TSI-specific; TSI's added exposure (estimator deflation to 0.70 at α = 0.4) is repaired by uncle-counting (row 9), and the profit premium is blunted, not eliminated, by the soft-rewarded references (row 10). The adversary frontier is exactly optimal selfish mining; no compounding lever remains ([§6.7](#s6-7)). +**What TSI defends, and what it measures.** Abstention-style withholding is strictly unprofitable (reward/stake 0.95 → 0.31 for duty 0.10 → 0.75 at β_adv = 0.3), bounded as griefing (deflation = β_adv·ψ), non-persistent, and does not stack across coalitions ([§6.5](#s6-5), [§6.9](#s6-9)) — and a full withdrawal is *correctly measured*, not mis-measured: `D̂ → (1−β_adv)` is the right answer for the participating stake ([§6.4](#s6-4)). The one profitable lever is classic selfish mining above `(1−γ)/(3−2γ)` — a general Nakamoto-PoS result, not TSI-specific; TSI's added exposure (estimator deflation to 0.70 at α = 0.4) is *partly* repaired by uncle-counting (row 9 — to 0.81, not the 0.94 an unrestricted count would reach, because the deployed rule can reference only the first block of each discarded fork), and the profit premium is blunted, not eliminated, by the soft-rewarded references (row 10). The adversary frontier is exactly optimal selfish mining; no compounding lever remains ([§6.7](#s6-7)). ### 8.3 Residual risks and open items 1. **The ρ ≈ 1 fold ([§6.2](#s6-2)).** The fitted static map is bistable at the recipe's own boundary; the full dynamics never reach it, but "U = ⌈ρ⌉ suffices" is an upper edge, not a safe interior — row 6 is a hard provisioning requirement, not a preference. -2. **Multi-coalition selfish mining is open ([§6.9](#s6-9)).** The literature threshold can fall below ⅓ and total orphaning can exceed the single-coalition value, so `D̂ ≈ 0.70` at α = 0.4 is not an upper bound. Flagged, not solved. +2. **Multi-coalition selfish mining is open ([§6.9](#s6-9)).** The literature threshold can fall below ⅓ and total orphaning can exceed the single-coalition value, so `D̂ ≈ 0.70` at α = 0.4 is not an upper bound. Flagged, not solved. The [§6.6](#s6-6) countable ceiling adds a second unmodelled dimension: competing coalitions orphan each other in *runs* as well as in volume, and the recoverable share depends on that shape, not just on the total (item 16). 3. **Near-threshold reward backfire ([§6.7](#s6-7)–[§6.8](#s6-8)).** At α ≈ 0.35 a small backfire (≤ +0.03 above block-only for `p_ref ≳ 0.3`, rising to +0.05 at the unreachable `p_ref = 0`) persists until `p_ref ≳ 0.75`; even `p_ref = 1` leaves a premium growing with α. 4. **No re-convergence ([Appendix C](#sC)).** Any mechanism that could inject persistent per-node `D̂` disagreement (genesis/clock exploit) would never be self-corrected — safety rests entirely on row 7. 5. **Adversarial `U` margin unquantified ([§6.8](#s6-8)).** Under attack-inflated orphaning (orphan/blocks ≈ 0.35 at α = 0.46) the honest-load cap may need extra margin (owed uncles beyond `U` defer and can age out of `W`); this report does not size it. @@ -807,6 +821,7 @@ Everything below is assembled from this report's own measurements; each row cite 13. **Reward payout mechanism unspecified ([§8.5](#s8-5)).** Rows 10–11 give the incentive constraints, not the encoding. Header-count indistinguishability (Blend) and the equal-share, content-independent voucher of the Anonymous Leaders Reward Protocol are the binding constraints on any `w_u`/`w_n` schedule, and reconciling them — including a payout path for a producer whose block is off-chain — is spec work left open here. 14. **Temporal resolution — `D̂` is stale by design ([§6.11](#s6-11)).** With `β = 1`, `D̂` tracks active stake at a one-epoch (≈ 7.5-day) lag, so it reflects participating stake from ~1–2 weeks ago and updates only weekly; a genuinely *sub-epoch* swing (daily or intra-week churn) falls below the estimator's Nyquist limit and is not tracked as a cycle at all. This is the correct trade for organic churn (row 5), but it bounds how fast TSI can follow real participation changes — flagged, not a defect. 15. **Correlated latency untested ([§2](#s2)).** Both link distributions draw each link *independently*, so the sweeps probe the latency marginal, not its spatial structure; geographically **correlated** latency (regional clustering that lets co-located nodes fork as a bloc) is not modelled. In the primary Blend regime the per-hop mixing delay dominates the geographic link term, so this is expected to stay second-order — but it is untested. +16. **The deflation-optimal adversary is unquantified ([§6.6](#s6-6)).** The countable recovery ceilings measured there come from adversaries optimising something *else* — revenue (the SSZ MDP: `η = 0.44` at α = 0.4) and reorg depth (`η = 0.52` at α = 0.30). An adversary optimising the *estimator* directly would trade revenue for fork depth, since every block buried behind a fork's first is permanently uncountable while a deeper fork costs it only the marginal revenue of waiting. Both measured ceilings are therefore upper bounds on `η` — best-case-for-the-defender — not lower bounds on the damage; sizing the deflation-optimal policy is future work. ### 8.4 Capstone: the whole recipe, in one run @@ -878,6 +893,7 @@ Sweep studies are committed configs, run with `make ` (writes a dated `run | dynamic withhold-rejoin grinding | `scripts/dynamic_withhold.py` (`adversary_period`, `adversary_withhold_epochs`) | [§6.5](#s6-5) | | selfish / private-chain withholding | `scripts/selfish_mining.py` (`src/tsi_sim/selfish.py`) | [§6.6](#s6-6) | | optimal selfish (SSZ MDP) + uncle rewards | `scripts/selfish_rewards.py` (`src/tsi_sim/selfish_mdp.py`, `RewardParams`) | [§6.6](#s6-6), [§6.7](#s6-7) | +| **countable recovery ceiling under a selfish adversary** (the `η` cap: SM1 vs the optimal policy) | `scripts/countable_selfish.py` (`selfish_mdp.optimal_policy_stats`, `reorg.countable_recovery_from_depths`); fig36 | [§6.6](#s6-6) | | soft uncle inclusion (reward share vs emergent `p_ref`) | `scripts/reward_mandate.py` | [§6.8](#s6-8), [§6.9](#s6-9) | | U = 0 fluctuation series (zero delay, k ∈ {256, 1024, 2160}) | `scripts/appendix_fluct.py --run` (`runs/fluctuation_u0.parquet`) | [Appendix B](#sB) | | CI smoke grid + analytic sanity checks | `configs/smoke.yaml`; `scripts/verify.py` (`make verify`) — validation only, no figures | — | @@ -893,7 +909,7 @@ Because the two models draw independent RNG streams, every countable-vs-unrestri All studies were **re-run on 2026-07-23/24 with the corrected slot-counting mechanism** ([§2.1](#s2-1)) and the early-stop optimisation; the resilient batch is `scripts/run_all_reruns.sh` (per-step log in `runs/rerun_status.log`). Canonical run directories (latest): fullscale N=5000/10000 = `2026-07-24_094519_fullscale`; fullscale N=1000/2000 = `2026-07-23_171803_fullscale-small`; uncle-window = `2026-07-24_001456`; window-uncles = `2026-07-24_014240`; block-rate = `2026-07-24_043943`; blend-hops-delay = `2026-07-24_064052`; window-scale = `2026-07-24_085234`; latency-shape = `2026-07-24_090014_expdist`; stake-tail = `2026-07-24_090044_pareto133`; heterogeneous-start = `2026-07-24_090114_default`; N-scaling = the `nscaling-{a,b}` + `nscaling32-{a,b}` runs; adversary grids = `runs/adversary_grid/`; jitter = `runs/jitter_grid/`; bootstrap = `runs/bootstrap_fullscale/`; fluctuation = `runs/fluctuation_u0.parquet`; fork-rate = `runs/fork_rate_vs_delay.parquet`; ρ-boundary = `2026-07-27_195627_rho-boundary`. Referencing-model studies: countable-vs-unrestricted (paired, primary) = `2026-08-05_120804_cvo-paired-countable` / `2026-08-05_123403_cvo-paired-old`; the superseded unpaired pair = `2026-08-04_103536_cvo-countable` / `2026-08-04_104010_cvo-old`; window absorption = `2026-08-04_104633_absorption-window`; fine delay band (unpaired) = `2026-08-04_191441_fine-countable` / `2026-08-04_195353_fine-old`; fine delay band (paired, primary) = `2026-08-05_111109_fine-paired-countable` / `2026-08-05_113218_fine-paired-old`. -Figures are embedded from [`report-figures/`](report-figures) and versioned here alongside the report; the simulator folder does **not** commit its own generated figures, so the copies checked in here are the figures of record. Figures are in `report-figures/` (`fig1`–`fig29`, plus [Appendix B](#sB)'s `figB1`–`figB2`; numbering is generation order, not order of appearance). Committed generators: `fig1` (bootstrap, k=2160) by `scripts/bootstrap_dynamics.py`; `fig2`,`fig4`,`fig5`,`fig17`–`fig22` by `scripts/regenerate_extra_figs.py` from the latest sweeps (`fig3` hops×delay×U grid by `scripts/hops_delay_grid.py`, `fig6` (block-rate `U_min` grid + ρ-collapse) rendered ad hoc from `runs/2026-07-24_043943_block-rate` with no committed generator; `fig26` deficit-vs-ρ by `scripts/rho_boundary_analysis.py`) (fullscale-derived `fig17`–`fig20` pool both sizes in that run, N = 5 000 and N = 10 000 — the generators filter on stake_dist/topology/degree/init_dest only, never on `n_nodes`); `fig8`,`fig9` by `scripts/adversary_figs.py` from `runs/adversary_grid/`; `fig10`–`fig12` by `scripts/dynamic_withhold.py`; `fig13`–`fig15` by `scripts/selfish_mining.py`/`selfish_rewards.py`/`reward_mandate.py`; `fig16` by `scripts/stake_vs_delay.py`; `fig23`–`fig24` by `scripts/nscaling_analysis.py`; `fig25` by `scripts/window_scale_analysis.py`; `fig27`–`fig28` by `scripts/reorg_depth.py` (fork rates via `--measure`; private-chain model `src/tsi_sim/reorg.py`); `fig29` by `scripts/churn.py`; `figB1`–`figB2` by `scripts/appendix_fluct.py`. `fig7` (feedback fixed-point) is an analytic overlay; `fig30`–`fig33` (countable-vs-unrestricted accuracy, `q_u`-prediction check, recovery rate, absorption-window sweep) by `scripts/plot_countable_vs_old.py` from the `cvo-countable`/`cvo-old`/`absorption-window` runs; `fig34`–`fig35` (design-regime accuracy and the model gap with 95 % CIs) by `scripts/plot_fine_delay.py` from the `fine-countable`/`fine-old` runs. Every figure type the per-node simulator generates appears in this report, and the fork-rate/reorg-depth study closes the previous reproducibility gap for the adversarial figures (`fig8`,`fig9` now have committed generators from `runs/adversary_grid/`). +Figures are embedded from [`report-figures/`](report-figures) and versioned here alongside the report; the simulator folder does **not** commit its own generated figures, so the copies checked in here are the figures of record. Figures are in `report-figures/` (`fig1`–`fig29`, plus [Appendix B](#sB)'s `figB1`–`figB2`; numbering is generation order, not order of appearance). Committed generators: `fig1` (bootstrap, k=2160) by `scripts/bootstrap_dynamics.py`; `fig2`,`fig4`,`fig5`,`fig17`–`fig22` by `scripts/regenerate_extra_figs.py` from the latest sweeps (`fig3` hops×delay×U grid by `scripts/hops_delay_grid.py`, `fig6` (block-rate `U_min` grid + ρ-collapse) rendered ad hoc from `runs/2026-07-24_043943_block-rate` with no committed generator; `fig26` deficit-vs-ρ by `scripts/rho_boundary_analysis.py`) (fullscale-derived `fig17`–`fig20` pool both sizes in that run, N = 5 000 and N = 10 000 — the generators filter on stake_dist/topology/degree/init_dest only, never on `n_nodes`); `fig8`,`fig9` by `scripts/adversary_figs.py` from `runs/adversary_grid/`; `fig10`–`fig12` by `scripts/dynamic_withhold.py`; `fig13`–`fig15` by `scripts/selfish_mining.py`/`selfish_rewards.py`/`reward_mandate.py`; `fig16` by `scripts/stake_vs_delay.py`; `fig23`–`fig24` by `scripts/nscaling_analysis.py`; `fig25` by `scripts/window_scale_analysis.py`; `fig27`–`fig28` by `scripts/reorg_depth.py` (fork rates via `--measure`; private-chain model `src/tsi_sim/reorg.py`); `fig29` by `scripts/churn.py`; `figB1`–`figB2` by `scripts/appendix_fluct.py`. `fig7` (feedback fixed-point) is an analytic overlay; `fig30`–`fig33` (countable-vs-unrestricted accuracy, `q_u`-prediction check, recovery rate, absorption-window sweep) by `scripts/plot_countable_vs_old.py` from the `cvo-countable`/`cvo-old`/`absorption-window` runs; `fig34`–`fig35` (design-regime accuracy and the model gap with 95 % CIs) by `scripts/plot_fine_delay.py` from the `fine-countable`/`fine-old` runs; `fig36` (the countable recovery ceiling under a selfish adversary) by `scripts/countable_selfish.py`, which solves the MDP directly and writes `runs/countable_selfish.parquet` + `runs/countable_selfish_reorg.parquet` (no sweep input). Every figure type the per-node simulator generates appears in this report, and the fork-rate/reorg-depth study closes the previous reproducibility gap for the adversarial figures (`fig8`,`fig9` now have committed generators from `runs/adversary_grid/`). --- diff --git a/reports/tsi/report-figures/fig36_countable_selfish.png b/reports/tsi/report-figures/fig36_countable_selfish.png new file mode 100644 index 0000000..e49a84e Binary files /dev/null and b/reports/tsi/report-figures/fig36_countable_selfish.png differ diff --git a/tools/simulators/tsi/tsi-sim-pernode/scripts/countable_selfish.py b/tools/simulators/tsi/tsi-sim-pernode/scripts/countable_selfish.py new file mode 100644 index 0000000..cda28c6 --- /dev/null +++ b/tools/simulators/tsi/tsi-sim-pernode/scripts/countable_selfish.py @@ -0,0 +1,197 @@ +"""What the first-fork restriction costs against a selfish adversary — REPORT §6.6 (fig36). + +§6.6 reads the estimator repair off a free knob: the uncle-recovery fraction ``eta``, quoted at +``eta = 1`` ("honest-orphan recovery"). The countable uncle model (§2.1) can reference only the +**first block of a fork**, so ``eta`` is not free — it is capped by how the adversary *shapes* the +blocks it orphans: + + * **SM1** 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. Every orphan + it makes is the first block of its fork and the cap is exactly 1 — the restriction is free. + * The **optimal** (Sapirshtein–Sompolinsky–Zohar) policy *waits*, then overrides a run of ``h`` + honest blocks at once. That run is one chain, so the deployed rules recover **one** uncle from + it, not ``h``, and the cap falls to ~0.44 at ``alpha = 0.4``. + +So SM1 is a faithful proxy for selfish-mining *revenue* (§6.6 quotes 0.484 vs the optimum's 0.488 +at gamma = 0) but **not** for TSI's estimator damage: the two differ by a factor of ~2 in +recoverable orphans. The panel on the right is the consequence — the repair §6.6 credits to uncle +counting is roughly half of what the unrestricted model shows, and it *degrades* with alpha where +the unrestricted model improves. + +Two panels (fig36): + LEFT — the recovery ceiling ``eta_countable`` vs stake, per gamma, against SM1's flat 1.0. + RIGHT — the resulting ``D_hat/D*`` at gamma = 0: no uncles, countable (p_ref = 1 and 0.85), and + the unrestricted baseline §6.6 reports. + +Run: python scripts/countable_selfish.py (writes runs/countable_selfish.parquet + fig36) +""" + +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.reorg import ( + alpha_effective, + countable_recovery_from_depths, + simulate_deepest_reorg, +) +from tsi_sim.selfish import race_from_alpha, selfish_threshold, tsi_dhat_ratio +from tsi_sim.selfish_mdp import optimal_policy_stats + +HERE = Path(__file__).resolve().parent.parent +RUNS = HERE / "runs" +FIGS = HERE / "report-figures" +RUNS.mkdir(exist_ok=True) +FIGS.mkdir(exist_ok=True) + +# cap 64 keeps the orphan *shape* converged (it settles more slowly than the revenue: the drift +# from cap 48 to 64 is ~3e-4 in eta at alpha = 0.4, ~5e-3 at alpha = 0.45). +CAP = 64 +GAMMAS = [0.0, 0.5] +ALPHAS = [0.26, 0.28, 0.30, 0.34, 0.36, 0.38, 0.40, 0.42, 0.44, 0.46] +P_REF_REALISTIC = 0.85 # the §6.8 stand-in for the emergent honest-referencer rate +N_EVENTS = 4_000_000 # SM1 comparison arm + + +def sweep() -> pd.DataFrame: + rng = np.random.default_rng(20260805) + rows = [] + for gamma in GAMMAS: + for alpha in ALPHAS: + s = optimal_policy_stats(alpha, gamma, cap=CAP) + sm1 = race_from_alpha(alpha, N_EVENTS, gamma, rng) + rows.append(dict( + alpha=alpha, gamma=gamma, cap=CAP, + above_threshold=alpha > selfish_threshold(gamma), + deviates=s.deviates, + revenue_opt=s.revenue, + revenue_sm1=sm1.revenue_share, + density_fraction=s.density_fraction, + orphan_hon_blocks=s.orphan_hon_blocks, + orphan_hon_runs=s.orphan_hon_runs, + eta_countable=s.countable_recovery if s.deviates else np.nan, + eta_countable_adv=s.countable_recovery_adv if s.deviates else np.nan, + # the estimator, as §6.6 reports it (unrestricted) and as deployed (countable) + dhat_u0=s.dhat_ratio(p_ref=0.0), + dhat_unrestricted=s.dhat_ratio(p_ref=1.0, countable=False), + dhat_countable=s.dhat_ratio(p_ref=1.0, countable=True), + dhat_countable_pref=s.dhat_ratio(p_ref=P_REF_REALISTIC, countable=True), + # SM1's own repair, for contrast: eta = 1 is attainable there + dhat_sm1_eta1=tsi_dhat_ratio(sm1, 1.0), + eta_sm1=sm1.countable_recovery, + )) + out = pd.DataFrame(rows) + out.to_parquet(RUNS / "countable_selfish.parquet") + return out + + +def report(df: pd.DataFrame) -> None: + print(f"{'gamma':>5} {'alpha':>6} {'rev_opt':>8} {'rev_SM1':>8} {'eta_cnt':>8} {'eta_SM1':>8} " + f"{'no unc':>7} {'unrestr':>8} {'count':>7} {'cnt@.85':>8}") + for _, r in df[df.deviates].iterrows(): + print(f"{r.gamma:5.1f} {r.alpha:6.2f} {r.revenue_opt:8.4f} {r.revenue_sm1:8.4f} " + f"{r.eta_countable:8.4f} {r.eta_sm1:8.4f} {r.dhat_u0:7.4f} " + f"{r.dhat_unrestricted:8.4f} {r.dhat_countable:7.4f} {r.dhat_countable_pref:8.4f}") + hit = df[(df.gamma == 0.0) & np.isclose(df.alpha, 0.40)] + if not hit.empty: + r = hit.iloc[0] + print(f"\nHeadline (alpha=0.40, gamma=0): revenue {r.revenue_sm1:.3f} (SM1) vs " + f"{r.revenue_opt:.3f} (optimal) — a faithful proxy;") + print(f" but eta 1.000 (SM1) vs {r.eta_countable:.3f} (optimal) — not a faithful proxy, " + f"and D_hat {r.dhat_unrestricted:.3f} -> {r.dhat_countable:.3f}.") + + +def fig36(df: pd.DataFrame) -> None: + import matplotlib.pyplot as plt + style.apply_style() + fig, axes = plt.subplots(1, 2, figsize=(9.6, 3.8)) + + # LEFT: the recovery ceiling, optimal policy vs SM1 + ax = axes[0] + ax.axhline(1.0, color="0.5", lw=1.1, ls="--", + label="SM1 — every orphan countable") + for i, gamma in enumerate(GAMMAS): + g = df[(df.gamma == gamma) & df.deviates].sort_values("alpha") + ax.plot(g.alpha, g.eta_countable, "-o", ms=4, color=style.OKABE_ITO[i + 1], + label=rf"optimal policy, $\gamma={gamma}$") + thr = selfish_threshold(gamma) + ax.axvline(thr, color=style.OKABE_ITO[i + 1], lw=0.7, ls=":") + ax.set_ylim(0, 1.08) + ax.set_xlabel(r"adversary stake $\alpha$") + ax.set_ylabel(r"countable recovery ceiling $\eta$") + ax.set_title("The optimum buries orphans SM1 leaves reachable") + ax.legend(fontsize=7, loc="lower left") + + # RIGHT: the estimator consequence at gamma = 0 + ax = axes[1] + g0 = df[(df.gamma == 0.0) & df.deviates].sort_values("alpha") + ax.axhline(1.0, color="0.5", lw=0.9, ls="--", label=r"honest $D^*$") + ax.plot(g0.alpha, g0.dhat_unrestricted, "-s", ms=4, color=style.OKABE_ITO[2], + label=r"unrestricted count, $p_{ref}=1$ (fig13's $\eta=1$)") + ax.plot(g0.alpha, g0.dhat_countable, "-o", ms=4, color=style.OKABE_ITO[3], + label=r"countable, $p_{ref}=1$ (deployed rule)") + ax.plot(g0.alpha, g0.dhat_countable_pref, "-^", ms=4, color=style.OKABE_ITO[5], + label=rf"countable, $p_{{ref}}={P_REF_REALISTIC}$") + ax.plot(g0.alpha, g0.dhat_u0, "-v", ms=4, color=style.OKABE_ITO[1], + label=r"no uncles ($\eta=0$)") + ax.set_xlabel(r"adversary stake $\alpha$") + ax.set_ylabel(r"$\hat D / D^*$ (estimator deflation)") + ax.set_title(r"About half the repair fig13's $\eta=1$ implies") + ax.legend(fontsize=7, loc="lower left") + + style.save(fig, FIGS / "fig36_countable_selfish", provenance="scripts/countable_selfish.py") + plt.close(fig) + + +def reorg_ceilings() -> pd.DataFrame: + """The same ceiling for the *depth*-maximising adversary of §6.10, for cross-reference. + + A depth-``d`` reorg discards ``d`` consecutive public blocks — one chain, one countable uncle. + Reported at the honest fork rate ``o = 0`` and at the measured Blend value ``o = 0.35``, which + inflates the adversary's effective share and so its reorg depths. + """ + rows = [] + for o in (0.0, 0.35): + for alpha in (0.10, 0.20, 0.30): + ae = alpha_effective(alpha, o) + depths = simulate_deepest_reorg(ae, 4_000_000, np.random.default_rng(5)) + rows.append(dict(alpha=alpha, orphan_rate=o, alpha_eff=ae, attacks=int(depths.size), + mean_depth=float(depths.mean()), + eta_countable=countable_recovery_from_depths(depths))) + out = pd.DataFrame(rows) + out.to_parquet(RUNS / "countable_selfish_reorg.parquet") + print("\n=== depth-maximising adversary (§6.10) — same first-fork ceiling ===") + print(f"{'alpha':>6} {'o':>5} {'a_eff':>7} {'E[d]':>6} {'eta_cnt':>8}") + for _, r in out.iterrows(): + print(f"{r.alpha:6.2f} {r.orphan_rate:5.2f} {r.alpha_eff:7.4f} {r.mean_depth:6.3f} " + f"{r.eta_countable:8.4f}") + return out + + +def main() -> None: + import argparse + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--reuse", action="store_true", + help="re-render from runs/countable_selfish.parquet instead of re-solving the " + "MDP (the solve is ~15 min; the figure is not)") + args = ap.parse_args() + + cached = RUNS / "countable_selfish.parquet" + if args.reuse and cached.exists(): + print(f"=== re-rendering from {cached.name} ===") + df = pd.read_parquet(cached) + else: + print(f"=== countable recovery under a selfish adversary (MDP cap={CAP}) ===") + df = sweep() + reorg_ceilings() + report(df) + fig36(df) + print("wrote fig36_countable_selfish") + + +if __name__ == "__main__": + main() diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/reorg.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/reorg.py index a537084..9050199 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/reorg.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/reorg.py @@ -74,3 +74,20 @@ def simulate_deepest_reorg(alpha_eff: float, n_events: int, peak = 0 # honest block at lead 0 -> canonical growth, no attack in progress return np.asarray(depths, dtype=np.int64) + + +def countable_recovery_from_depths(depths: np.ndarray) -> float: + """Share of reorg-discarded public blocks the countable model can reference back (§6.6). + + A depth-``d`` reorg displaces ``d`` *consecutive* public blocks. They form one chain rooted at + the fork point, and the countable model can reference only the block whose parent lies on the + referencing chain — so one of the ``d`` is recoverable into the density and ``d-1`` are not. + Over a run of attacks that ceiling is ``attacks / blocks = len(depths) / sum(depths)``. + + This is the depth-*maximising* adversary's value; the revenue-maximising one is + :func:`tsi_sim.selfish_mdp.optimal_policy_stats`. Neither is a worst case for the estimator — + an adversary optimising deflation directly would trade revenue for depth. + """ + d = np.asarray(depths, dtype=np.int64) + total = int(d.sum()) + return (d.size / total) if total else 1.0 diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/selfish.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/selfish.py index b569e5b..4833e86 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/selfish.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/selfish.py @@ -63,6 +63,29 @@ class RaceResult: """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. @@ -217,6 +240,12 @@ def tsi_dhat_ratio(race: RaceResult, uncle_recovery: float) -> float: 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 diff --git a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/selfish_mdp.py b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/selfish_mdp.py index fc1e52c..3b3c53d 100644 --- a/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/selfish_mdp.py +++ b/tools/simulators/tsi/tsi-sim-pernode/src/tsi_sim/selfish_mdp.py @@ -13,10 +13,19 @@ bisection, solving each inner MDP by relative value iteration. The optimal revenue is an upper bound on any selfish adversary's take; it lower-bounds the honest stake threshold above which deviating pays. Used in §6.6 to bracket the real profit frontier. + +:func:`optimal_policy_stats` reads a second quantity off the same solution: how the orphaned +blocks are *shaped*. The countable uncle model (§2.1) can reference only the **first block of a +fork**, so an override that discards ``h`` honest blocks — one chain — yields one countable +uncle, not ``h``. SM1 never lets the honest branch grow past 1 before acting, so under SM1 every +orphan is countable; the optimum *waits*, and that is what the first-fork restriction cannot +recover (§6.6). """ from __future__ import annotations +from dataclasses import dataclass + import numpy as np # fork labels @@ -34,46 +43,53 @@ def _build_states(cap: int): def _transitions(a, h, f, action, alpha, gamma, cap): - """Legal-action transition list: [(prob, (a',h',f'), adv_reward, hon_reward)]. + """Legal-action transition list: [(prob, (a',h',f'), adv_reward, hon_reward, orph_hon, + orph_adv)]. Returns None if the action is illegal in this state. At the cap, only adopt/override remain so the chain stays bounded (the optimal policy resolves long before the cap in the tested range). + + ``orph_hon`` / ``orph_adv`` are the honest / adversary blocks *discarded* on that branch. They + are carried here rather than re-derived downstream so the orphan accounting cannot drift from + the race logic. Each is a **contiguous chain** rooted at the fork point, which is what makes the + countable model's first-fork rule recover exactly one of them (:func:`optimal_policy_stats`). """ beta = 1.0 - alpha at_cap = a >= cap or h >= cap if action == ADOPT: - # abandon the secret chain; the h honest blocks are confirmed, then one block is mined - return [(alpha, (1, 0, IRRELEVANT), 0, h), - (beta, (0, 1, IRRELEVANT), 0, h)] + # abandon the secret chain; the h honest blocks are confirmed, then one block is mined. + # The a secret blocks are discarded as one chain off the fork point. + return [(alpha, (1, 0, IRRELEVANT), 0, h, 0, a), + (beta, (0, 1, IRRELEVANT), 0, h, 0, a)] if action == OVERRIDE: if a <= h: return None # need a strictly longer chain to override # publish h+1 blocks -> they override the public h; a-h-1 stay secret; then one block mined - return [(alpha, (a - h, 0, IRRELEVANT), h + 1, 0), - (beta, (a - h - 1, 1, RELEVANT), h + 1, 0)] + return [(alpha, (a - h, 0, IRRELEVANT), h + 1, 0, h, 0), + (beta, (a - h - 1, 1, RELEVANT), h + 1, 0, h, 0)] if at_cap: return None # only adopt/override allowed at the boundary if action == WAIT: if f != ACTIVE: - return [(alpha, (a + 1, h, IRRELEVANT), 0, 0), - (beta, (a, h + 1, RELEVANT), 0, 0)] + return [(alpha, (a + 1, h, IRRELEVANT), 0, 0, 0, 0), + (beta, (a, h + 1, RELEVANT), 0, 0, 0, 0)] if a < h: return None # inconsistent (unreachable) active state: only adopt is valid # waiting while a fork is active: the same race dynamics as match - return [(alpha, (a + 1, h, ACTIVE), 0, 0), - (gamma * beta, (a - h, 1, RELEVANT), h, 0), # adv's matched branch wins h - ((1 - gamma) * beta, (a, h + 1, RELEVANT), 0, 0)] + return [(alpha, (a + 1, h, ACTIVE), 0, 0, 0, 0), + (gamma * beta, (a - h, 1, RELEVANT), h, 0, h, 0), # adv's matched branch wins h + ((1 - gamma) * beta, (a, h + 1, RELEVANT), 0, 0, 0, 0)] if action == MATCH: if not (f == RELEVANT and a >= h): return None # match needs equal-or-longer chain on a fresh tip - return [(alpha, (a + 1, h, ACTIVE), 0, 0), - (gamma * beta, (a - h, 1, RELEVANT), h, 0), - ((1 - gamma) * beta, (a, h + 1, RELEVANT), 0, 0)] + return [(alpha, (a + 1, h, ACTIVE), 0, 0, 0, 0), + (gamma * beta, (a - h, 1, RELEVANT), h, 0, h, 0), + ((1 - gamma) * beta, (a, h + 1, RELEVANT), 0, 0, 0, 0)] return None @@ -89,6 +105,8 @@ def _precompute(alpha, gamma, states, index, cap): nxt = np.zeros((4, n, _K), dtype=np.int64) radv = np.zeros((4, n, _K)) rhon = np.zeros((4, n, _K)) + ohon = np.zeros((4, n, _K)) # honest blocks discarded on the branch (one chain) + oadv = np.zeros((4, n, _K)) # adversary blocks discarded on the branch (one chain) legal = np.zeros((4, n), dtype=bool) for i, (a, h, f) in enumerate(states): for action in (ADOPT, OVERRIDE, MATCH, WAIT): @@ -96,12 +114,14 @@ def _precompute(alpha, gamma, states, index, cap): if tr is None: continue legal[action, i] = True - for b, (p, s2, ra, rh) in enumerate(tr): + for b, (p, s2, ra, rh, oh, oa) in enumerate(tr): probs[action, i, b] = p nxt[action, i, b] = index[s2] radv[action, i, b] = ra rhon[action, i, b] = rh - return probs, nxt, radv, rhon, legal + ohon[action, i, b] = oh + oadv[action, i, b] = oa + return probs, nxt, radv, rhon, ohon, oadv, legal def _solve_mdp(pc, rho, ref, iters, tol): @@ -112,7 +132,7 @@ def _solve_mdp(pc, rho, ref, iters, tol): textbook span criterion: at the average-reward fixed point ``TV − V = g·1`` (span → 0), and the gain ``g`` is that uniform increment. Returns the span-centre of the final Bellman increment. """ - probs, nxt, radv, rhon, legal = pc + probs, nxt, radv, rhon, _ohon, _oadv, legal = pc reward = (1.0 - rho) * radv - rho * rhon # (4, n, K), constant across iterations V = np.zeros(probs.shape[1]) tau = 0.5 @@ -142,6 +162,11 @@ def optimal_selfish_revenue(alpha: float, gamma: float, cap: int = 60, states, index = _build_states(cap) pc = _precompute(alpha, gamma, states, index, cap) ref = index[(1, 0, IRRELEVANT)] + return _bisect_revenue(pc, ref, alpha, iters, tol) + + +def _bisect_revenue(pc, ref, alpha, iters, tol): + """Bisection on the ratio objective — the shared inner loop of the revenue solvers.""" lo, hi = alpha - 1e-9, 1.0 # relative revenue in [alpha, 1) for _ in range(44): mid = 0.5 * (lo + hi) @@ -151,3 +176,122 @@ def optimal_selfish_revenue(alpha: float, gamma: float, cap: int = 60, else: hi = mid return 0.5 * (lo + hi) + + +@dataclass +class OptimalPolicyStats: + """Per-block-finding-event rates under the optimal policy's stationary distribution. + + Every MDP transition consumes exactly one block-finding event (the alpha/beta branch), so + stationary per-step rates *are* per-event rates. ``deviates`` is False below the profitability + threshold, where the optimum is honest mining and the MDP is indifferent across policies (the + value-iteration policy is then arbitrary and its orphan structure meaningless). + """ + alpha: float + gamma: float + revenue: float + deviates: bool + density_fraction: float # canonical blocks per event — the raw TSI deflation factor + orphan_hon_blocks: float # honest blocks orphaned per event + orphan_hon_runs: float # honest orphan *chains* per event (1 countable uncle each) + orphan_adv_blocks: float # adversary blocks discarded per event + orphan_adv_runs: float + + @property + def countable_recovery(self) -> float: + """Ceiling on the uncle-recovery fraction ``eta`` under the first-fork rule (§2.1). + + ``runs / blocks``: an override discards a *chain* of honest blocks and only its first is + referenceable, so this is the largest ``eta`` the deployed counting rules admit — 1.0 under + SM1 (which never buries a second block), below 1 whenever the policy waits. + """ + if self.orphan_hon_blocks <= 0: + return 1.0 + return self.orphan_hon_runs / self.orphan_hon_blocks + + @property + def countable_recovery_adv(self) -> float: + """The same ceiling on the attacker *self-uncling* its own abandoned chain (§6.7(a)).""" + if self.orphan_adv_blocks <= 0: + return 1.0 + return self.orphan_adv_runs / self.orphan_adv_blocks + + def dhat_ratio(self, p_ref: float = 1.0, countable: bool = True) -> float: + """Equilibrium ``D̂/D*`` = canonical density + the referenced share of honest orphans. + + ``countable=True`` counts one uncle per orphaned *chain* (the deployed rule); + ``countable=False`` is the unrestricted baseline that counts every orphaned block. + """ + rec = self.orphan_hon_runs if countable else self.orphan_hon_blocks + return self.density_fraction + float(np.clip(p_ref, 0.0, 1.0)) * rec + + +def optimal_policy_stats(alpha: float, gamma: float, cap: int = 64, + iters: int = 4000, tol: float = 1e-10) -> OptimalPolicyStats: + """Orphan structure of the *optimal* selfish policy — the input the countable model needs. + + Solves the same MDP as :func:`optimal_selfish_revenue`, then reads the greedy policy off the + value function at the optimal ``rho``, finds its stationary distribution, and accumulates the + per-event canonical / orphan rates carried on the transition table. + + ``cap`` must be larger here than for the revenue alone: the revenue converges once long leads + are rare, but the orphan *shape* keeps changing while the policy still waits near the cap + (measured drift at alpha = 0.45 is ~0.005 in eta from cap 48 to 64, ~0.0003 at alpha = 0.4). + """ + states, index = _build_states(cap) + pc = _precompute(alpha, gamma, states, index, cap) + probs, nxt, radv, rhon, ohon, oadv, legal = pc + ref = index[(1, 0, IRRELEVANT)] + revenue = _bisect_revenue(pc, ref, alpha, iters, tol) + + # Below the profitability threshold the optimum is honest mining (revenue == alpha) and the MDP + # is indifferent among many policies; report the honest outcome rather than an arbitrary one. + if revenue <= alpha * (1.0 + 1e-6): + return OptimalPolicyStats(alpha=alpha, gamma=gamma, revenue=revenue, deviates=False, + density_fraction=1.0, orphan_hon_blocks=0.0, + orphan_hon_runs=0.0, orphan_adv_blocks=0.0, + orphan_adv_runs=0.0) + + # Recover V at the optimal rho, then the greedy policy. + reward = (1.0 - revenue) * radv - revenue * rhon + n = probs.shape[1] + V = np.zeros(n) + for _ in range(iters): + q = (probs * (reward + V[nxt])).sum(axis=2) + q[~legal] = -1e18 + d = q.max(axis=0) - V + if d.max() - d.min() < tol: + break + V = V + 0.5 * d + V -= V[ref] + q = (probs * (reward + V[nxt])).sum(axis=2) + q[~legal] = -1e18 + # Deterministic tie-break toward the lowest action index (ADOPT < OVERRIDE < MATCH < WAIT) so + # near-ties resolve to the least-deviating policy instead of an arbitrary argmax. + best = q.max(axis=0) + pol = np.where(q >= best[None, :] - 1e-9, np.arange(4)[:, None], 99).min(axis=0) + + rows = np.arange(n) + p_s, n_s = probs[pol, rows], nxt[pol, rows] # (n, K) + # Stationary distribution. The policy chain is periodic (see _solve_mdp), so iterate the lazy + # chain — same stationary vector, no oscillation. + pi = np.full(n, 1.0 / n) + for _ in range(500_000): + new = 0.5 * pi + 0.5 * np.bincount(n_s.ravel(), weights=(pi[:, None] * p_s).ravel(), + minlength=n) + new /= new.sum() + if np.abs(new - pi).max() < 1e-15: + pi = new + break + pi = new + + w = pi[:, None] * p_s # stationary branch flow + ohon_s, oadv_s = ohon[pol, rows], oadv[pol, rows] + return OptimalPolicyStats( + alpha=alpha, gamma=gamma, revenue=revenue, deviates=True, + density_fraction=float((w * (radv[pol, rows] + rhon[pol, rows])).sum()), + orphan_hon_blocks=float((w * ohon_s).sum()), + orphan_hon_runs=float((w * (ohon_s > 0)).sum()), + orphan_adv_blocks=float((w * oadv_s).sum()), + orphan_adv_runs=float((w * (oadv_s > 0)).sum()), + ) diff --git a/tools/simulators/tsi/tsi-sim-pernode/tests/test_countable_selfish.py b/tools/simulators/tsi/tsi-sim-pernode/tests/test_countable_selfish.py new file mode 100644 index 0000000..2129a28 --- /dev/null +++ b/tools/simulators/tsi/tsi-sim-pernode/tests/test_countable_selfish.py @@ -0,0 +1,86 @@ +"""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