# Grandine: EIP-7732 (ePBS) fork choice and networking vs. Gloas spec

Item `grandine`, batch `cl-forkchoice`. This is a public-page, read-only spec-conformance review. No node was run and nothing was sent to any network beyond reading public repos and the GitHub API.

## 1. Pins

| Repo | Ref | Commit (from `api.github.com/repos/<repo>/commits/<ref>`) | Commit date |
|---|---|---|---|
| grandinetech/grandine | develop | `52cd677117b5d93bf477fe4843148070b0303444` | 2026-10-27T15:22:50Z |
| ethereum/consensus-specs | master | `8a80ffa82bd49aac0dd6db17f9e8150aa257fbc2` | 2026-10-27T16:28:12Z |
| status-im/nimbus-eth2 | stable | `0abc0dd0fc0750a8875c49089d074f7d7197b62c` | 2026-10-23T10:15:50Z |
| consensus-specs tag v1.7.0-beta.4 | tag | `aa16bb4c156184e9548a997d53efaf2a228a6304` | – |

Short names used below:
- `G:` = `https://github.com/grandinetech/grandine/blob/52cd677117b5d93bf477fe4843148070b0303444/`
- `S:` = `https://github.com/ethereum/consensus-specs/blob/8a80ffa82bd49aac0dd6db17f9e8150aa257fbc2/specs/gloas/`
- `N:` = `https://github.com/status-im/nimbus-eth2/blob/0abc0dd0fc0750a8875c49089d074f7d7197b62c/`

Each repo was read from a shallow `git clone --depth 1`, and every `HEAD` matched the pinned hash. Line numbers are at those pins.

**Spec version targeted by Grandine:** `G:scripts/download_spec_tests.sh#L4` reads `SPEC_VERSION="${SPEC_VERSION:-v1.7.0-beta.4}"`. Grandine therefore tests against v1.7.0-beta.4. Its builder-onboarding code matches beta.4, not master; see candidate C1. The beta.4 to master diff of `specs/gloas/fork.md` was checked locally against `raw.githubusercontent.com/ethereum/consensus-specs/v1.7.0-beta.4/specs/gloas/fork.md`. The only change is in `onboard_builders_from_pending_deposits`.

**EIP:** https://eips.ethereum.org/EIPS/eip-7732. Not re-fetched here; the review was done against the consensus-specs text, which is normative for client behaviour.

**Live-code rule:** every candidate below is in Gloas-only code paths. Each one is either gated on `Phase::Gloas`, reached only from Gloas-gated callers, or about Gloas-only objects. No candidate concerns logic shared with live Fulu, so nothing was withheld.

---

## 2. Candidates

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

1. **Spec (master)**: `S:fork.md#L65-L70` and `#L84-L106`.
   - "Only deposits with builder withdrawal credentials can fund builders. … A valid builder registration can use a validator's public key."
   - Code:
     ```python
     # Only builder-credential deposits can fund builders at the fork.
     if not is_builder_withdrawal_credential(deposit.withdrawal_credentials):
         pending_deposits.append(deposit); continue
     ...
     if deposit.pubkey not in builder_pubkeys:
         if not is_valid_deposit_signature(...):
             # Preserve validator top-ups when no builder can receive them.
             if deposit.pubkey in validator_pubkeys or is_pending_validator(pending_deposits, deposit.pubkey):
                 pending_deposits.append(deposit)
             continue
         add_builder_to_registry(...)
     ```
   - beta.4 instead had `# Deposits for existing validators stay in the pending queue` / `if deposit.pubkey in validator_pubkeys:` first. It also had an `is_pending_validator` check that kept the deposit queued before any builder was created.
2. **Grandine**: `G:helper_functions/src/fork.rs#L984-L1059`.
   - L992-L997: `// Deposits for existing validators are never onboarded and never have their signature checked.` … `let is_for_validator = deposits.iter().map(|deposit| state.validators.pubkeys().contains(&deposit.pubkey))`
   - L1056-L1059: `if is_for_validator[index] { pending_deposits.push(*deposit); continue; }`
   - L1029-L1033 keep a `pending_validator_pubkeys` set "that the spec's `is_pending_validator` would find". That is the beta.4 ordering.
   - Called from `upgrade_to_gloas` at `G:helper_functions/src/fork.rs#L942-L943`.
