# Nimbus: EIP-7732 (ePBS) fork choice and networking vs. consensus-specs `gloas`

Scope: I read code and specs only, from shallow clones and api.github.com. Nothing was run against any network or node. Gloas is not live on mainnet. None of the candidates below sit in logic shared with Fulu: every cited path is gated on Gloas (`when consensusFork >= ConsensusFork.Gloas`, or `GLOAS_FORK_EPOCH` checks).

## 1. Pins (fetched 2026-10-27 via `api.github.com/repos/<repo>/commits/<branch>`)

| Repo / branch | Commit | Committer date |
|---|---|---|
| status-im/nimbus-eth2 `stable` | `0abc0dd0fc0750a8875c49089d074f7d7197b62c` ("version v26.10.2") | 2026-10-23 |
| status-im/nimbus-eth2 `unstable` | `5babd698516450d8b8e60c2af610a5853baaa9a9` | 2026-10-27 |
| ethereum/consensus-specs `master` | `8a80ffa82bd49aac0dd6db17f9e8150aa257fbc2` | 2026-10-27 |
| consensus-specs tag `v1.7.0-beta.4` | `aa16bb4c156184e9548a997d53efaf2a228a6304` | — |
| OffchainLabs/prysm `develop` | `eb681c1d13b57c2d0c5da27a63bb41ac9d071e1a` | 2026-10-27 |

Abbreviations: `N@` = `https://github.com/status-im/nimbus-eth2/blob/0abc0dd0fc0750a8875c49089d074f7d7197b62c/`, `NU@` = the same at `5babd698…`, `S@` = `https://github.com/ethereum/consensus-specs/blob/8a80ffa82bd49aac0dd6db17f9e8150aa257fbc2/`, `P@` = `https://github.com/OffchainLabs/prysm/blob/eb681c1d13b57c2d0c5da27a63bb41ac9d071e1a/`.

**Which spec Nimbus targets.** Nimbus targets `1.7.0-beta.4` on both branches: `N@beacon_chain/spec/datatypes/base.nim#L83` reads `const SPEC_VERSION* = "1.7.0-beta.4"`, and unstable has the same line. Per-function comments still cite older tags: `fork_choice_epbs.nim` cites `v1.7.0-alpha.12` for most handlers and `v1.7.0-beta.4` only for `is_head_weak`.

**beta.4 vs. master.** The compare `v1.7.0-beta.4...master` is 18 commits ahead. Under `specs/gloas/` it touches only `fork.md` (PR 5736, "Separate builder and validator deposits at the Gloas fork") and two lines of `beacon-chain.md` (PR 5758, an EIP-number docstring fix). `fork-choice.md`, `p2p-interface.md` and `validator.md` are identical between beta.4 and master. One correction to the brief: master also bumps the version to v1.7.0-beta.5 (PR 5728) and changes `phase0/fork-choice.md` (PR 5756, "Round slot component durations to the nearest millisecond"). I did not check how that rounding affects Nimbus's Gloas deadlines.

**Stable vs. unstable, in the reviewed area.**
- `fork_choice_epbs.nim::on_payload_attestation_message`: stable gets the PTC from `dag.headState`; unstable gets it from the attested block's `EpochRef` (`NU@beacon_chain/fork_choice/fork_choice_epbs.nim#L228-L256`).
- `validatePayloadAttestationMessage`: unstable also uses `epochRef.get_ptc` (`NU@beacon_chain/gossip_processing/gossip_validation.nim#L2055`).
- `proto_array.nim`: only debug-export fields were added (`pendingWeight`, `isFull`, `parentIsFull`).
- The bid builder checks still use `dag.headState` on unstable (`NU@…/gossip_validation.nim#L1881`).

These changes come from PR 9341 "Cache PTC in EpochRef", merged into unstable on 2026-10-24.

## 2. Candidates

### C1: In-block payload attestations update only the set bits, not every PTC seat the attester holds

