# Gloas (EIP-7732 ePBS) fork choice and networking: merged review of Lighthouse, Prysm and Teku

Merge of three code-reading items (batch cl-forkchoice); nothing was run against any network or node. Every candidate is copied with all six parts and its known-check, numbered `<client>-<n>` with the item's own C-number; internal references were renumbered alike. Citations keep the `path#Lstart-Lend` form at the pins below (long URLs reduced to it; some Teku paths use prefixes defined below); quoted code is unchanged; a few explanatory sentences in parts 3 to 5 were shortened to fit 64 KB. URL ledgers were dropped; no finding was added or dropped.

## 1. Pins

| Client / reference | Branch | Commit (api.github.com commits) | consensus-specs master commit |
|---|---|---|---|
| Lighthouse, sigp/lighthouse | stable (v8.3.2) | `a376defa30470923556d91bdeb15a802e297f31f`, 2026-10-22 | `8a80ffa82bd49aac0dd6db17f9e8150aa257fbc2`, 2026-10-27 |
| Lighthouse, sigp/lighthouse | unstable (8.4.0-rc.0) | `301f3bf2323cd2b3f946ceed026be8075d24e1c3`, 2026-10-27 | same |
| Prysm, OffchainLabs/prysm | develop | `eb681c1d13b57c2d0c5da27a63bb41ac9d071e1a`, 2026-10-27 (#17674) | same |
| Teku, Consensys-Incorporated/teku (Consensys/teku redirects) | master | `2c35574b3373cbe8ad121a768d98f066aec05b73`, 2026-10-27 | same |
| consensus-specs released baseline | tag v1.7.0-beta.4 | `aa16bb4c156184e9548a997d53efaf2a228a6304` | |

**Spec version targeted.** All three clients pin v1.7.0-beta.4 (Lighthouse `testing/ef_tests/Makefile#L3-L3`, both branches; Prysm `WORKSPACE#L285` and `.ethspecify.yml#L1`; Teku `build.gradle#L528-L532`). Per the Teku item, `specs/gloas/{fork-choice,p2p-interface,validator}.md` are byte-identical between beta.4 and master; the substantive Gloas difference is `specs/gloas/fork.md` after consensus-specs PR 5736 (merged 2026-10-26): lighthouse-14, prysm-12, teku-1. Per the Lighthouse item's diff of both trees, all Lighthouse findings apply to both branches; lines cited from `beacon_chain.rs` and `builder.rs` hold at the stable pin only.

**Citation conventions:**
- Lighthouse item: **LH** = sigp/lighthouse `a376defa…`; **SP** = consensus-specs `specs/gloas/` at `8a80ffa…`; **PR** = OffchainLabs/prysm `eb681c1…`; `BC/` = `beacon_node/beacon_chain/src/`, `PA/` = `consensus/proto_array/src/`, `FC/` = `consensus/fork_choice/src/`, `NBP/` = `beacon_node/network/src/network_beacon_processor/`, `LN/` = `beacon_node/lighthouse_network/src/`.
- Prysm item: bare paths are OffchainLabs/prysm `eb681c1…`; `fc.md`, `p2p.md`, `fork.md` = `specs/gloas/{fork-choice,p2p-interface,fork}.md`; `phase0-p2p.md` = `specs/phase0/p2p-interface.md`; `lh:` = sigp/lighthouse `a376defa…`. "Per the read pass" marks lines the item's author did not re-read.
- Teku item: bare paths are Consensys-Incorporated/teku `2c35574…`; `specs/…` = consensus-specs `8a80ffa…`; **FC**, **P2P**, **V** = `specs/gloas/{fork-choice,p2p-interface,validator}.md`; "LH" paths use the Lighthouse prefixes. Merge prefixes: `TST/` = `ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/`, `TSPEC/` = `ethereum/spec/src/main/java/tech/pegasys/teku/spec/logic/versions/gloas/`.

## 2. Candidates

### 2.1 Lighthouse

#### lighthouse-1. on_block order: a block's payload attestations reach fork choice before the fork-choice on_block checks
1. **Spec.** SP `fork-choice.md#L1058-L1061` and `#L1094-L1095`: `if is_parent_node_full(store, block): assert is_payload_verified(store, block.parent_root)`. The finalized-descendant asserts at `#L1067-L1076` also run before `notify_ptc_messages(store, state, block.body.payload_attestations)`. A failed assert aborts the handler, so no PTC vote is recorded.
2. **Client.**
   - LH `BC/block_verification.rs#L1782-L1805` runs right after the state-root check, under a fork-choice write lock: `if let Ok(payload_attestations) = block.message().body().payload_attestations() { … fork_choice.on_payload_attestation(current_slot, indexed_payload_attestation, AttestationFromBlock::True, &ptc.0)`.
   - The checks run later, at import, in LH `FC/fork_choice.rs#L828-L879`: `if builds_on_full && !self.is_payload_received(&block.parent_root()) { return Err(…ParentPayloadNotVerified…) }`, followed by the `FutureSlot`, `FinalizedSlot` and `NotFinalizedDescendant` checks.
   - Nothing undoes the votes if `on_block` then fails.
3. **When results differ.** A block passes the state transition but fails the fork-choice `on_block` checks, e.g. finalization advances during execution/availability verification so it is no longer a finalized descendant. Its valid, signature-checked payload attestations have still raised the parent's PTC timeliness and data-availability votes in Lighthouse; the spec store never records them. That can flip `should_extend_payload`/`should_build_on_full` for the previous-slot parent.
4. **Prysm.** Agrees with the spec. PR `beacon-chain/blockchain/process_block.go#L79-L91` calls `InsertNode` (where the parent-full check lives, PR `beacon-chain/forkchoice/doubly-linked-tree/gloas.go#L58-L61`, `store.go#L174-L185`) before `handleBlockPayloadAttestations`.
5. **Confidence: low.** Disproof: gossip verification already checks the full-parent condition and finalized descent (LH `block_verification.rs#L989-L999` region), so only a verification-to-import race, or a parent-payload status change in that window, remains; the same PTC votes usually arrive on gossip. No reachable path to a different head was shown.
6. **Severity:** fork-choice divergence (narrow).

#### lighthouse-2. is_head_weak leaves out equivocating committee weight (Gloas `should_apply_proposer_boost`)
1. **Spec.** SP `fork-choice.md#L815-L832`: `for index in range(get_committee_count_per_slot(head_state, epoch)): committee = get_beacon_committee(…) head_weight += Gwei(sum(… for index in committee if index in store.equivocating_indices))`. `should_apply_proposer_boost` uses it at `#L544-L546`.
2. **Client.** LH `PA/proto_array.rs#L793-L818`:
   - The comment reads: `// TODO(gloas): the spec adds weight from equivocating validators in the head slot's *committees* … We approximate with equivocating_attestation_score which only tracks equivocating validators whose vote pointed at this block.`
   - The code: `let head_weight = head_node.attestation_score(PayloadStatus::Pending).saturating_add(head_node.equivocating_attestation_score().unwrap_or(0)); head_weight < reorg_threshold`.
   - It is called from `should_apply_proposer_boost` at `#L858-L861`.
3. **When results differ.** Equivocators in the parent slot's committees voted for a different branch (or not for the parent), and the parent's weight is below the reorg threshold without them and above it with them. Lighthouse calls the parent weak and, if an early equivocating block exists, removes the proposer boost; the spec keeps it, so the head can differ.
4. **Prysm.** Also omits it, so it agrees with Lighthouse rather than the spec. PR `beacon-chain/forkchoice/doubly-linked-tree/gloas.go#L719-L719`: `if p.node.weight*100 >= s.committeeWeight*…ReorgHeadWeightThreshold { return true }`. A grep for "equivocat" in Prysm's forkchoice package found no committee add-back.
5. **Confidence: high**; the TODO admits the deviation, so no disproof is possible.
6. **Severity:** fork-choice divergence.
7. **Known:** yes, sigp/lighthouse #9593 (issue, open) and #9831 (PR, open).

#### lighthouse-3. Gossip `beacon_attestation`/`beacon_aggregate_and_proof`, index 1: no IGNORE for an optimistic payload, no REJECT for an invalidated one
1. **Spec.** SP `p2p-interface.md#L455-L462`: `# [IGNORE] The attested execution payload is optimistic … if payload_status == PAYLOAD_STATUS_NOT_VALIDATED: raise GossipIgnore(…)` and `# [REJECT] The attested execution payload is processed and invalid`.
2. **Client.** The only payload check is `payload_received`:
   - Unaggregated, LH `BC/attestation_verification.rs#L964-L972`: `if fork_name.gloas_enabled() && attestation.data.index == 1 && !head_block.payload_received { return Err(Error::UnknownPayloadEnvelope …`
   - Aggregate: `#L624-L634`.
   - The flag is set for both optimistic and valid imports, at LH `PA/proto_array.rs#L905-L906`: `// The envelope arrived: record it so sync stops fetching, whatever the verdict.` / `v29.payload_received = true;`
   - Later invalidation leaves it set. `verify_head_block_is_known` (`attestation_verification.rs#L1244-L1291`) does not look at execution status.
3. **When results differ.**
   - An index-1 attestation names a block whose envelope was imported optimistically (EL SYNCING/ACCEPTED). The spec IGNOREs it; Lighthouse ACCEPTs and forwards it.
   - Same case, but the payload was later invalidated. The spec REJECTs; Lighthouse ACCEPTs.
4. **Prysm.** Has the REJECT and lacks the optimistic IGNORE, so it agrees with Lighthouse on that half. PR `beacon-chain/sync/validate_beacon_attestation.go#L373-L399`: `if s.hasBadPayload(blockRoot) { return pubsub.ValidationReject … } if !s.cfg.chain.HasFullNode(blockRoot) { return pubsub.ValidationIgnore …`
5. **Confidence: high**, from reading the code. Disproof: no optimistic or invalid filter in `verify_head_block_is_known`, `verify_attestation_target_root` or the GM error mapping.
6. **Severity:** cosmetic (gossip verdict). Fork-choice weight from invalid payloads is filtered separately by `get_node_children`, which drops invalid FULL nodes (`proto_array.rs#L1693-L1699`).
7. **Known:** the invalidated half is #10464 (PR, open, "Reject Gloas index 1 attestations to an invalidated payload"). For the optimistic half, nothing found.

#### lighthouse-4. Gossip `execution_payload`: a block that failed validation gives IGNORE instead of REJECT
1. **Spec.** SP `p2p-interface.md#L840` (inside the envelope validator): `# [REJECT] The envelope's block passes validation`.
2. **Client.** LH `BC/payload_envelope_verification/gossip_verified_envelope.rs#L154-L169`. The comment reads `// 1. Blocks we haven't seen (IGNORE), and // 2. Blocks we've seen that are invalid (REJECT). // Presently these two cases are conflated.`, and the code is `let Some(proto_block) = fork_choice_read_lock.get_block(&beacon_block_root) else { return Err(EnvelopeError::BlockRootUnknown …`. That error maps to IGNORE plus a queued lookup.
3. **When results differ.** An envelope references a block the node has seen and rejected as invalid: the spec REJECTs (penalising the peer), Lighthouse IGNOREs and queues it.
4. **Prysm.** Agrees with the spec. PR `beacon-chain/sync/validate_execution_payload_envelope.go#L91-L92`: `if err := v.VerifyBlockRootValid(s.hasBadBlock); err != nil { return pubsub.ValidationReject, err }`.
5. **Confidence: high**, self-documented.
6. **Severity:** cosmetic.
7. **Known:** related #8949 (issue, closed, "envelope peer penalties and REJECT/IGNORE mapping"). The TODO is still on stable, so this is effectively known and not fixed.

#### lighthouse-5. Gossip `payload_attestation_message`: a block that failed validation gives IGNORE instead of REJECT
1. **Spec.** SP `p2p-interface.md#L922`: `# [REJECT] The payload attestation's block passes validation`.
2. **Client.** LH `BC/payload_attestation_verification/gossip_verified_payload_attestation.rs#L75-L88`: `// TODO(gloas): These two conditions are conflated. … Presently both cases return IGNORE.`, then `.ok_or(Error::UnknownHeadBlock { beacon_block_root })?`.
3. **When results differ.** A PTC message names a block root the node has seen and found invalid. Spec REJECT; Lighthouse IGNORE.
4. **Prysm.** Agrees with the spec, REJECT on a bad block: PR `beacon-chain/sync/validate_payload_attestation.go#L95-L97`.
5. **Confidence: high.**
6. **Severity:** cosmetic.
7. **Known:** not found as a separate issue. It is a code TODO.

#### lighthouse-6. Gossip `beacon_block`: no REJECT when the parent is not full and the bid does not build on the parent's `latest_block_hash`
1. **Spec.** SP `p2p-interface.md#L662-L666`: `# [REJECT] If the parent is not full, the bid builds on the parent's execution head` / `if not is_parent_node_full(store, block): if bid.parent_block_hash != parent_state.latest_block_hash: raise GossipReject(…)`.
2. **Client.**
   - Gossip verification, LH `BC/block_verification.rs` `new_without_slasher_checks` (`#L906-L1138`), checks bid-parent-root equality (`#L991-L999`) but not `latest_block_hash`.
   - A grep for `latest_block_hash` in `beacon_node/beacon_chain/src` outside tests finds only `test_utils.rs`.
   - The rule is enforced only during the state transition, at LH `consensus/state_processing/src/per_block_processing.rs#L818-L822`: `bid.parent_block_hash() == *latest_block_hash`.
3. **When results differ.** A correctly signed block whose bid's `parent_block_hash` equals neither the parent's bid `block_hash` nor the parent state's `latest_block_hash`. The spec REJECTs it on gossip; Lighthouse ACCEPTs and forwards it, then fails it at import, so consensus is unaffected.
4. **Prysm.** Agrees with the spec. PR `beacon-chain/sync/validate_beacon_block_gloas.go#L41-L42`: `if !s.cfg.chain.HasPayloadBlockHash(blk.ParentRoot(), bid.ParentBlockHash()) { return pubsub.ValidationReject, errors.New("bid does not build on the parent's execution head") }`.
5. **Confidence: medium.** Disproof: no equivalent pre-propagation check found; whether `get_parent_import_status` indirectly rejects this case was not traced.
6. **Severity:** cosmetic (an invalid block is propagated, an invalid block is not accepted into the state).
7. **Known:** not found.

#### lighthouse-7. Gossip `execution_payload_bid`: proposer-preferences key derived from the head, not from `bid.parent_block_root`
1. **Spec.** SP `p2p-interface.md#L1017-L1021`: `dependent_root = get_shuffling_dependent_root(store, bid.parent_block_root, proposal_epoch)`, `prefs_key = (bid.slot, dependent_root)`, `if prefs_key not in seen.proposer_preferences: raise GossipIgnore(…)`.
2. **Client.** LH `BC/payload_bid_verification/gossip_verified_bid.rs#L392-L405`: `// Look up the preferences keyed by the dependent root that is canonical from our head's perspective …`, then `head_state.proposer_shuffling_decision_root_at_epoch(proposal_epoch, cached_head.head_block_root(), ctx.spec)?` and `get_preferences(&bid_slot, dependent_root) … else { return Err(PayloadBidError::NoProposerPreferences …`.
3. **When results differ.** The bid's parent is not the head and its branch has a different dependent root for the proposal epoch. Lighthouse checks the head branch's preferences instead: it IGNOREs bids the spec accepts and can accept bids the spec IGNOREs. The unseen-preferences verdict (IGNORE) itself matches.
4. **Prysm.** Also IGNOREs on unseen preferences (PR `beacon-chain/sync/validate_execution_payload_bid.go#L85-L94`: `if !ok { return pubsub.ValidationIgnore, nil }`). I did not verify which root Prysm derives the key from.
5. **Confidence: medium.** The choice is deliberate. Disproof: the later head-branch-compatibility IGNORE (spec `#L1040`, LH `#L428-L433`) masks most cases. No concrete case was constructed.
6. **Severity:** cosmetic.
7. **Known:** not found. Related closed PRs: #9036, #9337.

#### lighthouse-8. Gossip `execution_payload_bid`: no proposer-lookahead IGNORE; builder checks run on an un-advanced head state
1. **Spec.**
   - SP `p2p-interface.md#L1013-L1015`: `# [IGNORE] The bid's slot is within the parent's proposer lookahead` / `if proposal_epoch > get_current_epoch(state) + MIN_SEED_LOOKAHEAD`.
   - The builder, balance and signature checks (`#L1048-L1077`) use the parent state after `process_slots(state, bid.slot)`.
2. **Client.**
   - LH `gossip_verified_bid.rs`: a grep for `lookahead` or `min_seed_lookahead` in the file returns nothing.
   - The builder checks at `#L130-L155` and the signature check at `#L462-L472` use `head_state` (sub-review).
3. **When results differ.** Either the parent is more than one epoch behind the bid slot (spec IGNORE; Lighthouse may ACCEPT), or a builder's status or balance changes at an epoch boundary between the head state and `bid.slot`.
4. **Prysm.** Not verified.
5. **Confidence: low.** Likely masked by the preferences key and the head-branch check.
6. **Severity:** cosmetic.
7. **Known:** not found.

#### lighthouse-9. Gossip clock-disparity allowance missing for bid slot and proposer-preferences slot
1. **Spec.** SP `p2p-interface.md#L979` (bid: `is_current_or_next_slot`, which includes `MAXIMUM_GOSSIP_CLOCK_DISPARITY`, `#L344-L358`) and `#L1125` (preferences: `is_past_slot` with disparity).
2. **Client.** LH `gossip_verified_bid.rs#L27-L33`: `if bid_slot == current_slot || bid_slot == current_slot.saturating_add(1u64) { Ok(()) } else { Err(…InvalidBidSlot…) }`. The preferences check is in LH `BC/proposer_preferences_verification/gossip_verified_proposer_preferences.rs#L40-L45` (`proposal_slot <= current_slot`, IGNORE).
3. **When results differ.** A message arrives within 500 ms of a slot boundary. Lighthouse IGNOREs where the spec ACCEPTs.
4. **Prysm.** Not verified.
5. **Confidence: medium.**
6. **Severity:** cosmetic.
7. **Known:** not found.

#### lighthouse-10. ExecutionPayloadEnvelopesByRange rejects any request whose start slot is before Gloas
1. **Spec.** SP `p2p-interface.md#L1418-L1420`: "Specifications of request/response methods are equivalent to [BeaconBlocksByRange v2] … with the only difference being the response content type." BlocksByRange answers with whatever exists in the range ("at least the first block … that exists in the range").
2. **Client.** LH `NBP/rpc_methods.rs#L1440-L1449`: `if !fork_name.gloas_enabled() { return Err((RpcErrorResponse::InvalidRequest, "Requested envelopes for pre-gloas slots")); }`. The check keys on `request_start_slot` only.
3. **When results differ.** A request range straddles `GLOAS_FORK_EPOCH`: the spec answers with the Gloas-part envelopes, Lighthouse returns `InvalidRequest` and nothing, which can stall a peer's sync across the fork.
4. **Prysm.** Agrees with the spec by clamping. PR `beacon-chain/sync/rpc_execution_payload_envelopes_by_range.go#L236-L245`: `// Envelopes only exist from the Gloas fork onward — clamp start if needed.`
5. **Confidence: medium.** Disproof: the spec never says outright that a pre-Gloas start is valid; Lighthouse's own range sync may never send one.
6. **Severity:** cosmetic (Req/Resp interop).
7. **Known:** not found.

#### lighthouse-11. EnvelopesByRange canonical filter uses FULL vs EMPTY weight, not the child bid's `parent_block_hash`
1. **Spec.** SP `p2p-interface.md#L1422-L1431`: "A payload is part of that chain if the next block in the chain builds on it, i.e. the `parent_block_hash` of that block's execution payload bid equals the `block_hash` of the payload … Clients MUST NOT include envelopes for blocks whose `ForkChoiceNode` in that chain has `payload_status` equal to `PAYLOAD_STATUS_EMPTY`."
2. **Client.**
   - LH `BC/payload_envelope_streamer/beacon_chain_adapter.rs#L66-L72` says: `// Revisit after Lion's execution payload verdict changes merge: by-range responses must // follow the current head's chain, using each child's parent payload status for ancestors. // https://github.com/ethereum/consensus-specs/pull/5608`
   - It calls `get_canonical_payload_status`, LH `PA/proto_array.rs#L1679-L1761`. That function compares the FULL weight with the EMPTY weight, then applies the tiebreaker.
3. **When results differ.** An ancestor whose canonical child built on EMPTY but whose FULL node now carries more weight, or the reverse (e.g. blocks between justified and finalized, or a stale cached head): Lighthouse serves or omits envelopes contrary to the child-edge rule.
4. **Prysm.** Agrees with the spec. It walks successor bids at PR `beacon-chain/sync/rpc_execution_payload_envelopes_by_range.go#L98-L132`.
5. **Confidence: medium.** Self-acknowledged.
6. **Severity:** cosmetic (Req/Resp content).
7. **Known:** acknowledged in a code comment referencing consensus-specs PR 5608. No Lighthouse issue found.

#### lighthouse-12. Envelopes are not replayed into fork choice after an unclean restart; DB-write failure leaves FULL without an envelope
1. **Spec.** The spec store keeps `store.payloads` (SP `fork-choice.md#L1132-L1133`, `store.payloads[envelope.beacon_block_root] = envelope`). The spec has no persistence model, so this is an implementation-conformance concern.
2. **Client.**
   - `payload_received` exists only inside the persisted proto-array (LH `PA/proto_array.rs#L168-L171`). Fork choice is persisted on epoch transition or reorg (LH `BC/canonical_head.rs#L1564-L1565`, `if is_epoch_transition || reorg_distance.is_some() { self.persist_fork_choice()?;`), on finalization, and on clean shutdown.
   - Envelope import does not persist it (LH `BC/payload_envelope_verification/import.rs#L274-L280` updates memory, then the DB write at `#L305-L312`).
   - On a DB error, `#L312-L324` returns `Err` with `// TODO(gloas) handle db write failure`, and fork choice is not rolled back.
3. **When results differ.** After a crash, envelopes imported since the last persist are on disk but their blocks are EMPTY to fork choice until re-received. Until then, head, `should_build_on_full`, index-1 handling and by-range serving differ from a node that never restarted. A failed DB write instead leaves FULL with no envelope stored.
4. **Prysm.** Not compared.
5. **Confidence: medium** for the mechanism (spot-checked); a recovery path such as range-sync re-fetch was not verified.
6. **Severity:** fork-choice divergence (local, transient).
7. **Known:** related #10414 (PR, open, "Dedup gossip payload envelopes against fork choice after a restart"). The replay gap itself was not found.

#### lighthouse-13. PTC duty requires the head block to be at the duty slot
1. **Spec.** SP `validator.md#L411-L422`: a PTC member does not submit only if no block for the slot was seen.
2. **Client.** LH `BC/beacon_chain.rs#L2229-L2233`: `// Check if we've seen a block for this slot from the canonical head` / `if head.beacon_block.slot() != request_slot { return Err(Error::NoBlockForSlot(request_slot)); }`.
3. **When results differ.** A block for the slot was seen but is not the head (e.g. a late block that lost to its parent): the spec's PTC member votes, Lighthouse produces nothing. PTC participation changes, not validity.
4. **Prysm.** Not verified.
5. **Confidence: medium.**
6. **Severity:** cosmetic (validator behaviour).
7. **Known:** related #9474 (issue, open, "Emit payload attestation votes for non-canonical envelopes").

#### lighthouse-14. Spec-version lag: the Gloas fork upgrade follows v1.7.0-beta.4 builder onboarding, not master (PR 5736)
1. **Spec.** SP `fork.md#L84-L107` (master) routes by credentials first:
   - `if not is_builder_withdrawal_credential(deposit.withdrawal_credentials): pending_deposits.append(deposit); continue`.
   - A valid builder-credential deposit is applied as a builder even if a validator holds the key. An invalid one is kept only `if deposit.pubkey in validator_pubkeys or is_pending_validator(…)`.
2. **Client.** LH `consensus/state_processing/src/upgrade/gloas.rs#L258-L285`:
   - `if state.get_validator_index(&deposit.pubkey)?.is_some() { pending_deposits.push(deposit.clone()); continue; }`
   - `if valid_pending_validator_pubkeys.contains(&deposit.pubkey) { pending_deposits.push(deposit.clone()); continue; }`
   - `if !is_valid_deposit_signature_cached(…) { continue; }`
   - These implement the beta.4 rules.
3. **When results differ.** At the fork slot the pending-deposit queue holds a builder-credential deposit whose pubkey belongs to an existing validator or an earlier valid pending validator deposit. Master onboards or tops up a builder; Lighthouse leaves it in the validator queue, so post-fork `builders`, `pending_deposits` and the state root differ.
4. **Prysm.** Not checked.
5. **Confidence: high** that the code follows beta.4. The difference only matters if devnets or other clients adopt master before the fork.
6. **Severity:** consensus split, if peers target master. Outside fork-choice/p2p, but asked for by the brief.
7. **Known:** not found in sigp/lighthouse (searched "5736"; the only hit, #5736, is an unrelated issue).

- Lighthouse, EIP-7732: affects live code, details withheld (two candidates).

### 2.2 Prysm

#### prysm-1. A bid is marked "seen" before the remaining IGNORE checks run, so a later valid bid from the same builder is IGNOREd

1. **Spec.** `p2p.md#L968-L971`:
   `# [IGNORE] This is the first bid for this slot, parent, and builder` / `bid_key = (bid.slot, bid.parent_block_hash, bid.parent_block_root, bid.builder_index)` / `if bid_key in seen.execution_payload_bids:`
   The bid is added to `seen` only after every check, including the signature, has passed. `p2p.md#L1081-L1083`:
   `# Mark this bid as seen and update the highest-value bid for this slot/parent` / `seen.execution_payload_bids.add(bid_key)`
2. **Prysm.** `beacon-chain/sync/validate_execution_payload_bid.go#L70-L73`:
   `tupleKey := executionPayloadBidTupleKey(bid)` / `if s.hasSeenExecutionPayloadBid(tupleKey) {` / `return pubsub.ValidationIgnore, nil` / `}`
   `#L120-L123`:
   `if err := v.VerifySignature(st); err != nil { return pubsub.ValidationReject, err }` / `s.setSeenExecutionPayloadBid(bid.Slot(), tupleKey)`
   After the key is set, Prysm still runs IGNORE checks for highest value, builder can cover the bid, builder not exiting, parent hash known, gas limit and head compatibility (`#L124-L160`), and the parent-slot REJECT (`#L161-L168`).
3. **When results differ.** A builder's first correctly signed bid for a (slot, parent hash, parent root) tuple fails a later check for a reason that clears within the slot (head not yet moved, parent envelope not arrived, balance short). The spec still accepts that builder's next valid bid for the tuple; Prysm IGNOREs it for the rest of the slot.
4. **Lighthouse agrees with the spec.** It checks for duplicates at `lh:beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs#L304-L316` and records the bid only after the signature and all other checks pass (`observe_bid`, `#L474-L477`).
5. **Confidence: high.** Disproof: no code removes the key when a later check fails. The comment at `#L69` ("a hit here implies a valid-sig bid was already seen") shows this is deliberate, but not what the spec says.
6. **Severity:** a valid message is dropped (gossip IGNORE). This is closest to "valid block rejected" at the bid level. It costs bid liveness and proposer revenue; it does not split consensus.

#### prysm-2. ExecutionPayloadEnvelopesByRange with `count > MAX_REQUEST_PAYLOADS` returns the *last* 128 envelopes of the range, not the first

1. **Spec.**
   - `p2p.md#L1418-L1420`: "Specifications of request/response methods are equivalent to [BeaconBlocksByRange v2] … with the only difference being the response content type."
   - `phase0-p2p.md#L1555-L1556`: "Clients MUST respond with at least the first block that exists in the range, if they have it, and no more than `MAX_REQUEST_BLOCKS` blocks."
   - `phase0-p2p.md#L1560`: "Clients MAY limit the number of blocks in the response."
2. **Prysm.** `beacon-chain/sync/rpc_execution_payload_envelopes_by_range.go`:
   - `#L231`: `rp.end, err = rp.start.SafeAdd(rp.size - 1)` uses the raw `Count`.
   - `#L253-L256`: `if rp.size > maxRequest { rp.size = maxRequest }` clamps the size, but `rp.end` is not clamped again.
   - The walk then starts at `#L115` from `s.canonicalSuccessorBlock(ctx, rp.end+1)` and goes *backwards*, with `wQuota := params.BeaconConfig().MaxRequestPayloads` (`#L136`).
   - It stops at `if uint64(len(collected)) >= wQuota { break }` (`#L158-L160`), then reverses the list (`#L169`).
3. **When results differ.** A peer requests more than 128 slots and the canonical chain has more than 128 full payloads in `[start_slot, min(start_slot+count-1, current_slot)]`. Prysm returns the 128 newest and omits the first envelope, which the spec requires; a requester expecting a response starting at `start_slot` (as for blocks) sees a gap.
4. **Lighthouse** rejects `count > max_request_payloads` with InvalidData (`lh:beacon_node/lighthouse_network/src/rpc/handler.rs#L966-L979`). That conforms.
5. **Confidence: high.** Disproof: Prysm's own requester never asks for more than 128 (per the read pass), so only other clients are affected.
6. **Severity: cosmetic** (Req/Resp conformance only).

#### prysm-3. ExecutionPayloadEnvelopesByRange with `start_slot > current_slot` can return an envelope below `start_slot`

1. **Spec.** The range is `[start_slot, start_slot + count)` (`phase0-p2p.md#L1507`), and "The response MUST contain no more than `count` blocks" (`#L1562`), applied to envelopes through `p2p.md#L1418-L1420`.
2. **Prysm.**
   - `rpc_execution_payload_envelopes_by_range.go#L226-L228`: `if rp.start > current { return rangeParams{start: current, end: current, size: 0}, nil }`.
   - `canonicalSuccessorBlock(current+1)` falls back to the head root (`beacon-chain/db/kv/blocks.go#L840-L846`, "No block found at or above slot — fall back to the head block root.").
   - `#L131-L134` starts from the head's own payload when `FullBeatsEmpty(successorRoot)`.
   - The loop stops only on `blindedEnv.Message.Slot < rp.start` (`#L150-L152`), and `rp.start` is now `current`. `size: 0` is never used as the quota.
3. **When results differ.** A range starts after the server's current slot, the head is at the current slot and fork choice holds it FULL. Prysm sends the head envelope, whose slot is below `start_slot`; the spec sends nothing. Prysm's own client rejects such a response (`beacon-chain/sync/rpc_send_request.go#L1010-L1014`, per the read pass).
4. **Lighthouse agrees with the spec.** It filters every envelope against `[req_start_slot, req_start_slot+count)` before sending (`lh:beacon_node/network/src/network_beacon_processor/rpc_methods.rs#L1508-L1515`).
5. **Confidence: high.** The tip-serving path was added by the merged PR 17533 ("Serve the chain tip's envelope in envelopes by range when fork choice holds the tip full"), which did not re-check the lower bound against the original `start_slot`.
6. **Severity: cosmetic** (Req/Resp only).

#### prysm-4. Payload attestations submitted over REST/gRPC are applied to fork choice without the spec's `block.slot == data.slot` check

1. **Spec.** `fc.md#L1153-L1154` (on_payload_attestation_message):
   `if data.slot != state.slot:` / `return`
   Here `state = store.block_states[data.beacon_block_root]`, so a message naming a block from another slot records no vote.
2. **Prysm.**
   - `beacon-chain/blockchain/receive_payload_attestation_message.go#L27-L50` performs no slot check. It looks up the PTC with `PtcLookupState(ctx, root, a.Data.Slot)`, then for each PTC seat calls `SetPTCVote(root, idx, a.Data.PayloadPresent, a.Data.BlobDataAvailable)`.
   - `SetPTCVote` (`forkchoice/doubly-linked-tree/gloas.go#L537-L548`) only checks that the node exists.
   - REST `beacon-chain/rpc/eth/beacon/handlers_pool.go#L994-L1046` checks the current slot, PTC membership and signature, then calls `ReceivePayloadAttestationMessage`. It does not check that the block is at `data.slot`.
   - gRPC `SubmitPayloadAttestation` (`beacon-chain/rpc/prysm/v1alpha1/validator/payload_attestation.go#L64-L119`, per the read pass) behaves the same way.
   - The gossip path does check it (`beacon-chain/sync/validate_payload_attestation.go#L85-L92`).
3. **When results differ.** A correctly signed message from a current-slot PTC member names an *older* block root (e.g. after a skipped slot or from a buggy validator client). The spec ignores it; Prysm sets PTC bits on the older block, feeding `should_extend_payload`, the tiebreaker and `should_build_on_full`.
4. **Lighthouse agrees with the spec.** It has the early return `if block.slot != payload_attestation.data.slot { return Ok(()); }` (`lh:consensus/fork_choice/src/fork_choice.rs#L1426-L1428`).
5. **Confidence: high** on the code path, **medium** that it matters: the submitter must hold a current-slot PTC key and the votes land on one node. Disproof: `PtcLookupState` does not bind the block slot; it only selects a state by dependent root (`receive_payload_attestation_message.go#L52-L82`). Merged PR 17555 ("…skip votes for older blocks") fixed only the in-block path (`process_block.go#L625-L660`).
6. **Severity: fork-choice divergence** (local node only).

#### prysm-5. Queued payload attestations are applied after their slot has passed

1. **Spec.** `fc.md#L1167-L1169`:
   `if not is_from_block:` / `# Check that the attestation is for the current slot` / `assert data.slot == get_current_slot(store)`
2. **Prysm.** `beacon-chain/sync/pending_payload_attestation.go#L68-L107` (`processPendingPayloadAttestation`) replays queued messages when the block arrives. It re-checks only `payloadAttestationCache.Seen(...)` (`#L94`) and `blockSlot != pa.Slot()` (`#L97`), not the current slot.
3. **When results differ.** A PTC message arrives before its block and the block arrives after its slot has ended: Prysm applies the queued votes in a later slot, the spec rejects them.
4. **Lighthouse agrees with the spec.** It enforces the current slot for non-block messages inside fork choice (`lh:consensus/fork_choice/src/fork_choice.rs#L1430-L1441`).
5. **Confidence: medium.** Whether the queue is pruned at the slot boundary was not traced; none appears in the function read.
6. **Severity: fork-choice divergence** (low: it only affects late blocks).

#### prysm-6. Init-sync batch path skips `notify_ptc_messages` and can mark a parent FULL from an unverified child bid

1. **Spec.**
   - `fc.md#L1095`: `notify_ptc_messages(store, state, block.body.payload_attestations)` runs for every block in `on_block`.
   - `fc.md#L1059-L1061`: "If this block builds on the parent's full payload, that payload must have been verified by on_execution_payload_envelope … `assert is_payload_verified(store, block.parent_root)`".
   - `fc.md#L243`: the anchor store starts with `payloads={}`.
2. **Prysm.**
   - `handleBlockPayloadAttestations` is called only from the single-block `postBlockProcess` (`beacon-chain/blockchain/process_block.go#L89`). The batch path `onBlockBatch` (`process_block.go#L188-L382`) never calls it.
   - In the batch path, `fillInForkChoiceMissingBlocks` runs at `#L235`, before batch signature verification at `#L330-L341`. It calls `insertFirstPayloadIfNeeded` (`beacon-chain/blockchain/process_block_helpers.go#L444-L447`, `#L457-L469`):
   `if s.builtOnFullParentInForkchoice(b) && !s.cfg.ForkChoiceStore.HasFullNode(parentRoot) {` / `...` / `s.cfg.ForkChoiceStore.MarkFullNode(parentRoot, gasLimit)` / `}`
     This marks the parent FULL from the child bid's `parent_block_hash` alone, without an envelope; per the read pass, nothing rolls it back if the batch later fails.
3. **When results differ.**
   - Batch-imported blocks never get their in-block PTC votes.
   - A batch whose first block claims a full parent makes Prysm create the parent's FULL node with no verified envelope, even if the batch is later rejected; the spec rejects that block.
4. **Lighthouse agrees with the spec.** It checks `ParentPayloadNotVerified` (`lh:consensus/fork_choice/src/fork_choice.rs#L829-L839`) and applies PTC votes for every imported block (`lh:beacon_node/beacon_chain/src/block_verification.rs#L1782-L1805`).
5. **Confidence: medium.**
   - (a) The missing call is certain (`grep handleBlockPayloadAttestations` shows one non-test caller); its effect is uncertain because batch-imported blocks are usually older than the previous slot by the end of sync.
   - (b) The child's state transition binds `parent_execution_requests` to the parent bid (`beacon-chain/core/gloas/parent_payload.go#L49-L56`, per the read pass), which partly compensates, but the parent's EL payload is never checked in this path unless `envelopes[0]` was supplied (`process_block.go#L158-L180`).
6. **Severity: fork-choice divergence.** Case (b) amounts to an invalid parent payload status accepted.

#### prysm-7. Restart rebuilds fork choice only up to the justified checkpoint and infers FULL status; stored envelopes are not replayed

1. **Spec.** There is no restart rule; the store is defined as if every block and every envelope had been processed (`on_execution_payload_envelope`, `fc.md#L1107-L1134`). Fork choice after a restart should equal the uninterrupted store.
2. **Prysm.**
   - `beacon-chain/blockchain/setup_forkchoice.go#L33-L39`: `startupHeadRoot()` returns the justified root by default.
   - `#L64-L66`: `if headRoot == fRoot { return nil }` returns before `markFinalizedRootFull`.
   - `resolveChainPayloadStatus` (`#L167-L192`) sets FULL from successor bids (`BlockBuiltOnParentPayload`) and uses `HasExecutionPayloadEnvelope` only for the last block.
   - No call to `InsertPayload` replays envelopes stored in the DB for blocks after the justified root.
3. **When results differ.** After any restart:
   - Blocks after the justified checkpoint are in the DB but not in fork choice. Their envelopes are never applied.
   - When justified equals finalized, the anchor's FULL node is never created.
   - A child that builds on a full parent then fails `ParentPayloadReady` (`beacon-chain/blockchain/chain_info.go#L486-L500`), or fails fork-choice insert with "invalid parent root".
4. **Lighthouse** persists proto-array nodes together with `payload_received` (`lh:consensus/proto_array/src/proto_array_fork_choice.rs#L1116`, per the read pass), so the status survives a restart.
5. **Confidence: high.** This matches the field report in the open PR 17735.
6. **Severity:** valid block rejected (the node stalls) and fork-choice divergence.
7. **Known:** yes, PR 17735, which is open.

#### prysm-8. Previous-slot EMPTY/FULL decided by early-counted current-slot votes instead of weight 0 plus the tiebreaker

1. **Spec.** `fc.md#L567-L570`:
   `# [New in Gloas:EIP7732]` / `if is_previous_slot_payload_decision(store, node):` / `return Gwei(0)`
   `is_previous_slot_payload_decision` is defined at `fc.md#L461-L464`.
2. **Prysm.**
   - `beacon-chain/forkchoice/doubly-linked-tree/gloas.go#L298-L317` (`choosePayloadContent`) compares `fn.weight` with `en.weight` first. It uses `shouldExtendPayload` only on a tie in the previous slot.
   - The only adjustment is `removeProposerBoostFromParent` (`gloas.go#L734-L753`), which removes the boost and nothing else.
   - From `SlotDuration-2s` into each slot, `UpdateHead` runs with attestations processed under `disparity += reorgLateBlockCountAttestations` (2s) (`beacon-chain/blockchain/receive_attestation.go#L27`, `#L94-L104`, `#L163-L166`). `OnAttestation` only requires `VerifyTime(slot+1, disparity)` (`process_attestation.go#L83-L86`).
3. **When results differ.** In the last ~2 s of slot N+1, slot-(N+1) votes count toward block N's EMPTY or FULL node, including through a slot-(N+1) child; the spec keeps both at 0 and chooses by `should_extend_payload`. If the vote majority disagrees with the PTC-based tiebreaker, the local head, FCU and next proposal's parent status differ until slot N+2.
4. **Lighthouse agrees with the spec.** It returns `Ok(0)` for non-PENDING nodes of the previous slot (`lh:consensus/proto_array/src/proto_array.rs#L1775-L1799`).
5. **Confidence: medium.** The behaviour is clear, but it is a deliberate look-ahead.
6. **Severity: fork-choice divergence** (transient).
7. **Known:** yes. Issue 17119 is closed (the brief records it as rejected), and the fix PR 17146 was closed unmerged.

#### prysm-9. A proposer reorg picks the parent's payload status by weight, not by `get_parent_payload_status(head_block)`

1. **Spec.** `fc.md#L851-L863`: "This function is modified to preserve the payload status of the parent node when a proposer re-org is selected."
   `parent_payload_status = get_parent_payload_status(store, head_block)` / `parent_node = ForkChoiceNode(root=parent_root, payload_status=parent_payload_status)`
2. **Prysm.**
   - `GetProposerHead` returns only a root (`beacon-chain/forkchoice/doubly-linked-tree/reorg_late_blocks.go#L107-L171`: `return parent.node.root`).
   - The proposer derives the status in `parentFull` (`beacon-chain/rpc/prysm/v1alpha1/validator/proposer.go#L185-L194`): `if root == parentRoot { return full } … return vs.ForkchoiceFetcher.FullBeatsEmpty(parentRoot)`.
   - `FullBeatsEmpty` is the weight/tiebreak choice made by `choosePayloadContent`.
3. **When results differ.** A late head H (slot N) built on P's EMPTY variant, but at slot N+1 P's FULL variant is heavier (e.g. slot-N voters chose P with index 1): the spec reorgs onto P-EMPTY, Prysm builds on P-FULL. The reverse case also differs.
4. **Lighthouse:** not determined; how it picks the parent's status after `get_proposer_head` was not traced.
5. **Confidence: medium-low.** It is unclear how often P's two variants disagree with H's choice.
6. **Severity: fork-choice divergence.** The local proposal builds on a non-spec parent node.

#### prysm-10. `beacon_attestation` / aggregate with `index == 1` is ACCEPTed while the attested payload is optimistic

1. **Spec.** `p2p.md#L455-L458`:
   `# [IGNORE] The attested execution payload is optimistic` / `payload_status = block_payload_statuses.get(block_root, PAYLOAD_STATUS_NOT_VALIDATED)` / `if payload_status == PAYLOAD_STATUS_NOT_VALIDATED:` / `raise GossipIgnore("attested payload is optimistic")`
2. **Prysm.** `beacon-chain/sync/validate_beacon_attestation.go#L373-L399` (`validateGloasCommitteeIndex`):
   - `>= 2` → REJECT
   - same slot with `index != 0` → REJECT
   - `hasBadPayload` → REJECT
   - `!HasFullNode` → IGNORE
   - there is no optimistic check. Full nodes carry an `optimistic` flag (`forkchoice/doubly-linked-tree/gloas.go#L134-L141`, per the read pass).
3. **When results differ.** An index-1 attestation for a payload whose EL verdict is still SYNCING or ACCEPTED. The spec IGNOREs it; Prysm forwards it.
4. **Lighthouse agrees with Prysm.** It checks only `payload_received` (`lh:beacon_node/beacon_chain/src/attestation_verification.rs#L964-L972`, `#L624-L634`).
5. **Confidence: high.**
6. **Severity: cosmetic.** The gossip verdict differs and messages propagate.
7. **Known:** yes. PR 17574, "Ignore Gloas payload-present attestations while the payload is optimistic", is open.

#### prysm-11. Execution-payload-bid verdict ordering differences (cosmetic)

1. **Spec.**
   - The highest-value IGNORE (`p2p.md#L973-L977`) runs before the signature REJECT (`#L1077-L1079`).
   - "bid slot > parent slot" is an early REJECT (`p2p.md#L1002-L1004`).
2. **Prysm.**
   - The signature check (`validate_execution_payload_bid.go#L120-L122`) runs before highest-value (`#L124-L127`).
   - The parent-slot REJECT is last (`#L161-L168`). Before it, `#L81-L84` IGNOREs when the next-slot state cache has nothing for the parent. In practice that makes the REJECT unreachable, so the bid gets IGNORE (per the read pass, `core/transition/trailing_slot_state_cache.go#L41-L54`).
3. **When results differ.**
   - A lower-value bid with a bad signature: the spec says IGNORE, Prysm says REJECT, so Prysm penalizes the peer.
   - A bid whose slot is not above its parent's: the spec says REJECT, Prysm says IGNORE.
4. **Lighthouse agrees with the spec** (value check `gossip_verified_bid.rs#L320-L329`, signature last at `#L462-L472`, early `BidNotDescendantOfParent` at `#L337-L344`).
5. **Confidence: medium.**
6. **Severity: cosmetic** (peer scoring only).

#### prysm-12. Spec-version lag: builder onboarding at the fork follows v1.7.0-beta.4, not master (consensus-specs PR 5736)

1. **Spec (master).**
   - `fork.md#L69`: "A valid builder registration can use a validator's public key."
   - `fork.md#L86`: `if not is_builder_withdrawal_credential(deposit.withdrawal_credentials):` is checked first. Non-builder deposits stay queued "even if a builder uses the same public key" (`fork.md#L68`).
2. **Prysm.** `beacon-chain/state/state-native/setters_gloas.go#L808-L842` (beta.4 text embedded at `#L743-L794`; called from `core/gloas/upgrade.go#L120-L133`):
   - an existing-validator pubkey keeps the deposit queued (`if _, ok := b.validatorIndexByPubkey(pubkey); ok {… continue}`, `#L810-L814`), and so does a valid pending validator deposit (`#L829-L837`);
   - an existing-builder pubkey tops up that builder *before* the credential check (`#L816-L822`).
3. **When results differ.** At the fork, a builder-credential deposit uses a validator's pubkey, or a non-builder deposit uses the pubkey of a builder just created. Master creates the builder or keeps the deposit queued; Prysm does the reverse, so the post-fork state root differs from a master client.
4. **Lighthouse is also on beta.4** (`lh:consensus/state_processing/src/upgrade/gloas.rs#L258-L305`, per the read pass).
5. **Confidence: high** that the lag exists. **Low** that it triggers, since it needs such deposits pending at the fork.
6. **Severity:** consensus split against master-spec clients. This is spec-version lag, not a bug against beta.4.
7. **Known:** PR 17664 ("Bump consensus spec to v1.7.0-beta.4") is closed. I found no PR bumping past beta.4.

#### Minor notes

- **Envelope dedupe.** `validate_execution_payload_envelope.go#L77-L80` IGNOREs every envelope for a root that already has a full node. Under the spec, a mismatched builder gets the builder-index REJECT (`p2p.md#L858-L860`), so Prysm under-penalizes the peer. The effect is cosmetic.
- **Lighthouse ordering.** Lighthouse records block PTC votes (`block_verification.rs#L1782-L1805`) before `on_block` (`beacon_chain.rs#L4525`). Prysm records them after insert (`process_block.go#L79-L91`), which is the spec order.

- Prysm, EIP-7732: affects live code, details withheld (five candidates in logic shared with the live Fulu fork).

### 2.3 Teku

#### teku-1. Spec-version lag: onboarding routes by pubkey before credentials

1. **Spec, verbatim:** “A valid builder registration can use a validator's public key.” `specs/gloas/fork.md#L65-L69`. `specs/gloas/fork.md#L83-L119` tests credentials first; a valid builder deposit may register despite a matching validator, while non-builder deposits remain queued.
2. **Teku, quoted:** `TSPEC/forktransition/GloasStateUpgrade.java#L222-L268`: `if (validatorsUtil.getValidatorIndex(state, pubkey).isPresent())` leads to `pendingDeposits.add(deposit);` and `continue;`. Builder lookup/top-up also precedes the credential test. `pendingValidators.isPendingValidator(pubkey)` prevents registration before checking the builder deposit's signature.
3. **Condition:** At Gloas transition, a valid builder deposit uses an existing/pending validator's key: master registers the builder, Teku queues it. A non-builder deposit following a same-key builder registration tops up the builder in Teku but stays queued in master. Upgrade state roots differ.
4. **Lighthouse:** LH `consensus/state_processing/src/upgrade/gloas.rs#L258-L303` likewise checks existing/pending validators first and tops up existing builders without checking credentials. **Agrees with Teku/beta.4, differs from master.**
5. **High confidence; disproof:** Checked complete routing/signature/top-up guards. Behavior matches beta.4: **spec-version lag**, not a beta.4 failure. Fix unmerged; reported vector checks not repeated.
6. **Severity: consensus split** on mixed-version transition replay with affected deposits; occurrence on deployed testnets unverified. **Known:** Teku PR 11582, **open/unmerged**; spec PR 5736 **closed/merged**. Teku all-state queries: `7732`, `gloas`, `epbs`, `5736`, `onboard builders`.

#### teku-2. Gloas proposer-boost equivocation suppression missing

1. **Spec, verbatim:** `return len(equivocations) == 0`. `specs/gloas/fork-choice.md#L531-L561` suppresses boost for a weak immediately preceding parent when an early same-proposer conflicting block exists. FC#L567-L589 uses this predicate in weight calculation.
2. **Teku, quoted:** `TSPEC/util/ForkChoiceUtilGloas.java#L335-L365` ends `return true;` with the equivocation branch deferred. Active `storage/src/main/java/tech/pegasys/teku/storage/protoarray/ForkChoiceStrategy.java#L147-L166` resolves the boost node and passes `proposerBoostAmount` into delta computation. `shouldApplyProposerBoost` has only definitions/test calls, no production call: active scoring lacks the gate.
3. **Condition:** Two valid previous-slot blocks share a proposer; the conflict was PTC-timely and the selected parent is weak. A timely current-slot child receives boost. A competitor's unboosted advantage is smaller than boost: spec suppresses it and can choose the competitor; Teku retains it and can choose the child. Imported conflicts suffice.
4. **Lighthouse:** LH `PA/proto_array.rs#L853-L883` suppresses boost on a PTC-timely same-slot/same-proposer conflict. **Agrees with spec** here. Choose no attester equivocators so Lighthouse's separate weakness approximation does not affect this comparison.
5. **High confidence; disproof:** Traced timeliness, model and scoring for an alternate suppression path; none found. PR 11041 is **closed/unmerged**, not a fix at this pin. Finding concerns Gloas suppression only.
6. **Severity: fork-choice divergence. Known:** 10608 and 11020, **open**; PR 11041, **closed/unmerged**. Queries: EIP/fork terms, `should_apply_proposer_boost`, `proposer equivocation`, `proposer_equivocation`, all states.

#### teku-3. Payload-attestation gossip uses the referenced branch's PTC

1. **Spec, verbatim:** `state = store.block_states[get_head(store).root]`. `specs/gloas/p2p-interface.md#L926-L945` uses that head state for membership/signature checks. Fork choice separately uses referenced state.
2. **Teku, quoted:** `TST/payloadattestation/PayloadAttestationMessageGossipValidator.java#L156-L198` loads `.getStateAtBlockRoot(data.getBeaconBlockRoot())`, passes that state to `calculatePayloadTimelinessCommitteePositions`, and rejects empty membership. It never selects the head state here.
3. **Condition:** Two validated branches have different current-slot PTCs. Head is on one; the message names a same-slot block on the other. A signed message from a referenced-branch-only member passes Teku but fails spec; head-only membership reverses this. Divergence must predate selection inputs. State-dependent PTC: `specs/gloas/beacon-chain.md#L1266-L1281`, cached lookup `specs/gloas/beacon-chain.md#L1392-L1404`.
4. **Lighthouse:** LH `BC/payload_attestation_verification/gossip_verified_payload_attestation.rs#L101-L118` passes `beacon_block_root` to `with_cached_shuffling`, then checks that PTC. **Agrees with Teku, differs from the literal gossip spec** under differing branch committees. Fork-choice membership is a separate rule.
5. **Medium confidence; disproof:** Shared selection history makes shallow-fork PTCs equal, eliminating that trigger. Deep-branch invariance is not guaranteed. Spec ambiguity remains; no clarification found. Retained-store feasibility unverified; no deep-fork fixture.
6. **Severity: potential fork-choice divergence via propagation.** Direct difference: conditional gossip ACCEPT/REJECT. **Not found** as a matching Teku bug report. Queries: `7732`, `gloas`, `epbs`, `payload attestation state`, `"payload_attestation" "head"`, `"get_ptc" "gossip"`; web `"PayloadAttestationMessageGossipValidator" "head"`. Hits 10174/11501 did not identify this. Novelty unproven.

#### teku-4. ByRoot serving omits the non-finalized-history exception

1. **Spec, verbatim fragment:** “even if outside of this range.” `specs/gloas/p2p-interface.md#L1483-L1494` requires retaining and serving envelopes newer than the latest finalized checkpoint, including outside the normal request window.
2. **Teku, quoted:** `networking/eth2/src/main/java/tech/pegasys/teku/networking/eth2/rpc/beaconchain/methods/ExecutionPayloadEnvelopesByRootMessageHandler.java#L98-L168` derives `minServableEpoch` from current epoch, configured window and fork epoch. It skips old known slots before retrieval, and filters retrieved envelopes using `return spec.computeEpochAtSlot(slot).isGreaterThanOrEqualTo(minServableEpoch);`. Neither path checks finalization.
3. **Condition:** Finalization stalls beyond Teku's serving window. A held envelope is newer than finalization but older than the cutoff. A valid one-root request returns empty; spec requires service.
4. **Lighthouse:** LH `NBP/rpc_methods.rs#L596-L611` passes requested roots to the envelope streamer and sends available envelopes. LH `BC/payload_envelope_streamer/mod.rs#L90-L146` in `payload_envelope_streamer/` loads held data and bypasses canonical filtering for ByRoot, without Teku's age cutoff. **Matches spec for held data**; pruning policy unassessed.
5. **High confidence; disproof:** Neither slot-known nor retrieval/filter path grants the exception. Assume a held complete envelope, avoiding EL reconstruction assumptions. A larger configured window delays, but does not eliminate, the trigger.
6. **Severity: cosmetic under the supplied taxonomy**: wire-serving violation with possible recovery impact; no direct head change shown. **Not found** as a matching report. Queries: EIP/fork terms, `envelopes retention`, `"ExecutionPayloadEnvelopesByRoot"`, `"isWithinServableRange"`; web `"unfinalized" "envelopes"`. PRs 10598/10616/10627 did not identify this. `unfinalized envelopes` API query failed with 403 rate limit, not a negative result.

#### teku-5. Known envelope poisoning persists after correct payload import

1. **Spec, verbatim:** `payload_status == PAYLOAD_STATUS_INVALIDATED`. `specs/gloas/p2p-interface.md#L445-L462` rejects full votes for invalidated payloads, not a now-valid committed payload whose root was associated with earlier malformed contents. `specs/gloas/fork-choice.md#L1121-L1133` verifies/stores sync envelopes without permanently banning a root after one failed envelope.
2. **Teku, quoted:** `TST/validation/ExecutionPayloadGossipValidator.java#L323-L330` calls `seenPayloads.add(envelope.getBlockRootAndBuilderIndex())` before EL verification. `TST/execution/DefaultExecutionPayloadManager.java#L217-L239` adds `blockRootsWithInvalidExecutionPayload.add(` on failed verification, but successful import returns without removing it. `TST/validation/GossipValidationHelper.java#L256-L274` checks this mark before current payload status, returning `return InternalValidationResult.reject(`. `beacon/sync/src/main/java/tech/pegasys/teku/beacon/sync/gossip/executionpayloads/RecentExecutionPayloadsFetchService.java#L118-L128` also stops recovery requests when an envelope has merely been seen.
3. **Condition:** A builder signs an envelope declaring the bid's hash but with EL-invalid contents. Gossip records the seen pair; failed import marks the root invalid. The correct envelope is then duplicate-ignored and by-root recovery suppressed. Even if it imports through sync/direct import, the stale invalid mark remains: Teku rejects a valid later-slot index-one attestation that spec's verified/VALID status permits.
4. **Lighthouse:** LH `BC/payload_envelope_verification/gossip_verified_envelope.rs#L173-L186` **shares initial gossip deduplication**; LH `BC/payload_envelope_verification/gossip_verified_envelope.rs#L294-L306` exempts non-gossip sources from duplicate rejection. LH `BC/attestation_verification.rs#L954-L975` uses `payload_received`, not Teku's sticky invalid-root mark. After valid import, this allows the full vote. Other poisoning variants remain unassessed.
5. **High confidence; disproof:** No invalid-set clearing found on successful direct import. **Initial gossip acceptance/dedup is spec-permitted**: spec also has a seen set and no gossip EL-hash recomputation. Finding is recovery poisoning and later VALID-payload verdicts. PR 11588's hash check is a mitigation, not a normative gossip requirement.
6. **Severity: potential fork-choice divergence through rejected valid full votes/recovery failure.** **Known:** issue 11568, **open**; PR 11588, **open/unmerged**. EIP/fork queries plus direct reads. PR proposes hash verification/status clearing/separate fetch gate; its reported devnet checks were not repeated here.

- Teku, EIP-7732: affects live code, details withheld.

## 3. Cross-client table

One row per rule any item flagged or checked; rules with identical cells share a row. Cells: `agrees with spec`, `differs (see <client>-<n>)`, `not checked`; a reference to another client's candidate means that candidate holds the comparison. FC/P2P/V = `specs/gloas/{fork-choice,p2p-interface,validator}.md`, P0 = `specs/phase0/p2p-interface.md`; ByRange/ByRoot = ExecutionPayloadEnvelopesByRange/ByRoot.

| Rule (spec) | Lighthouse | Prysm | Teku |
|---|---|---|---|
| on_block: full parent ⇒ parent payload verified (FC#L1058-L1061) | agrees with spec | agrees with spec; batch path differs (see prysm-6) | agrees with spec |
| on_block: block-carried PTC votes applied after the on_block checks, for every imported block (FC#L1067-L1095) | differs (see lighthouse-1) | differs (see prysm-6) | agrees with spec |
| get_weight = 0 for previous-slot EMPTY/FULL (FC#L461-L464, #L567-L570) | agrees with spec | differs (see prysm-8) | agrees with spec |
| should_apply_proposer_boost equivocation set, wired into scoring (FC#L531-L561) | agrees with spec | agrees with spec | differs (see teku-2) |
| is_head_weak adds head-committee equivocator weight (FC#L815-L832) | differs (see lighthouse-2) | differs (see lighthouse-2) | agrees with spec |
| should_build_on_full (FC#L475-L486); other bid verdicts: value, builder, balance, parent hash, gas limit, head compatibility, signature (P2P#L973-L1079) | agrees with spec | agrees with spec | not checked |
| should_extend_payload, tiebreaker, strict `>` PTC thresholds, envelope import after EL + DA, on-attestation index rules (FC#L326-L363, #L497-L526, #L937-L944, #L1119-L1133); attestation gossip index basics, other envelope and PTC-message verdicts, ByRoot basics (P2P#L440-L453, #L846-L877, #L904-L945, #L1466-L1494); unseen proposer preferences ⇒ IGNORE (P2P#L1017-L1021) | agrees with spec | agrees with spec | agrees with spec |
| Boost to ancestors only; head key order; get_node_children/get_ancestor; update_proposer_boost_root; update_latest_messages; PTC membership in on_payload_attestation_message; pre-Gloas parent_execution_requests empty (FC#L388-L398, #L582-L587, #L614-L669, #L970, #L1019-L1031, #L1146-L1179; V#L253-L254) | agrees with spec | not checked | not checked |
| get_parent_payload_status hash comparison; get_supported_node (FC#L368-L372, #L436-L451) | not checked | agrees with spec | not checked |
| on_payload_attestation_message: no vote unless data.slot == block slot, on every entry path; non-block message only for the current slot, also when replayed (FC#L1153-L1154, #L1167-L1169) | agrees with spec | differs (see prysm-4, prysm-5) | not checked |
| Invalid-envelope mark cleared by a later valid import of the root (FC#L1121-L1133, P2P#L445-L462) | agrees with spec | not checked | differs (see teku-5) |
| Store after restart equals the uninterrupted store (no spec rule; FC#L1132-L1133) | differs (see lighthouse-12) | differs (see prysm-7) | agrees with spec |
| get_proposer_head keeps the parent payload status (FC#L851-L863) | not checked | differs (see prysm-9) | not checked |
| Gloas deadlines switch at the fork slot (FC#L747-L790, V#L43-L48) | agrees with spec | agrees with spec (first-slot late-block ticker keeps the Fulu offset) | agrees with spec |
| Attestation index 1: IGNORE while the payload is optimistic (P2P#L455-L458) | differs (see lighthouse-3) | differs (see prysm-10) | agrees with spec |
| Attestation index 1: REJECT when payload invalidated (P2P#L459-L462); envelope or PTC message for a block that failed validation ⇒ REJECT (P2P#L840, #L922) | differs (see lighthouse-3, lighthouse-4, lighthouse-5) | agrees with spec | agrees with spec |
| beacon_block: parent not full ⇒ bid builds on parent_state.latest_block_hash, else REJECT (P2P#L662-L666); ByRange range starting before Gloas served from the fork (P2P#L1418-L1420) | differs (see lighthouse-6, lighthouse-10) | agrees with spec | not checked |
| Bid_key added to `seen` only after every check (P2P#L968-L971, #L1081-L1083); bid verdict order: value IGNORE before signature REJECT, parent-slot REJECT reachable (P2P#L973-L977, #L1002-L1004, #L1077-L1079); ByRange: count > max ⇒ first envelopes or limit, nothing below start_slot, start > current ⇒ empty (P0#L1507, #L1555-L1562) | agrees with spec | differs (see prysm-1, prysm-11, prysm-2, prysm-3) | not checked |
| Bid prefs_key root from bid.parent_block_root; proposer-lookahead IGNORE and builder checks on the state advanced to bid.slot; bid slot clock disparity (P2P#L979, #L1013-L1021, #L1048-L1077); PTC duty when any block for the slot was seen (V#L411-L422) | differs (see lighthouse-7, lighthouse-8, lighthouse-9, lighthouse-13) | not checked | not checked |
| proposer_preferences verdicts, incl. is_past_slot with disparity (P2P#L1103-L1174) | differs (see lighthouse-9) | agrees with spec | not checked |
| Envelope: second envelope for a root from another builder ⇒ builder-index REJECT (P2P#L858-L860) | not checked | differs (see prysm-note-1) | not checked |
| PTC message: membership and signature from the head state (P2P#L926-L945) | differs (see teku-3) | not checked | differs (see teku-3) |
| ByRange: canonical by child bid parent_block_hash, EMPTY excluded, head only if FULL (P2P#L1422-L1431) | differs (see lighthouse-11) | agrees with spec | agrees with spec |
| ByRange count == 0 (spec silent; Lighthouse sends no chunks, Prysm InvalidRequest, Teku empty success) | agrees with spec | agrees with spec | agrees with spec |
| ByRoot: unfinalized envelopes served outside the window (P2P#L1483-L1494) | agrees with spec | not checked | differs (see teku-4) |
| Gloas upgrade: builder onboarding by credentials first (`specs/gloas/fork.md#L84-L107`, master after PR 5736) | differs (see lighthouse-14) | differs (see prysm-12) | differs (see teku-1) |

## 4. Known-check summary

Each item used GitHub `search/issues` on its client repository (issues and PRs, open and closed) and read hits via the API. Issue numbers are inside each candidate unless given here; Teku's query strings are inside its candidates, the Lighthouse and Prysm query lists were dropped for size. "Not found" is bounded by those queries.

- **Known:** lighthouse-2, -3 (invalidated half only); prysm-7, -8 (issue closed as rejected, fix PR unmerged), -10; teku-1, -2, -5.
- **Related only:** lighthouse-1 (#10088 issue, #10101 PR, open: block-carried votes discarded if the envelope is late); lighthouse-4 (TODO still in code); lighthouse-7, -8, -11, -12, -13; prysm-12 (no bump past beta.4).
- **Not found:** lighthouse-5, -6, -10, -14; prysm-1 to -6, -9, -11; teku-3, -4. The two Prysm minor notes carry no known-check.
- **Incomplete searches:** lighthouse-9 (query rate-limited, not re-run); Prysm lost three queries to rate limiting (on ByRange, bid seen-set and ByRange count); teku-4 (one query HTTP 403).
- **Pre-indexed, no match:** Lighthouse #10466 (PR, open); Prysm 17464, 17465 (closed, PTC timing).
- **Hive and execution-spec-tests:** no item obtained a matching public result (Lighthouse did not consult them; Prysm: out of scope; Teku: HTTP 403 from hive.ethpandaops.io, no Teku CL cases in the execution-specs Glamsterdam milestone); absence disproves nothing.

- **Disagreement:** lighthouse-2 says Prysm omits committee equivocator weight in `is_head_weak`; the Prysm item checked only `should_apply_proposer_boost` (`gloas.go#L699-L730`), so that claim is uncontradicted but unconfirmed.
- **Disagreement:** teku-3 says Lighthouse takes PTC membership from the referenced block, not the head state; the Lighthouse item did not flag this rule. The table follows teku-3.