3. **When the results differ**: at the Fulu→Gloas upgrade, `state.pending_deposits` holds a validly signed deposit with builder (0x03) withdrawal credentials whose pubkey either:
   - (a) already belongs to a validator, or
   - (b) belongs to a validly signed pending deposit for a new validator that sits earlier in the queue.

   Master turns it into a builder (new builder registry entry, deposit removed from the queue). Grandine leaves it in the queue, where it is later applied as a validator top-up or validator creation. The post-upgrade state root differs.
4. **Nimbus**: `N:beacon_chain/spec/beaconstate.nim#L2308-L2385` cites `v1.7.0-alpha.12` and has `# Deposits for existing validators stay in the pending queue` / `if findValidatorIndex(state.validators…, deposit.pubkey).isSome: pending_deposits.add(deposit); continue`. It agrees with **Grandine**, i.e. it also lags master.
5. **Confidence: high** that the code follows beta.4 and not master.
   - Disproof attempted: I checked whether master's change is also present elsewhere in Grandine (none found in `fork.rs`). I also checked whether a later loop branch re-onboards validator pubkeys; it does not, because `continue` happens before the builder path.
   - Whether this becomes a live split depends on which spec release the Glamsterdam devnets and mainnet adopt. Today it is a lag against master, not against the tagged release.
6. **Severity**: consensus split (state root at the fork block) if clients disagree on the version. Otherwise cosmetic (version lag).

### C2. `is_head_weak` counts slashed equivocators as 0, while the spec adds their `effective_balance`

1. **Spec**: `S:fork-choice.md#L810-L833`.
   ```python
   for index in range(get_committee_count_per_slot(head_state, epoch)):
       committee = get_beacon_committee(head_state, head_block.slot, CommitteeIndex(index))
       head_weight += Gwei(sum(
           justified_state.validators[index].effective_balance
           for index in committee if index in store.equivocating_indices))
   ```
   There is no `slashed` or active filter on the added equivocator weight.
2. **Grandine**:
   - `G:fork_choice_store/src/store.rs#L1501-L1524` adds `self.justified_active_balances.get(index).copied().unwrap_or_default()` for each equivocating committee member.
   - `justified_active_balances` is built by `active_balances` at `G:fork_choice_store/src/store.rs#L6328-L6344`: `if predicates::is_active_validator(validator, epoch) && !validator.slashed { effective_balance } else { 0 }`.
   - `is_head_weak` feeds `should_apply_proposer_boost` (`#L1630-L1667`), which gates proposer boost in `update_head_segment_id` (`#L6210-L6216`).
3. **When the results differ**:
   - Setup: an attester slashing has put validator V into `equivocating_indices`, V is already marked `slashed` in the justified checkpoint state, and V is in a committee of the boosted block's parent's slot.
   - Effect: Grandine's head weight for that parent is lower than the spec's by V's effective balance.
   - If the parent's weight lands between the two values relative to `REORG_HEAD_WEIGHT_THRESHOLD`, Grandine treats the parent as weak and the spec does not. If additionally an early (PTC-timely) equivocating sibling of the parent exists, Grandine withholds proposer boost and the spec applies it, so the heads can differ.
   - The same happens for a committee member that is not yet active in the justified state: the spec still uses `effective_balance`, Grandine uses 0.
4. **Nimbus**: `N:beacon_chain/fork_choice/fork_choice_epbs.nim#L131-L158` adds `balances[val].effective_balance` from `self.checkpoints.justified.balances` for validators whose vote slot is `FAR_FUTURE_SLOT`. I did not verify whether Nimbus's justified `balances` zeroes slashed validators, so this is **undetermined**.
5. **Confidence: medium.**
   - The code difference is clear.
   - Disproof attempted: I checked whether Grandine adds the equivocator weight from another source. It does not; the only source is `justified_active_balances`.
   - The trigger needs a slashing included on chain before the current justified checkpoint while the slashed validator is still in committees. Slashed validators stay in committees until their exit epoch, so this is reachable.
   - The impact needs a threshold edge plus an early proposer equivocation, which is narrow.