1. **Spec.** `S@specs/gloas/fork-choice.md#L265-L289` (`notify_ptc_messages`) builds `get_indexed_payload_attestation(...)` and then, `for idx in indexed_payload_attestation.attesting_indices: on_payload_attestation_message(store, PayloadAttestationMessage(validator_index=idx, ...), is_from_block=True)`. Then `#L1136-L1188`: "Get all positions of the attester in the PTC … `for ptc_index, validator_index in enumerate(ptc): if validator_index == ptc_message.validator_index: ptc_indices.append(ptc_index)` … `for ptc_index in ptc_indices: payload_timeliness_vote[ptc_index] = data.payload_present`". `S@specs/gloas/beacon-chain.md#L1266-L1269`: "Get the payload timeliness committee, **with possible duplicates**". `#L1419-L1424`: the attesting indices are the set bits only, sorted and not de-duplicated.
2. **Nimbus.** `N@beacon_chain/fork_choice/fork_choice.nim#L519-L528`:
   ```nim
   for pa in blck.body.payload_attestations:
     if pa.data.slot + 1 < slot:
       continue
     let tally = self.backend.mgetPtcTally(
       pa.data.beacon_block_root, pa.data.slot)
     for i in 0 ..< pa.aggregation_bits.len:
       if pa.aggregation_bits[i]:
         tally.voted[i] = true
         tally.present[i] = pa.data.payload_present
         tally.available[i] = pa.data.blob_data_available
   ```
   Only the bit positions that are set get written. The wire path (`fork_choice_epbs.nim#L237-L244`) does loop over every seat. Unstable has the same in-block code.
3. **When results differ.** Some validator holds two or more seats in the PTC for slot N. A block at slot N+1 includes a payload attestation for the slot-N block that sets only some of that validator's seats. A proposer can build this on purpose: the aggregate signature over the de-duplicated set stays valid. The spec counts every seat of that validator, Nimbus counts only the set bits. If the node did not also receive that validator's message on the wire, `payload_timeliness` / `payload_data_availability` counts differ, which changes `should_extend_payload` → `get_payload_status_tiebreaker` (FULL vs EMPTY for the previous-slot block) and `should_build_on_full` near the 256-vote threshold. A second, harmless difference: Nimbus does not check `data.slot == slot of beacon_block_root`, but tallies only matter for previous-slot blocks.
4. **Prysm agrees with the spec.** `P@beacon-chain/blockchain/process_block.go#L625-L659` collects the voters, then writes every seat: "The spec writes a voter's vote to every PTC seat it holds, not only the seats whose bit is set."
5. **Confidence: medium.** Disproof tried: I looked for a de-duplication step before fork choice and found none in `process_block`. The divergence needs a validator with duplicate seats and an aggregate covering only some of them. Honest aggregators probably set all seats (I did not check `payload_attestation_pool.nim` aggregation in detail). It only flips the result near the threshold.
6. **Severity:** fork-choice divergence (FULL/EMPTY choice for the previous-slot block, plus what the proposer builds on).

### C2: (stable only) The PTC used for gossip REJECT and for fork-choice votes comes from the head state, not the attested block's state

1. **Spec.** `S@specs/gloas/fork-choice.md#L1139-L1164`: `state = store.block_states[data.beacon_block_root]` … `ptc = get_ptc(state, data.slot)` … `assert len(ptc_indices) > 0`. The p2p `payload_attestation_message` rule (`S@specs/gloas/p2p-interface.md#L886-L950`) also checks membership in `get_ptc(state, data.slot)` for the attested block's state.
2. **Nimbus stable.**
   - `N@beacon_chain/gossip_processing/gossip_validation.nim#L2042-L2052`: `withState(dag.headState): … for idx in get_ptc(dag.cfg, forkyState.data, data.slot): … if not present: return dag.checkedReject("PayloadAttestationMessage: validator not in ptc")`.
   - `N@beacon_chain/fork_choice/fork_choice_epbs.nim#L234-L248`: `withState(dag.headState): … for ptc_index, vidx in enumerate(get_ptc(dag.cfg, forkyState.data, slot)):`.
