## 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.