# ptc-gossip-state — disproof attempt and verdict

## VERDICT: CONFIRMED (as a code-vs-spec divergence), with an important inversion

The candidate's description of the code is accurate at every pin checked: Teku and Lighthouse validate `payload_attestation_message` gossip against the *referenced block's* branch state, while the spec's gossip rule at consensus-specs 8a80ffa8 prescribes the *head* state (`state = store.block_states[get_head(store).root]`, `specs/gloas/p2p-interface.md:930`); Nimbus stable uses the head state in gossip (matching the spec text) but also uses `dag.headState` inside `on_payload_attestation_message`, diverging from the spec's fork-choice handler which uses the block's state (`specs/gloas/fork-choice.md:1150`). The divergence is real but conditional: `get_ptc` reads `state.ptc_window` (`beacon-chain.md:1392-1404`), whose entries for epoch E are frozen by `process_ptc_window` during the epoch processing at the end of epoch E-2 (`beacon-chain.md:1653`, `1737-1748`). Two same-slot states can therefore disagree on a PTC only if their branches last shared a state at or before the last slot of epoch E-2 — i.e., a fork at least ~33+ slots (over one epoch, typically multi-epoch) deep, a "branching non-finality" scenario. All five refutation routes were tried; none eliminates the divergence, but the strongest one (reachability analysis) bounds it tightly, and the code history shows the deviation is deliberate: the deviating clients match the spec's own fork-choice consumer, and the spec's two rules are themselves inconsistent under the same condition (a message can pass the head-state gossip check and then fail `assert len(ptc_indices) > 0` in `on_payload_attestation_message`). The anomaly is best attributed to the spec's gossip text (head state is uniform boilerplate preserved mechanically from the table-era spec) rather than to Teku/Lighthouse/Prysm; Nimbus stable had the genuinely inconsistent implementation (head state in the fork-choice handler, contrary to `fork-choice.md:1150`), already fixed on unstable by PR #9341.

## Strongest refutation attempt (route 2/5: reachability + spec history)

The candidate's stated condition — "two validated branches with different current-slot PTCs" — looked like it might be impossible for same-epoch branches, since PTCs derive from the same shuffling lineage. Reading `get_ptc`/`compute_ptc`/`process_ptc_window` shows the window for epoch E is computed once, at the end of epoch E-2, from that boundary state's `get_seed` mix, active set, **and effective balances** (`compute_balance_weighted_selection`, `beacon-chain.md:1263-1279`). Balance weighting means essentially *any* divergence at the E-2 boundary state changes the PTC — so the condition needs only a fork whose last common ancestor predates the last slot of E-2 (≥ ~33 slots before the earliest message slot; realistically a multi-epoch partition/non-finality period). Such forks are rare on a healthy chain but are precisely the case the client code was written for: Nimbus PR #9341's own description says "Payload attestation messages need to look up the PTC depending on the branch that they arrive on, which may have different PTC shufflings ... in branching non-finality scenarios", and Lighthouse issue #9474 describes "periods of non-finality when forks on the network have a different shuffling" as a liveness concern. So the condition is rare but not excludable; the refutation bounds it rather than kills it.