3. **When results differ.** The attested block sits on a branch whose PTC for that slot differs from the head branch. This happens during non-finality or deep forks, where the branches split before the epoch boundary that fixed `ptc_window`. A valid message is then REJECTed and its vote dropped, or a non-member's vote is counted.
4. **Prysm agrees with the spec.** It looks up a state for the attested block's branch: `P@beacon-chain/blockchain/receive_payload_attestation_message.go#L52-L67` (`PtcLookupState` compares dependent roots), used at `P@beacon-chain/sync/validate_payload_attestation.go#L98-L113`.
5. **Confidence: high** that stable differs. **Known:** fixed on unstable by PR 9341 (merged 2026-10-24, base `unstable`), not yet on stable. That PR's description gives the same reason ("may have different PTC shufflings … branching non-finality").
6. **Severity:** a valid message rejected on gossip (peer-scoring) plus fork-choice divergence in PTC tallies, only on forked branches.

### C3: Execution-payload-bid builder checks run on the head state instead of the parent state advanced to `bid.slot`

1. **Spec.** `S@specs/gloas/p2p-interface.md#L951-L1085`: `state = store.block_states[bid.parent_block_root]` … `state = state.copy(); process_slots(state, bid.slot)` … "[REJECT] The builder index is valid", "[REJECT] The builder is a payload builder", "[REJECT] The builder is active", "[IGNORE] The builder can cover the bid", and the bid signature is checked against `state`. `is_active_builder` (`S@specs/gloas/beacon-chain.md#L1054-L1064`) depends on `builder.deposit_epoch < state.finalized_checkpoint.epoch`.
2. **Nimbus.** `N@beacon_chain/gossip_processing/gossip_validation.nim#L1881-L1907`:
   ```nim
   withState(dag.headState):
     when consensusFork >= ConsensusFork.Gloas:
       # [REJECT] The builder index is valid
       if bid.builder_index >= forkyState.data.builders.lenu64:
         return dag.checkedReject(...)
       # [REJECT] The builder is active
       if not is_active_builder(forkyState.data, bid.builder_index):
         return dag.checkedReject("ExecutionPayloadBid: builder is not active")
   ```
   `can_builder_cover_bid(forkyState.data, …)` and the builder pubkey come from the same head state. Unstable has the same code (`NU@…#L1881`).
3. **When results differ.**
   - (a) The bid is for the first slot of a new epoch and the head state has not been advanced across the boundary. The spec's `process_slots` runs epoch processing; if that moves finality (`finalized_checkpoint.epoch`), a newly registered builder becomes active. The spec accepts the bid and Nimbus REJECTs it. The reverse holds for builder exits and pending payments that epoch processing settles, which changes `can_builder_cover_bid`.
   - (b) The bid builds on the head's parent, which `is_bid_compatible_with_head` allows. The spec uses the parent's state; Nimbus uses the head state, which already includes the head block's effects on builders and balances.
4. **Prysm agrees with the spec.** `P@beacon-chain/sync/validate_execution_payload_bid.go#L81` calls `st := transition.NextSlotStateReadOnly(parentBlockRoot[:], bid.Slot())` and then `VerifyBuilderActive(st)` at `#L95-L97`.
5. **Confidence: medium.** Disproof tried: I checked whether Nimbus's head state is advanced to the wall slot before gossip validation and found no such step in this function. Whether the epoch-boundary case happens depends on when a builder's deposit epoch passes finality. Not found in Nimbus issues: queries "builder active bid", "headState bid".
6. **Severity:** wrong gossip verdict. A valid bid is rejected and the peer penalised, or an unusable bid is accepted. This is not a block-validity split: block processing uses the real state.

### C4: Bid gossip checks run in a different order, so some duplicate or low-value bids get REJECT where the spec says IGNORE