6. **Severity**: fork-choice divergence (proposer-boost application). Narrow trigger.

### C3. `payload_attestation_message` gossip: no "first valid message from this validator" IGNORE, and later messages overwrite PTC votes

1. **Spec**: `S:p2p-interface.md#L908-L911`.
   ```python
   # [IGNORE] This is the first valid payload attestation from this validator index
   payload_attestation_key = (data.slot, validator_index)
   if payload_attestation_key in seen.payload_attestation_validators:
       raise GossipIgnore("already seen payload attestation from this validator")
   ```
   Plus `#L947-L948` (`seen.payload_attestation_validators.add(...)`).
2. **Grandine**:
   - `G:fork_choice_store/src/store.rs#L4364-L4376` has the check commented out: `// TODO: need rough consensus from implementors whether override vote is allowed, or preventing it as the commented code.` … `// [IGNORE] The payload_attestation_message is the first valid message received from the validator …` / `// if let Some(votes) = self.payload_vote.get(&block_root) … { return Ok(PayloadAttestationAction::Ignore(...)); }`
   - It then returns `Accept` (`#L4378-L4381`).
   - `apply_payload_attestation_batch` (`#L4787-L4836`) does `timeliness_votes.set(pos, data.payload_present)` and `availability_votes.set(pos, data.blob_data_available)`, so the newest message wins.
   - A repo-wide search for a seen-set of payload attesters (`seen_payload_attest|observe.*payload_attest|PayloadAttestationValidators`) found nothing.
3. **When the results differ**:
   - Setup: a PTC member publishes two different signed `PayloadAttestationMessage`s for the same slot and block, for example first `payload_present=false` and then `true`. Gossipsub message-id dedup does not catch them because the contents differ.
   - Spec node: IGNOREs the second message, so it is not forwarded and its PTC vote stays at the first value.
   - Grandine: ACCEPTs and forwards the second message, and overwrites the vote.
   - Consequences: `payload_timeliness` and `payload_data_availability` (`#L1188-L1243`) feed `should_extend_payload`, the previous-slot EMPTY/FULL tiebreak and `should_build_on_full`. So the head's payload status and the proposer's choice to build on FULL or EMPTY can differ between Grandine and spec-following nodes.
   - A coordinated PTC minority flipping votes near the threshold of 256 can split views.
4. **Nimbus**: `N:beacon_chain/gossip_processing/gossip_validation.nim#L2005-L2035` returns `errIgnore("PayloadAttestationMessage: duplicate message from validator")` at about L2024. It agrees with the **spec**.
5. **Confidence: high** for the gossip-verdict difference, since it is an explicit TODO with the code commented out. **Medium** for an actual head divergence, which needs equivocating PTC members.
   - Disproof attempted: I looked for dedup in the p2p crate and the fork_choice_control tasks and found none. Gossipsub only dedups byte-identical messages.
6. **Severity**:
   - Gossip verdict: ACCEPT vs IGNORE.
   - Fork-choice divergence in the payload-status choice (FULL/EMPTY) for the previous-slot block.

### C4. Block-included attestations with `index == 1` for a payload that is not verified are delayed and applied later; the spec rejects them for fork choice

1. **Spec**: `S:fork-choice.md#L942-L944`.
   ```python
   # If attesting for a full node, the payload must be known
   if attestation.data.index == 1:
       assert is_payload_verified(store, attestation.data.beacon_block_root)
   ```
   This runs in `validate_on_attestation(..., is_from_block=True)`. A failed assert means `on_attestation` makes no store update. For gossip only, `S:p2p-interface.md#L448-L453` allows "MAY queue".
2. **Grandine**: `G:fork_choice_store/src/store.rs#L3206-L3218`.
   ```rust
   if index == 1 && !self.is_payload_verified(beacon_block_root) {
       if is_from_block {
           // TODO(gloas): the block should be delayed in this place
           // bail!(Error::AttestationDataInvalidPayloadStatus { ... });
       }
       return Ok(PartialAttestationAction::DelayUntilPayload(beacon_block_root));
   }
   ```
