## EPF Week 8 Updates
This week, I finished up with the `BeaconBlockBody` and `BeaconState` modifications and submitted a [PR](https://github.com/shane-moore/lighthouse/pull/1) for review. The epbs branch now contains all new and modified Containers per the [consensus-specs](https://ethereum.github.io/consensus-specs/specs/_features/eip7732/beacon-chain/).
### BeaconBlockBody and BeaconState
I updated these structs per the [consensus-specs](https://ethereum.github.io/consensus-specs/specs/_features/eip7732/beacon-chain/) to contain the new epbs fields and remove deprecated fields like the `execution_payload` from `BeaconBlockBody`. Additionally, extensive refactoring was done across the codebase to support these changes. It compiles! I also tested that the ssz serialization of these modified structs work.
## Fork-Choice Investigation
I also spent a good bit of time digesting the spec's [section](https://ethereum.github.io/consensus-specs/specs/_features/eip7732/fork-choice/) on fork-choice since it will be a pretty significant lift.
We introduce the concept of a `ForkChoiceNode`:
```rust=
struct ForkChoiceNode {
root: Root,
payload_status: PayloadStatus, // PENDING | EMPTY | FULL
}
```
For each slot, you can have `(B, PENDING)`, `(B, EMPTY), and (B, FULL)`. When running `get_head`, fork choice starts from latest justified checkpoint down to the tip, using weights to select branches along the way. A big shift is that now fork choice makes a payload decision (traverse down `FULL` or `EMPTY` nodes) and then moves on to choosing a child of B `(B, FULL)` or `(B, EMPTY)`, i.e. `(C, PENDING)`. This happens for each slot, and in most slots, the payload decision is weight-based, but for the previous slot, a special tiebreaker is used.
### Current Slot Fork-Choice Decision
- Pick the tip with max LMD-GHOST weight + proposer boost the same as today.
### Slot Before Current Slot Fork-Choice Decision - Use PTC Votes
```
BXXX -> (B-1, PENDING) ──→ (B-1, FULL or EMPTY) ──→ (B, PENDING) = current
```
PTC votes really only come into play for the slot before the current slot, in the scenario of (B-1, PENDING) at `(B-1).slot + 1 == current_slot`. Firstly, `(B-1,FULL)` can only be selected if the execution payload is locally available. Then, a tiebreaker is run to:
1. Pick `FULL` if the majority of the PTC voted that the payload was present.
2. Else pick `FULL` if the current slot's proposer block `B` saw and extended `B-1`’s payload.
In the spec, this is determined from block `B`, the proposer's block, if it exists, via header linkage:
- If block `B` is not seen, allow `FULL`.
- If block `B` does not directly build on `(B-1)`, allow `FULL`.
- If it does directly build on `(B-1)`, compare execution headers:
- If `parent_block_hash` of `B`’s execution header equals `(B-1)`’s `block_hash`, proposer extended `FULL`.
3. Otherwise, default to `(B-1, EMPTY)`.
In other words, we only mark `(B-1)` as `EMPTY` if the current slot’s proposer directly built on `(B-1)` but their execution header’s `parent_block_hash` does not match `(B-1)`’s block_hash, indicating they did not extend its payload.
Also note thta PTC votes never change earlier ancestors or the current slot's head.
### Older ancestors (≥ 2 slots behind the tip) Fork-Choice Decision
#### Normal Flow
```
(B, PENDING)
├─→ (B, EMPTY) ← parent_block_hash mismatch
│ └─→ (C, PENDING)
│ ├─→ (C, EMPTY)
│ └─→ (C, FULL)
└─→ (B, FULL) ← parent_block_hash match
└─→ (D, PENDING)
├─→ (D, EMPTY)
└─→ (D, FULL)
```
For older ancestors in the normal case, fork-choice traversal uses the descendant path of `is_supporting_vote`: walk from the vote’s tip to the ancestor at the node’s slot and derive the ancestor’s status from headers. In the example above, let's say we're trying to select `(B,FULL | EMPTY)`. If `D.parent_block_hash == B.block_hash`, `B` is treated as `FULL`, else `EMPTY`. The `attestation.data.index` bit is ignored here. The idea is that if a payload was withheld during `B`'s slot, then the child `D`'s `parent_block_hash` would likely match the grandparent instead, so the parent block is marked as empty here, and we'd continue down `C's` path.
#### Edge Case
```
Slots: N-1 N N+1 (missed) N+2 (current)
(A, FULL) ──→ (B, PENDING)
├─→ (B, EMPTY)
└─→ (B, FULL)
[ no block proposed/seen at N+1 ]
↘ latest_message at slot N+1 votes for B
- index = 1 → supports (B, FULL)
- index = 0 → supports (B, EMPTY)
(valid only if msg.slot > B.slot)
```
Sometimes a validator’s latest attestation is directly for an older block (i.e, at slot `N+1`, they attest to `B(N)` because the `N+1` block was missing/not seen by the deadline). This triggers the same-root path of `is_supporting_vote` (same-root means root being scored is the same as the root in the attestation):
- Same-slot votes always set `attestation.data.index = 0` and never count towards `FULL/EMPTY`
- For prior slot votes, count toward `(B, FULL|EMPTY)` only if `attestation.data.slot > B.slot`. If `attestation.data.index == 1`, `B` is `Full`, else `EMPTY`
In other words, in the same-root path, an attestation only influences whether an older block is scored as `FULL` or `EMPTY` if it comes from a later slot than that block. This ensures that only validators with a later view of the chain who have had more opportunity to have seen the payload can contribute weight to a `FULL/EMPTY` decision.