# lh-ptc-onblock — disproof attempt for candidate lighthouse-1

## VERDICT: REFUTED (as a consequential divergence; a narrow, effectively cosmetic ordering difference remains)

The ordering difference is real. Lighthouse writes a block's payload attestations into fork choice in `ExecutionPendingBlock` (block_verification.rs#L1782-L1805), and the fork-choice `on_block` asserts run later at import (fork_choice.rs#L828-L879). Nothing undoes those writes if `on_block` fails. The candidate's first trigger, a full parent whose payload is unverified, cannot reach L1782. Every path into L1782 (gossip, RPC, range sync, backfill and chain segments) first calls `load_parent` (block_verification.rs#L2009-L2031, called at L680, L1038, L1195, L1266, L1364). `load_parent` rejects the block through `is_parent_imported` → `get_parent_import_status` (fork_choice.rs#L1652-L1679), which returns `UnknownPayload` when the child builds on a full parent and `!is_payload_received(parent)`. That is the same predicate `on_block` uses for `ParentPayloadNotVerified` (fork_choice.rs#L829-L839). `is_payload_received` (fork_choice.rs#L1629-L1632) is `proto_array.is_payload_received && is_finalized_checkpoint_or_descendant`. The first conjunct only changes from false to true. So between the check and import, the result can only flip if finalization advances. That leaves the second trigger: finalization advances after `load_parent`/gossip (gossip finalized check at block_verification.rs#L981-L985) but before `on_block`. In that window, the PTC votes were cast on the parent (spec `process_payload_attestation`: `data.beacon_block_root == state.latest_block_header.parent_root`, `data.slot + 1 == state.slot`). That parent is either (a) not a descendant of the new finalized checkpoint, so it can never be in the head walk, which starts at `store.justified_checkpoint.root` (fork-choice.md L602/L646/L652), or (b) the finalized/justified anchor block itself. Only (b) could affect `should_extend_payload` / the payload-status tie-break. Even there, the votes are BLS-verified votes from real PTC members (the state transition passed). A spec node that saw the same messages on gossip in their slot would hold the same votes. A difference only arises when a PTC member equivocates, sending different payload_present/blob_data_available values in the block than on the wire. I found no route to an invalid block being accepted, a valid block being rejected, or a consensus split. A fork-choice divergence would need an equivocating PTC member and a finalization race on the anchor block. I did not prove that impossible, so the residual is recorded below at low confidence.

## Strongest refutation attempt (route 5 + route 1)
- Full-parent case: refuted. `load_parent` (block_verification.rs#L2023-L2031) uses `is_parent_imported`, and that function enforces the same full-parent/payload-received condition as `on_block`, on every import path. The condition is monotonic except through finalization (fork_choice.rs#L1629-L1632, #L1663-L1679). Gossip also checks it at block_verification.rs#L989 (`verify_parent_block_and_envelope_are_known`, #L1988-L2001, which maps `UnknownPayload` to `ParentUnknown`).
- Finalized case (route 2): the votes target the parent. Any parent that fails the new finalized-descendant check is outside the viable tree, so its PTC counters cannot reach the spec's head computation. The only exception is the finalized/justified anchor itself.
- Route 3 (spec reading): the candidate's reading is correct. The spec asserts `is_payload_verified(store, block.parent_root)` when `is_parent_node_full`, plus `block.slot > finalized_slot` and the finalized-ancestor check. All of these come before `state_transition` and before `notify_ptc_messages(store, state, block.body.payload_attestations)` (specs/gloas/fork-choice.md ~L1058-L1095 at 8a80ffa8). A failed assert discards the whole call, so the spec records no PTC vote.
- Route 4: I listed recent commits on block_verification.rs (unstable, through 2026-10-19: "Processor limits", #10327, #9729, #9934, #9817). None touches PTC-vote ordering. No fix found.
- Write revert: none. `fork_choice` is dropped at L1806 with the votes kept, and an `on_block` error does not roll them back.

## Residual record (low impact)
- Spec: fork-choice.md on_block asserts (`assert block.slot > finalized_slot`; `assert store.finalized_checkpoint.root == finalized_checkpoint_block`) precede `notify_ptc_messages`.
- Code: lighthouse a376defa beacon_node/beacon_chain/src/block_verification.rs#L1782-L1805 (votes applied); consensus/fork_choice/src/fork_choice.rs#L851-L879 (FinalizedSlot / NotFinalizedDescendant checked later).
- Condition: finalization advances between block verification and import, and the block's parent is the new finalized/justified anchor. The block's payload attestations then differ from what the node saw on gossip, which needs an equivocating PTC member.
- Other client: the brief says Prysm eb681c1d process_block.go#L79-L91 also inserts first. I did not verify this.
- Confidence that this has any observable effect: low. Severity: cosmetic (at most a theoretical fork-choice divergence on the anchor node's payload-status tie-break).

## Known-check
Per the brief, no existing sigp/lighthouse issue or PR covers this, and #9593 (open) is about is_head_weak, not this. I did not search the issue tracker myself. Recent commits on the file are listed above; none is related.

## URLs read
- https://raw.githubusercontent.com/sigp/lighthouse/a376defa30470923556d91bdeb15a802e297f31f/beacon_node/beacon_chain/src/block_verification.rs
- https://raw.githubusercontent.com/sigp/lighthouse/a376defa30470923556d91bdeb15a802e297f31f/consensus/fork_choice/src/fork_choice.rs
- https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/gloas/fork-choice.md
- https://raw.githubusercontent.com/ethereum/consensus-specs/8a80ffa8/specs/gloas/beacon-chain.md (process_payload_attestation, L2488-L2500)
- https://api.github.com/repos/sigp/lighthouse/commits?path=beacon_node/beacon_chain/src/block_verification.rs&sha=unstable