1. **Spec.** `S@specs/gloas/p2p-interface.md#L968-L978`. The first two checks are "[IGNORE] This is the first bid for this slot, parent, and builder" and "[IGNORE] This is the highest value bid seen …". Only after them come "[REJECT] The bid's execution payment is zero", "[REJECT] … block hash … equal to its parent block hash", "[REJECT] … blob KZG commitment count".
2. **Nimbus.** `N@beacon_chain/gossip_processing/gossip_validation.nim#L1845-L1858` runs the REJECT checks (`execution_payment != 0`, `block_hash == parent_block_hash`, too many commitments) first. The first-seen and highest-bid IGNOREs come later, at `#L1904-L1915`.
3. **When results differ.** A bid repeats an already-seen (slot, parent hash, parent root, builder) key, or is not above the best value, *and* has a nonzero `execution_payment`, `block_hash == parent_block_hash`, or too many commitments. The spec returns IGNORE and Nimbus returns REJECT.
4. **Prysm is partly like Nimbus.** It checks `block_hash != parent_block_hash` (REJECT) before the first-seen IGNORE: `P@beacon-chain/sync/validate_execution_payload_bid.go#L59-L75`.
5. **Confidence: high** that the order differs. The observable effect is limited to the verdict and peer scoring.
6. **Severity:** cosmetic (gossip verdict / peer score only).

### C5: In stable `is_head_weak`, the head weight is a proto-array node weight that can include proposer boost and leaves out the FULL variant

1. **Spec.** `S@specs/gloas/fork-choice.md#L807-L833`: `head_node = ForkChoiceNode(root=head_root, payload_status=PAYLOAD_STATUS_PENDING); head_weight = get_attestation_score(store, head_node, justified_state)`, plus the effective balance of committee members in `store.equivocating_indices`. `get_attestation_score` counts no proposer boost and covers both payload variants.
2. **Nimbus.** `N@beacon_chain/fork_choice/fork_choice_epbs.nim#L138-L158`:
   ```nim
   let proto_node = self.backend.proto_array.node(head_root).valueOr:
     return true
   var head_weight = proto_node.weight.Gwei
   if head_root == self.backend.proto_array.previousProposerBoostRoot:
     head_weight -= self.backend.proto_array.previousProposerBoostScore
   ```
   `proto_array.node(root)` returns the `indices` (EMPTY/pending) node, not the FULL node (`N@beacon_chain/fork_choice/proto_array.nim#L80-L81,L119-L120`). Its `weight` includes the subtree, and that subtree includes boost carried up from a boosted child (`proto_array.nim#L282-L319`). The weights are also from the previous `find_head`, because `get_head` calls `should_apply_proposer_boost` before `find_head` (`fork_choice.nim#L645-L656`).
3. **When results differ.** In `should_apply_proposer_boost`, Nimbus calls `is_head_weak` on the parent of the boosted block (`fork_choice_epbs.nim#L179-L180`). Two cases:
   - The boosted block builds on the parent's EMPTY variant and was boosted in the previous `find_head`. The parent's node weight then includes the boost score (`PROPOSER_SCORE_BOOST`, 40% of a committee, above the 20% `REORG_HEAD_WEIGHT_THRESHOLD`), so Nimbus treats the parent as strong and applies the boost. The spec may find the parent weak and, if the parent's proposer equivocated in a timely way, withhold the boost.
   - Weight on the parent's FULL variant is left out, so Nimbus can call the parent weak when the spec does not.

   Either way, the head can differ during the slot.
4. **Prysm.** Not checked in the time available.
5. **Confidence: low to medium.** Disproof tried: the boost subtraction only covers `head_root == previousProposerBoostRoot`, which is the boosted block itself, never its parent. The case needs a weak parent plus a timely proposer equivocation, which is rare. I did not build a test.
6. **Severity:** fork-choice divergence (proposer-boost application in equivocation scenarios).

### C6: A `beacon_attestation` with index 1 is accepted when the envelope is only quarantined (not verified)

1. **Spec.** `S@specs/gloas/p2p-interface.md#L426-L463` (`verify_attestation_payload_status`): "[IGNORE] The corresponding execution payload envelope has been seen and verified … `if not is_payload_verified(store, block_root): raise GossipIgnore`". `is_payload_verified` means `root in store.payloads`, which is set only by `on_execution_payload_envelope`.
2. **Nimbus.** `N@beacon_chain/gossip_processing/gossip_validation.nim#L1012-L1016`:
   ```nim
   if not pool.dag.db.containsExecutionPayloadEnvelope(block_root) and
       not envelopeQuarantine[].hasOrphan(block_root):
     return errIgnore(
       "SingleAttestation: execution payload envelope has not been seen")
   ```
   The aggregate path has the same check (`#L1216-L1220`).