3. **When the results differ**:
   - Setup: an imported block carries an attestation with `index = 1` for block R, and R's envelope has not passed `on_execution_payload_envelope` locally. For example, the envelope arrives late or is missing.
   - Spec: that attestation never updates `latest_messages`.
   - Grandine: queues it and applies it once the envelope arrives. The vote then counts toward R's FULL node, and possibly replaces a later spec-kept latest message, giving different FULL/EMPTY weights.
4. **Nimbus**: `N:beacon_chain/fork_choice/fork_choice.nim#L498-L510` per the subagent report. Block attestations skip the `validate_on_attestation` index checks entirely. That matches neither the spec nor Grandine.
5. **Confidence: medium.** The TODO shows the authors know.
   - Disproof attempted: I checked whether block import itself is delayed until the payload. It is only when the block builds on a FULL parent (`#L2188-L2196`), not for attestations to other blocks.
   - Divergence only arises while the payload is missing locally, so it is often transient.
6. **Severity**: fork-choice divergence (weights). Usually transient.

### C5. Block payload attestations reach the fork-choice store after the block's head computation (asynchronous task)

1. **Spec**: `S:fork-choice.md#L1086-L1098`.
   - Order: `store.blocks[block_root] = block` → `payload_timeliness_vote[...] = [None]*PTC_SIZE` → `notify_ptc_messages(store, state, block.body.payload_attestations)` → `record_block_timeliness` → `update_proposer_boost_root`.
   - The votes are therefore in the store atomically with the block, before any subsequent `get_head`.
2. **Grandine**:
   - `G:fork_choice_store/src/store.rs#L4548-L4685` (`apply_block`) records timeliness, sets boost, inserts the block and calls `update_head_segment_id()` with no PTC votes from the block.
   - The block's `payload_attestations` are validated in a separately spawned `BlockPayloadAttestationsTask` (`G:fork_choice_control/src/mutator.rs#L3630`, `#L5261-L5284`; `G:fork_choice_control/src/tasks.rs#L430-L463`). They are applied later through `MutatorMessage::BlockPayloadAttestations` → `apply_payload_attestation_batch`.
3. **When the results differ**: between import of block B, whose PTC aggregate shows the parent's payload timely or late, and completion of the task:
   - Grandine's head and payload-status view (`should_extend_payload`, `should_build_on_full`, the EMPTY/FULL tiebreak for B's parent) is computed without those votes.
   - A proposer or attester acting in that window (for example, a block at the slot start) can choose differently than the spec would.
   - After the task completes, the views converge.
4. **Nimbus**: per the subagent report, `N:beacon_chain/fork_choice/fork_choice.nim#L519-L529` records the block's PTC tallies before `record_block_timeliness` (L535) and before proto-array insertion (L560-L571). It agrees with the **spec**. The brief notes that Lighthouse also records them early.
5. **Confidence: low to medium.** The ordering difference is certain; the observable impact needs timing. Disproof attempted: I checked whether `apply_block` itself reads `body.payload_attestations`. It does not.
6. **Severity**: transient fork-choice divergence.

### C6. Envelopes are not replayed into fork choice after restart — **known**

1. **Spec**: `is_payload_verified` is `root in store.payloads`, and `on_block` asserts it for FULL parents (`S:fork-choice.md#L314-L320`, `#L1060-L1061`). The spec does not model restarts.
2. **Grandine**:
   - `G:fork_choice_control/src/mutator.rs#L448-L500` (`process_unfinalized_blocks`) replays persisted blocks only. No stored envelope is fed to `apply_execution_payload_envelope` (`G:fork_choice_store/src/store.rs#L4887-L4922`).
   - Blocks on FULL parents hit `DelayUntilPayload` (`mutator.rs#L1088-L1116`) and request the envelope from the network (`P2pMessage::PayloadEnvelopeNeeded`), even though it is on disk (`storage.rs#L689-L770`).
3. **When the results differ**: after a restart, until envelopes are refetched, unfinalized FULL-parent blocks are not imported and FULL nodes are missing. The head can be an EMPTY variant or an older block.
4. **Nimbus**: not checked.
5. **Confidence: high** (matches the open issue).
6. **Severity**: liveness, plus a fork-choice divergence after restart.