Secondary attempts, all failed to refute:
- **Route 1 (earlier check forces block==head):** no client requires the attested block to be the head before the PTC check (Teku checks block-seen, block-slot match, not-invalid; Lighthouse checks block seen + slot match; Nimbus same pattern; spec itself only requires `data.beacon_block_root in store.block_states` and slot match).
- **Route 3 (spec misread):** verified verbatim at the pin — `p2p-interface.md:930` head state vs `fork-choice.md:1150` block state; `get_ptc` is state-dependent (`beacon-chain.md:1392-1404`).
- **Route 4 (later commit fixes it):** Teku pin 2c35574b is dated 2026-10-27 (same day as this review; latest commit touching the file, `5909fd2f`, is 2026-10-21 and only adds batch signature verification). The choice predates and survives — it was deliberate since PR #11215, whose code comment reads "the state to validate against is its own post state". Nimbus is the only client that changed: PR #9341 (merged to `unstable` 2026-10-24) switched *both* gossip and fork-choice from `dag.headState` to the attested block's `EpochRef` PTC — moving Nimbus toward Teku/Lighthouse/Prysm semantics and away from the spec gossip text.
- **Route 5 (spec history / intended state):** the table-era spec (bba2c7be14, Aug 2025) already said the PTC-check `state` "is the head state corresponding to processing the block up to the current slot as determined by the fork choice" (`specs/gloas/p2p-interface.md` under `payload_attestation_message`), while `on_payload_attestation_message` already used `store.block_states[data.beacon_block_root]`. The executable refactor (PR #5294) and state-from-Store refactor (PR #5563, "Load gossip validation state from the store") inserted `state = store.block_states[get_head(store).root]` mechanically into *every* gossip validator; in the same PR the envelope and bid validators were deliberately given block-scoped states (`store.block_states[block_root]`, `store.block_states[bid.parent_block_root]`) — the payload-attestation validator was not. No review comment on #5563 discusses the PTC case; no open spec issue found flagging the gossip/handler inconsistency.

## Corrected record (surviving finding)

- **Spec quote (pin 8a80ffa8):** `specs/gloas/p2p-interface.md:930-938` — `state = store.block_states[get_head(store).root]` then `if validator_index not in get_ptc(state, data.slot): raise GossipReject(...)`. Contrast `specs/gloas/fork-choice.md:1150,1158,1164` — `state = store.block_states[data.beacon_block_root]`, `ptc = get_ptc(state, data.slot)`, `assert len(ptc_indices) > 0`. `get_ptc` reads `state.ptc_window` (`beacon-chain.md:1392-1404`); window filled by `process_ptc_window` (`beacon-chain.md:1737-1748`) computing epoch X+2 entries at the end-of-X boundary via `compute_ptc` (`beacon-chain.md:1263-1279`, balance-weighted).
- **Teku** `2c35574b3373cbe8ad121a768d98f066aec05b73`, `ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/payloadattestation/PayloadAttestationMessageGossipValidator.java:150-198` — uses `gossipValidationHelper.getStateAtBlockRoot(data.getBeaconBlockRoot())` (the attested block's post-state) for validator-index, PTC-membership and signature checks; recorded votes reuse those same PTC positions in `ForkChoice.onPayloadAttestationMessage` (`ethereum/statetransition/.../forkchoice/ForkChoice.java:408-431`), so Teku is internally consistent with the spec's fork-choice handler but not the gossip text.
- **Lighthouse** `a376defa30470923556d91bdeb15a802e297f31f`, `beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs:101-118` — `with_cached_shuffling(..., beacon_block_root, message_epoch, |cached_shuffling,_| cached_shuffling.ptc_for_slot(slot))`: PTC is keyed to the *attested* block's shuffling/decision root (`shuffling_cache.rs:315-341` resolves `current_epoch_shuffling_id` of `beacon_block_root`, not the head). Per-branch semantics; carried `ptc` is reused downstream, consistent with fork-choice handler.
- **Nimbus** `0abc0dd0fc0750a8875c49089d074f7d7197b62c`, `beacon_chain/gossip_processing/gossip_validation.nim:2040-2052` — `withState(dag.headState)` + `get_ptc(...)` (matches spec gossip text); **`beacon_chain/fork_choice/fork_choice_epbs.nim:231-247`** — `withState(dag.headState)` again for vote tallying, diverging from spec `fork-choice.md:1150`. Under the condition, an A-branch-PTC validator could inject votes for B's block that Nimbus tallies at A's PTC positions, while honest B-PTC votes are `checkedReject`ed at gossip.
- **Prysm** `eb681c1d13b57c2d0c5da27a63bb41ac9d071e1a`, `beacon-chain/sync/validate_payload_attestation.go:88-113` → `beacon-chain/blockchain/receive_payload_attestation_message.go:52-90` (`PtcLookupState`): head state only when attested block==head or shares the epoch dependent root (provably equivalent); otherwise the attested block's cached post-state; IGNORE when neither available. Effectively block-state semantics where the two could differ.
- **Trigger condition:** node stores two validated branches whose last common ancestor is at/before the last slot of epoch E-2 (≥ ~33 slots, i.e. >1 epoch; practically a multi-epoch partition/non-finality period), both with same-slot-S blocks in epoch E; head on branch A; `payload_attestation_message` for B's block arrives within its slot window. Head-state readers (spec rule, Nimbus stable) then (a) reject honest B-PTC votes — "validator not in the payload timeliness committee" / out-of-range if registries diverged — so B's payload-timeliness tally is missing real votes, and (b) accept a malicious A-PTC member's attestation naming B's block, which the spec's own fork-choice handler would assert-fail and which Nimbus would tally at wrong positions. Block-state readers (Teku, Lighthouse, Prysm, Nimbus-unstable) do the reverse, matching `fork-choice.md` semantics.
- **Severity:** fork-choice divergence (PTC vote tallies feed payload-timeliness/data-availability status used by `get_head` viability, per `fork-choice.md` `payload_timeliness_vote`/`payload_data_availability_vote` and PR #5509 "viable payload-status variants"); under the same condition also a gossip-propagation divergence between clients. Trigger likelihood low-to-moderate (requires multi-epoch non-finality). Confidence: high on code reading; the code claims are verified at pins and the deliberate choice is documented by the clients themselves.
- **Caveat for the final report:** for Teku/Lighthouse/Prysm the "divergence" is conformance to the spec's fork-choice handler and is deliberate; the spec's gossip text is the outlier relative to its own consumer. The only client genuinely violating a spec rule in a way that misattributes votes is Nimbus stable's `on_payload_attestation_message` (head state where spec mandates block state) — already fixed on `unstable`.

## Known-check

- **Nimbus-eth2 PR #9341 "Cache PTC in EpochRef"** — merged 2026-10-24 into `unstable`; rewrites both `gossip_validation.nim` and `fork_choice_epbs.nim` to use `dag.findEpochRef(attestedBlck.bid, slot.epoch)` (attested-block-branch PTC). Confirms the scenario was a real defect in stable, not yet in the pinned stable commit.
- **Lighthouse issue #9305 "Fix non-canonical payload attestation processing"** (closed; breakout of PR #9295): "we always attempt to use the head" — the per-branch shuffling-cache PTC is the deliberate fix. **Lighthouse issue #9474** (open) argues broadcasting votes on non-canonical forks is needed against liveness/reorg attacks during non-finality.
- **Teku PR #11215 "Switch to cheaper getStateByRoot in validations"** — code comment documents the deliberate block-post-state choice; earlier code used `getStateAtSlotAndBlockRoot`, still the attested block's branch. No issue found requesting a change to head state.
- **consensus-specs:** PR #5294 (executable gossip functions), PR #5563 (mechanical `get_head` insertion into all validators; block-scoped states deliberately used for envelope/bid but not PTC; no review discussion of the PTC case). No open spec issue found flagging the gossip-rule vs fork-choice-handler inconsistency for PTC state.

## Sources read

- `https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/gloas/p2p-interface.md` (L887-948, esp. 930-938)
- `https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/gloas/fork-choice.md` (L1136-1188)
- `https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/gloas/beacon-chain.md` (L1263-1279, 1392-1404, 1630-1653, 1734-1748, 534-541)
- `https://raw.githubusercontent.com/ethereum/consensus-specs/bba2c7be14/specs/gloas/p2p-interface.md` (L216-244, table-era "head state" note)
- `https://raw.githubusercontent.com/ethereum/consensus-specs/bba2c7be14/specs/gloas/fork-choice.md` (L537-565)
- `https://raw.githubusercontent.com/Consensys/teku/2c35574b3373cbe8ad121a768d98f066aec05b73/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/payloadattestation/PayloadAttestationMessageGossipValidator.java`
- `https://raw.githubusercontent.com/Consensys/teku/2c35574b3373cbe8ad121a768d98f066aec05b73/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/ForkChoice.java` (L408-431)
- `https://raw.githubusercontent.com/sigp/lighthouse/a376defa30470923556d91bdeb15a802e297f31f/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs`
- `https://raw.githubusercontent.com/sigp/lighthouse/a376defa30470923556d91bdeb15a802e297f31f/beacon_node/beacon_chain/src/shuffling_cache.rs` (L40-110, 315-460)
- `https://raw.githubusercontent.com/status-im/nimbus-eth2/0abc0dd0fc0750a8875c49089d074f7d7197b62c/beacon_chain/gossip_processing/gossip_validation.nim` (L2004-2070)
- `https://raw.githubusercontent.com/status-im/nimbus-eth2/0abc0dd0fc0750a8875c49089d074f7d7197b62c/beacon_chain/fork_choice/fork_choice_epbs.nim` (L200-256)
- `https://raw.githubusercontent.com/OffchainLabs/prysm/eb681c1d13b57c2d0c5da27a63bb41ac9d071e1a/beacon-chain/sync/validate_payload_attestation.go`
- `https://raw.githubusercontent.com/OffchainLabs/prysm/eb681c1d13b57c2d0c5da27a63bb41ac9d071e1a/beacon-chain/blockchain/receive_payload_attestation_message.go`
- `https://api.github.com/repos/status-im/nimbus-eth2/pulls/9341` (+ files diff)
- `https://api.github.com/repos/sigp/lighthouse/issues/9305`, `.../issues/9474`
- `https://api.github.com/repos/Consensys/teku/commits/9e51aa5e3b9d` (PR #11215 diff), Teku commit history for the validator file (`.../commits?path=...&sha=master`)
- `https://api.github.com/repos/ethereum/consensus-specs/commits/e524a5830d` (PR #5563 diff incl. review comments at `pulls/5563/comments`), `.../commits?path=specs/gloas/p2p-interface.md` and `.../fork-choice.md` history, `.../issues/5666`, and issue-search queries above (no matching known issue for Teku or the spec inconsistency)