3. **When results differ.** The envelope for the attested block sits in Nimbus's envelope quarantine as an orphan (received but not yet verified or imported). The spec IGNOREs an index-1 attestation; Nimbus goes on and can ACCEPT and forward it. Fork-choice effect is small: `validate_on_attestation` runs again when the vote is applied (`fork_choice.nim#L400-L405`).
4. **Prysm.** Not checked in the time available.
5. **Confidence: low.** I did not trace whether `hasOrphan` entries are verified envelopes waiting on columns, or unverified ones.
6. **Severity:** cosmetic (gossip verdict / propagation).

### C7: Spec-version lag
Nimbus declares `SPEC_VERSION = "1.7.0-beta.4"` (`N@beacon_chain/spec/datatypes/base.nim#L83`, same on unstable). Master (beta.5-dev) adds the Gloas `fork.md` builder-onboarding change (PR 5736), which I did not review against Nimbus's fork upgrade because it is outside this item's files. For the three files in scope, beta.4 and master are identical, so the lag does not affect fork choice or p2p. Severity if PR 5736 is not implemented: state-root split at the fork. Confidence: low; not verified.

**Not a candidate: `get_proposer_head` / Lighthouse 9593.** Neither branch implements the proposer re-org path: grep for `is_head_late`, `is_parent_strong`, `get_proposer_head`, `proposer_equivocation` finds nothing under `beacon_chain/`. That changes only local proposal behaviour, not validity. `is_head_weak` is used only by `should_apply_proposer_boost`.

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