**Known**: https://github.com/grandinetech/grandine/issues/886 ("Load payload envelopes from storage during restart", open). Fix PR https://github.com/grandinetech/grandine/pull/887 ("Replay persisted execution payload envelopes at restart", open, not merged).

### C7. Payload/attestation deadlines at the fork boundary and the fixed 16-tick split — **known** (not re-derived)

`G:fork_choice_store/src/store.rs#L6413-L6423` (`is_before_due_bps_deadline`) and `G:clock/src/lib.rs#L358-L364` (`ticks_per_slot`: 16 ticks post-Gloas, 12 pre-Gloas). With mainnet 2500 and 7500 BPS and 16 ticks per slot, `(tick+1)*10000 <= due_bps*16` equals the spec's `time_into_slot_ms < threshold` at tick resolution (checked arithmetically).

Known:
- https://github.com/grandinetech/grandine/pull/989 ("Fix clock deadlines at the Gloas fork boundary", open)
- https://github.com/grandinetech/grandine/issues/912 ("Validator tick schedule is a fixed 16-way slot split, *_DUE_BPS config values are not used", open). With non-default BPS configs, the deadlines would not track the config.

---

## 3. Rules checked and found consistent with the spec

| Rule (spec) | Grandine at pin | Verdict / note |
|---|---|---|
| `payload_timeliness` / `payload_data_availability`: `not timely` if not verified; strict `> 256`; `None` votes excluded (`S:fork-choice.md#L326-L362`) | `G:fork_choice_store/src/store.rs#L1188-L1243` | OK. NAY is counted as `voted − present` over a separate `payload_vote` bitvector, which equals the spec's non-`None` filter. The strict `>` is used. |
| `PAYLOAD_TIMELY_THRESHOLD` strictly greater | `#L1212`, `#L1242` (`vote_count > PayloadTimelyThreshold`) | OK |
| `is_previous_slot_payload_decision` / `get_weight` returns 0 for previous-slot EMPTY/FULL (`#L461-L465`, `#L569-L570`) | `#L1697-L1709`; `score` `#L1749-L1757`; `ignore_child` `#L5996-L6004` | OK. Grandine skips weight entirely for previous-slot variants and chooses by `should_extend_payload`, which is equivalent to both weights being 0 and the tiebreaker deciding. |
| `get_payload_status_tiebreaker` (EMPTY→1, FULL→2/0) (`#L515-L526`) | `ignore_child` `#L5996-L6004`; `chain_link_payload_status` `#L1007-L1028` | OK: FULL is chosen iff `should_extend_payload` |
| Tie between EMPTY and FULL weights for an older block → FULL wins, since `PAYLOAD_STATUS_FULL=1 > EMPTY=0` (`#L103-L104`, `#L662-L669`) | `ignore_child` `#L6025-L6041` (`Empty if parent_payload_verified && parent_empty <= parent_full` → ignore the EMPTY child); `chain_link_payload_status` `full >= empty` → FULL | OK. I first suspected a tie fell through to child weight; disproved by `<=`. |
| `should_extend_payload` (`#L497-L510`) | `#L1084-L1117` | OK. Extra `is_payload_invalid` → false, but an EL-invalid payload never enters spec `store.payloads`, so there is no observable difference. |
| `should_build_on_full` (`#L475-L486`) | `#L1033-L1054` | OK |
| `is_bid_compatible_with_head` (`S:p2p-interface.md#L401-L423`) | `#L1056-L1082` | OK |
| `should_apply_proposer_boost`, including the early-equivocation scan at `PTC_TIMELINESS_INDEX` (`#L531-L561`) | `#L1630-L1667`, `exhibits_equivocation_on_blocks` `#L717-L737` | OK. See C2 for the `is_head_weak` input. |
| proposer boost applied only to ancestors of the boosted block (`#L582-L589`) | `payload_weights`/`boosted_payload_presence` `#L6097-L6165` | OK: the boost goes to the variant the boosted block builds on |
| `record_block_timeliness`: two thresholds, `is_current_slot` (`#L989-L1001`) | `apply_block` `#L4561-L4578` | OK |
| `update_proposer_boost_root` with the same-dependent-root check (`#L1019-L1031`) | `#L4580-L4598` | OK. Grandine uses the block's parent root for the dependent root. That gives the same ancestor because the boosted block is in the current slot, after the dependent slot. |
| `on_block`: a FULL parent must be verified (`#L1060-L1061`) | `validate_block_with_custom_state_transition` `#L2188-L2196` → `DelayUntilPayload` | OK. Spec assert = not imported now; Grandine queues and retries. |
| `validate_on_attestation` index ∈ {0,1}; same-slot → 0; index 1 → verified (`#L937-L944`) | `#L3188-L3218` | OK for gossip. For from-block, see C4. |
| `update_latest_messages` `payload_present = index == 1` (`#L970`) | not separately audited | Not verified |
| `on_payload_attestation_message`: return if `data.slot != state.slot`; PTC membership; multiple positions (`#L1139-L1187`) | `validate_payload_attestation` `#L4259-L4361`; `attesting_indices_positions` `#L4384-L4452` | OK: block-slot mismatch → Ignore; all positions are set |
| gossip `execution_payload_bid`: unseen proposer preferences → **IGNORE** (`S:p2p-interface.md#L1017-L1021`) | `#L2465-L2486` returns `ExecutionPayloadBidAction::Ignore(...)` | OK (IGNORE, not REJECT). Fee-recipient mismatch → IGNORE (`#L2489-L2494`), OK. |
| gossip `execution_payload_bid`: parent hash must be a known (delivered) payload (`#L1029-L1031`) | `#L2497-L2504`; the `execution_payload_locations` key is inserted only on envelope delivery for post-Gloas blocks (`#L4905-L4907`, `#L5132-L5139`) | OK |
| gossip `beacon_attestation`: `index > 1` → REJECT; same-slot `index != 0` → REJECT; index 1 not verified → IGNORE/queue; invalid payload → REJECT (`S:p2p-interface.md#L441-L462`, `#L1210-L1213`) | `#L3188-L3226` | OK |
| gossip `payload_attestation_message`: pre-Gloas REJECT; not current slot IGNORE; unknown block IGNORE/queue; block slot mismatch IGNORE; not in PTC REJECT; bad signature REJECT (`#L904-L945`) | `#L4259-L4361` | OK except for the duplicate rule (C3). Grandine uses the attested block's state rather than the head state for `get_ptc`; this is the same within an epoch. |
| gossip `execution_payload`: seen `(root, builder)` IGNORE; block unseen IGNORE; block invalid REJECT; slot < finalized IGNORE; slot/builder/hash/requests-root REJECT; signature REJECT (`#L830-L883`) | `#L4004-L4038`, `#L4041-L4258` | OK. Extra: IGNORE if `slot_number > current + max_gossip_future_slots`; no spec counterpart, but a known block cannot be that far ahead. |
| `ExecutionPayloadEnvelopesByRange`: canonical chain only, skip EMPTY, head FULL only per fork choice (`S:p2p-interface.md#L1422-L1431`) | `G:fork_choice_control/src/queries.rs#L1814-L1846`, `#L735-L749` | OK. Nimbus differs (serves envelopes of EMPTY-status blocks, per the subagent report: `N:beacon_chain/sync/sync_protocol.nim#L431-L485`). |
| ByRange with `count = 0` | `G:p2p/src/network.rs#L1631-L1641` → empty range → empty response | No explicit spec rule found; an empty response is acceptable. Nimbus raises `InvalidInputsError` instead. |
| `ExecutionPayloadEnvelopesByRoot` (`#L1466-L1493`) | `queries.rs#L751-L784` (`envelopes_by_roots`, `stored_envelope_by_root`) | OK: missing ones are skipped |
| `get_proposer_head` / `is_proposer_equivocation` (`S:fork-choice.md#L857-L907`) | not implemented. Searched for `proposer_head|head_late|parent_strong|proposer_equivocation`; none found. | Not a divergence: proposer re-orgs are optional (`S:validator.md#L195`, "if proposer re-orgs are implemented"). Lighthouse 9593 was not read. Nimbus also does not implement it. |