| Rule | Nimbus (stable) | Note |
|---|---|---|
| `PAYLOAD_TIMELY_THRESHOLD` / DA threshold uses strict `>` | `N@beacon_chain/fork_choice/fork_choice_epbs.nim#L47-L51,L65-L69` | `count > PAYLOAD_TIMELY_THRESHOLD`; an unverified payload returns `not timely` (#L44-L45) |
| `should_build_on_full` | `fork_choice_epbs.nim#L87-L100` | Matches `S@…fork-choice.md#L467-L486` |
| `should_extend_payload`, including the proposer-boost clauses | `fork_choice_epbs.nim#L73-L84` + `fork_choice.nim#L597-L612` (`emptyPreferredRoot` only when the boosted block's parent is that root and the boosted block builds on EMPTY) | Matches `#L488-L510` |
| `get_weight` = 0 for previous-slot EMPTY and FULL | `proto_array.nim#L590-L606` (`isPrevSlot` gives 0 to both) | Matches `#L564-L570` |
| Payload-status tiebreaker (prev slot: FULL 2 > EMPTY 1 unless `should_extend_payload` is false; otherwise FULL > EMPTY) | `proto_array.nim#L600-L614` | Matches `#L512-L526` |
| Proposer boost not given to the boosted block's EMPTY/FULL variants | `proto_array.nim#L270-L293,L594-L601` | Matches the `is_ancestor` semantics |
| `should_apply_proposer_boost` equivocation scan uses the PTC deadline | `fork_choice_epbs.nim#L161-L199`; `fork_choice.nim#L448-L463` | Same parent slot ⇔ `block.slot + 1 == slot` once `parent.slot + 1 == slot` |
| `is_head_weak` equivocator set | `fork_choice_epbs.nim#L147-L156` (`votes[val].slot == FAR_FUTURE_SLOT`, set by `process_equivocation` at `fork_choice.nim#L424-L433`) | Default vote slot is 0, so non-voters are not counted. Balances come from justified balances; I did not check whether slashed validators get 0 there |
| `validate_on_attestation` index in {0,1}, same-slot ⇒ 0 | gossip `gossip_validation.nim#L995-L1004`; fork-choice apply-time `fork_choice.nim#L400-L405` | Index-1 requires an envelope: see C6 |
| On-wire PTC message: block known, slot == block slot, current slot, all seats | `fork_choice_epbs.nim#L217-L248` | PTC source: see C2 |
| Bid: unseen proposer preferences → IGNORE | `gossip_validation.nim#L1922-L1932` | Matches `S@…p2p-interface.md#L1017-L1027` |
| Bid: fee recipient, prev_randao REJECT, lookahead IGNORE, parent slot REJECT | `gossip_validation.nim#L1864-L1874,L1934-L1946` | — |
| `is_bid_compatible_with_head` | `fork_choice_epbs.nim#L110-L128` | Matches `S@…p2p-interface.md#L398-L424` |
| On-wire `payload_attestation_message` order: current slot, dedupe, block known, block slot IGNORE | `gossip_validation.nim#L2016-L2036` | — |
| `on_execution_payload_envelope` adds the FULL node | `fork_choice_epbs.nim#L257-L282`; `proto_array.nim#L413-L440` | Verification and DA happen upstream (not traced) |

**Not reviewed in the time available:** `on_block` ordering of the `is_payload_verified` assert for a full parent vs. recording payload attestations (Nimbus records in-block PTC votes at `fork_choice.nim#L519-L528`, inside `process_block`, which runs after block verification; I did not trace the full-parent assert in `block_processor.nim`); ExecutionPayloadEnvelopesByRange/ByRoot (zero count, canonical only); payload deadlines at the fork boundary; envelope replay on restart (PR 9344, open); envelope gossip REJECT rules beyond the summary at `gossip_validation.nim#L821-L946` (note: the builder pubkey comes from `headState`, flagged by the in-code TODO at `#L923`).

## 4. Known-check (GitHub search API, `repo:status-im/nimbus-eth2`, open and closed issues and PRs)

| Candidate | Status | Queries |
|---|---|---|
| C1 | not found | "payload attestation", "ptc", "duplicate ptc", "aggregation_bits payload", "is_from_block", "7732", "gloas", "epbs" |
| C2 | **known, fixed on unstable only**: https://github.com/status-im/nimbus-eth2/pull/9341 (closed, merged 2026-10-24 into `unstable`) | "ptc" |
| C3 | not found | "builder active bid", "headState bid", "gloas" |
| C4 | not found | "gloas", "epbs"; related ordering PRs 9119 (envelope seen rule) and 8860 (gossip rules for alpha.13), both closed, not this issue |
| C5 | not found | "is_head_weak" (only PR 8559, closed, the original handlers) |
| C6 | not found | "orphan envelope attestation" (0 hits), "gloas" |
| C7 | n/a | — |

Already-indexed items seen: 9344 (open PR, envelope replay on restart), 9203 (open issue, EL-unvalidated head), 9011 (open issue, PTC waits for EL), 9008 (closed), 8631 (not returned by these queries; listed as closed in the brief).

## 5. URLs read
- https://api.github.com/repos/status-im/nimbus-eth2/commits/stable, …/commits/unstable
- https://api.github.com/repos/ethereum/consensus-specs/commits/master, …/git/refs/tags/v1.7.0-beta.4, …/compare/v1.7.0-beta.4...master
- https://api.github.com/repos/OffchainLabs/prysm/commits/develop
- https://api.github.com/repos/status-im/nimbus-eth2/pulls/9341
- https://api.github.com/search/issues?q=repo:status-im/nimbus-eth2+… (queries listed above)
- Shallow clones: https://github.com/status-im/nimbus-eth2 (stable, unstable), https://github.com/ethereum/consensus-specs (master), https://github.com/OffchainLabs/prysm (develop). Files: `specs/gloas/{fork-choice,p2p-interface,beacon-chain}.md`; Nimbus `beacon_chain/fork_choice/{fork_choice,fork_choice_epbs,proto_array,fork_choice_types}.nim`, `beacon_chain/gossip_processing/gossip_validation.nim`, `beacon_chain/spec/datatypes/base.nim`; Prysm `beacon-chain/blockchain/process_block.go`, `beacon-chain/blockchain/receive_payload_attestation_message.go`, `beacon-chain/sync/validate_payload_attestation.go`, `beacon-chain/sync/validate_execution_payload_bid.go`, `beacon-chain/forkchoice/doubly-linked-tree/gloas.go`.
- EIP text https://eips.ethereum.org/EIPS/eip-7732: not fetched; the comparison is against the consensus-specs text only.
- I did not check hive or execution-spec-tests results for this item.