---

## 4. Known-check (grandinetech/grandine issues and PRs, open and closed)

The GitHub search API (`api.github.com/search/issues?q=repo:grandinetech/grandine …`) was used. The rate limit was hit after 11 queries. Queries `reorg`, `is:pr payload attestation seen`, `builder onboarding`, `v1.7.0-beta.4` and `attestations in block payload` were **not run**.

| Query | Hits (relevant) |
|---|---|
| `7732` | #282 Implement EIP-7732 (open) |
| `gloas` (118) | #989 Fix clock deadlines at the Gloas fork boundary (open PR); #912 fixed 16-way tick split (open); #886 Load payload envelopes from storage during restart (open) |
| `epbs` | #502 Add Forkchoice changes for ePBS (closed PR); #579 ProposerPreferences gossip (open PR); #795 circuit-breaker (closed) |
| `is_head_weak`, `equivocating`, `slashed equivocating`, `payload_attestation duplicate` | 0 hits |
| `payload attestation` (42) | #673 "Delay attestations with payload_present=1 until envelope received" (open PR, related to C4 but about the gossip path); no duplicate/first-message issue |
| `proposer preferences` | #972, #859 (closed PRs); nothing on the IGNORE verdict |
| `envelope restart` | #886 (open), #887 (open PR) |
| `proposer boost` | #840 proposer boost dependent_root (closed); #561 (closed) |

Per candidate:
- **C1**: not found. Queries: `7732`, `gloas`, `epbs`; `builder onboarding` was not run because of the rate limit.
- **C2**: not found (`is_head_weak`, `equivocating`, `slashed equivocating`).
- **C3**: not found (`payload attestation`, `payload_attestation duplicate`). The in-code TODO shows the team is aware.
- **C4**: partially known. The in-code TODO at `store.rs#L3208` and PR #673 (open) address delaying gossip attestations. No issue covers the from-block discard rule.
- **C5**: not found (`payload attestation`, `gloas`).
- **C6**: **known**, #886 (open issue) and #887 (open PR).
- **C7**: **known**, #989 (open PR) and #912 (open issue).

Previously indexed and not re-opened here: 989 (open), 904, 840, 832, 742 (closed), 864 (merged). Of these, only #840 and #989 showed up in my searches. #904, #832, #742 and #864 were not re-read.

---

## 5. URLs read

- https://api.github.com/repos/grandinetech/grandine/commits/develop
- https://api.github.com/repos/ethereum/consensus-specs/commits/master
- https://api.github.com/repos/status-im/nimbus-eth2/commits/stable
- https://api.github.com/repos/ethereum/consensus-specs/git/ref/tags/v1.7.0-beta.4
- https://github.com/grandinetech/grandine (shallow clone at `52cd677`): `fork_choice_store/src/store.rs`, `fork_choice_control/src/{mutator,queries,tasks,storage}.rs`, `p2p/src/network.rs`, `helper_functions/src/fork.rs`, `clock/src/lib.rs`, `types/src/gloas/container_impls.rs`, `scripts/download_spec_tests.sh`
- https://github.com/ethereum/consensus-specs (shallow clone at `8a80ffa`): `specs/gloas/fork-choice.md`, `p2p-interface.md`, `validator.md`, `fork.md`; `specs/phase0/fork-choice.md` (`is_head_weak`)
- https://raw.githubusercontent.com/ethereum/consensus-specs/v1.7.0-beta.4/specs/gloas/fork.md
- https://github.com/status-im/nimbus-eth2 (shallow clone at `0abc0dd`): `beacon_chain/fork_choice/fork_choice_epbs.nim`, `fork_choice.nim`, `proto_array.nim`, `gossip_processing/gossip_validation.nim`, `spec/beaconstate.nim`, `sync/sync_protocol.nim`. Some Nimbus line references come from a read-only sub-agent survey; the ones cited in C1, C2 and C3 were re-checked directly.
- https://api.github.com/search/issues (queries listed in §4)
- Not fetched: https://eips.ethereum.org/EIPS/eip-7732; hive and execution-spec-tests dashboards (out of scope for this item); Lighthouse PR 9593.
