Merge branch 'gloas-walk-always' of https://github.com/sigp/lighthouse into gloas-fork-choice-fixes

This commit is contained in:
Eitan Seri- Levi
2026-03-31 23:53:09 -07:00
45 changed files with 2528 additions and 883 deletions

View File

@@ -144,6 +144,7 @@ impl ForkChoiceTestDefinition {
ExecutionStatus::Optimistic(ExecutionBlockHash::zero()),
self.execution_payload_parent_hash,
self.execution_payload_block_hash,
0,
&spec,
)
.expect("should create fork choice struct");

View File

@@ -52,7 +52,7 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition {
});
// Mark root_1 as having received its execution payload so that
// its FULL virtual node exists in the GLOAS fork choice tree.
// its FULL virtual node exists in the Gloas fork choice tree.
ops.push(Operation::ProcessExecutionPayload {
block_root: get_root(1),
});
@@ -262,7 +262,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe
});
// Mark root_1 as having received its execution payload so that
// its FULL virtual node exists in the GLOAS fork choice tree.
// its FULL virtual node exists in the Gloas fork choice tree.
ops.push(Operation::ProcessExecutionPayload {
block_root: get_root(1),
});
@@ -367,7 +367,7 @@ pub fn get_gloas_weight_priority_over_payload_preference_test_definition()
});
// Mark root_1 as having received its execution payload so that
// its FULL virtual node exists in the GLOAS fork choice tree.
// its FULL virtual node exists in the Gloas fork choice tree.
ops.push(Operation::ProcessExecutionPayload {
block_root: get_root(1),
});
@@ -537,7 +537,7 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef
});
// Mark root_1 as having received its execution payload so that
// its FULL virtual node exists in the GLOAS fork choice tree.
// its FULL virtual node exists in the Gloas fork choice tree.
ops.push(Operation::ProcessExecutionPayload {
block_root: get_root(1),
});
@@ -718,6 +718,137 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe
mod tests {
use super::*;
fn gloas_fork_boundary_spec() -> ChainSpec {
let mut spec = MainnetEthSpec::default_spec();
spec.proposer_score_boost = Some(50);
spec.gloas_fork_epoch = Some(Epoch::new(1));
spec
}
/// Gloas fork boundary: a chain starting pre-Gloas (V17 nodes) that crosses into
/// Gloas (V29 nodes). The head should advance through the fork boundary.
///
/// Parameters:
/// - `skip_first_gloas_slot`: if true, there is no block at the first Gloas slot (slot 32);
/// the first V29 block appears at slot 33.
/// - `first_gloas_block_full`: if true, the first V29 block extends the parent V17 node's
/// EL chain (Full parent payload status). If false, it doesn't (Empty).
fn get_gloas_fork_boundary_test_definition(
skip_first_gloas_slot: bool,
first_gloas_block_full: bool,
) -> ForkChoiceTestDefinition {
let mut ops = vec![];
// Block at slot 31 — last pre-Gloas slot. Created as a V17 node because
// gloas_fork_epoch = 1 → Gloas starts at slot 32.
//
// The test harness sets execution_status = Optimistic(ExecutionBlockHash::from_root(root)),
// so this V17 node's EL block hash = ExecutionBlockHash::from_root(get_root(1)).
ops.push(Operation::ProcessBlock {
slot: Slot::new(31),
root: get_root(1),
parent_root: get_root(0),
justified_checkpoint: get_checkpoint(0),
finalized_checkpoint: get_checkpoint(0),
execution_payload_parent_hash: None,
execution_payload_block_hash: None,
});
// First Gloas block (V29 node).
let gloas_slot = if skip_first_gloas_slot { 33 } else { 32 };
// The first Gloas block should always have the pre-Gloas block as its execution parent,
// although this is currently not checked anywhere (the spec doesn't mention this).
ops.push(Operation::ProcessBlock {
slot: Slot::new(gloas_slot),
root: get_root(2),
parent_root: get_root(1),
justified_checkpoint: get_checkpoint(0),
finalized_checkpoint: get_checkpoint(0),
execution_payload_parent_hash: Some(get_hash(1)),
execution_payload_block_hash: Some(get_hash(2)),
});
// Parent payload status of fork boundary block should always be Empty.
let expected_parent_status = PayloadStatus::Empty;
ops.push(Operation::AssertParentPayloadStatus {
block_root: get_root(2),
expected_status: expected_parent_status,
});
// Mark root 2's execution payload as received so the Full virtual child exists.
if first_gloas_block_full {
ops.push(Operation::ProcessExecutionPayload {
block_root: get_root(2),
});
}
// Extend the chain with another V29 block (Full child of root 2).
ops.push(Operation::ProcessBlock {
slot: Slot::new(gloas_slot + 1),
root: get_root(3),
parent_root: get_root(2),
justified_checkpoint: get_checkpoint(0),
finalized_checkpoint: get_checkpoint(0),
execution_payload_parent_hash: if first_gloas_block_full {
Some(get_hash(2))
} else {
Some(get_hash(1))
},
execution_payload_block_hash: Some(get_hash(3)),
});
// Head should advance to the tip of the chain through the fork boundary.
ops.push(Operation::FindHead {
justified_checkpoint: get_checkpoint(0),
finalized_checkpoint: get_checkpoint(0),
justified_state_balances: vec![1],
expected_head: get_root(3),
current_slot: Slot::new(gloas_slot + 1),
expected_payload_status: None,
});
ops.push(Operation::AssertParentPayloadStatus {
block_root: get_root(3),
expected_status: if first_gloas_block_full {
PayloadStatus::Full
} else {
PayloadStatus::Empty
},
});
ForkChoiceTestDefinition {
finalized_block_slot: Slot::new(0),
justified_checkpoint: get_checkpoint(0),
finalized_checkpoint: get_checkpoint(0),
operations: ops,
// Genesis is V17 (slot 0 < Gloas fork slot 32), these are unused for V17.
execution_payload_parent_hash: None,
execution_payload_block_hash: None,
spec: Some(gloas_fork_boundary_spec()),
}
}
#[test]
fn fork_boundary_no_skip_full() {
get_gloas_fork_boundary_test_definition(false, true).run();
}
#[test]
fn fork_boundary_no_skip_empty() {
get_gloas_fork_boundary_test_definition(false, false).run();
}
#[test]
fn fork_boundary_skip_first_gloas_slot_full() {
get_gloas_fork_boundary_test_definition(true, true).run();
}
#[test]
fn fork_boundary_skip_first_gloas_slot_empty() {
get_gloas_fork_boundary_test_definition(true, false).run();
}
#[test]
fn chain_following() {
let test = get_gloas_chain_following_test_definition();

View File

@@ -117,10 +117,10 @@ pub struct ProtoNode {
pub finalized_checkpoint: Checkpoint,
#[superstruct(getter(copy))]
pub weight: u64,
#[superstruct(getter(copy))]
#[superstruct(only(V17), partial_getter(copy))]
#[ssz(with = "four_byte_option_usize")]
pub best_child: Option<usize>,
#[superstruct(getter(copy))]
#[superstruct(only(V17), partial_getter(copy))]
#[ssz(with = "four_byte_option_usize")]
pub best_descendant: Option<usize>,
/// Indicates if an execution node has marked this block as valid. Also contains the execution
@@ -143,6 +143,8 @@ pub struct ProtoNode {
pub full_payload_weight: u64,
#[superstruct(only(V29), partial_getter(copy))]
pub execution_payload_block_hash: ExecutionBlockHash,
#[superstruct(only(V29), partial_getter(copy))]
pub execution_payload_parent_hash: ExecutionBlockHash,
/// Equivalent to spec's `block_timeliness[root][ATTESTATION_TIMELINESS_INDEX]`.
#[superstruct(only(V29), partial_getter(copy))]
pub block_timeliness_attestation_threshold: bool,
@@ -181,7 +183,6 @@ pub struct ProtoNode {
impl ProtoNode {
/// Generic version of spec's `parent_payload_status` that works for pre-Gloas nodes by
/// considering their parents Empty.
/// Pre-Gloas nodes have no ePBS, default to Empty.
pub fn get_parent_payload_status(&self) -> PayloadStatus {
self.parent_payload_status().unwrap_or(PayloadStatus::Empty)
}
@@ -535,7 +536,7 @@ impl ProtoArray {
.parent_root
.and_then(|parent| self.indices.get(&parent).copied());
let node = if !spec.fork_name_at_slot::<E>(current_slot).gloas_enabled() {
let node = if !spec.fork_name_at_slot::<E>(block.slot).gloas_enabled() {
ProtoNode::V17(ProtoNodeV17 {
slot: block.slot,
root: block.root,
@@ -570,31 +571,31 @@ impl ProtoArray {
block_root: block.root,
})?;
let parent_payload_status: PayloadStatus = if let Some(parent_node) =
parent_index.and_then(|idx| self.nodes.get(idx))
{
// Get the parent's execution block hash, handling both V17 and V29 nodes.
// V17 parents occur during the Gloas fork transition.
// TODO(gloas): the spec's `get_parent_payload_status` assumes all blocks are
// post-Gloas with bids. Revisit once the spec clarifies fork-transition behavior.
let parent_el_block_hash = match parent_node {
ProtoNode::V29(v29) => Some(v29.execution_payload_block_hash),
ProtoNode::V17(v17) => v17.execution_status.block_hash(),
};
// Per spec's `is_parent_node_full`: if the child's EL parent hash
// matches the parent's EL block hash, the child extends the parent's
// payload chain, meaning the parent was Full.
if parent_el_block_hash.is_some_and(|hash| execution_payload_parent_hash == hash) {
PayloadStatus::Full
let parent_payload_status: PayloadStatus =
if let Some(parent_node) = parent_index.and_then(|idx| self.nodes.get(idx)) {
match parent_node {
ProtoNode::V29(v29) => {
// Both parent and child are Gloas blocks. The parent is full if the
// block hash in the parent node matches the parent block hash in the
// child bid.
if execution_payload_parent_hash == v29.execution_payload_block_hash {
PayloadStatus::Full
} else {
PayloadStatus::Empty
}
}
ProtoNode::V17(_) => {
// Parent is pre-Gloas, pre-Gloas blocks are treated as having Empty
// payload status. This case is reached during the fork transition.
PayloadStatus::Empty
}
}
} else {
PayloadStatus::Empty
}
} else {
// Parent is missing (genesis or pruned due to finalization). Default to Full
// since this path should only be hit at Gloas genesis, and extending the payload
// chain is the safe default.
PayloadStatus::Full
};
// TODO(gloas): re-assess this assumption
// Parent is missing (genesis or pruned due to finalization). Default to Full
// since this path should only be hit at Gloas genesis.
PayloadStatus::Full
};
// Per spec `get_forkchoice_store`: the anchor (genesis) block has
// its payload state initialized (`payload_states = {anchor_root: ...}`).
@@ -614,14 +615,13 @@ impl ProtoArray {
justified_checkpoint: block.justified_checkpoint,
finalized_checkpoint: block.finalized_checkpoint,
weight: 0,
best_child: None,
best_descendant: None,
unrealized_justified_checkpoint: block.unrealized_justified_checkpoint,
unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint,
parent_payload_status,
empty_payload_weight: 0,
full_payload_weight: 0,
execution_payload_block_hash,
execution_payload_parent_hash,
// Per spec `get_forkchoice_store`: the anchor block's PTC votes are
// initialized to all-True, ensuring `is_payload_timely` and
// `is_payload_data_available` return true for the anchor.
@@ -642,7 +642,7 @@ impl ProtoArray {
block_timeliness_attestation_threshold: is_genesis
|| (is_current_slot
&& time_into_slot < spec.get_unaggregated_attestation_due()),
// TODO(gloas): use GLOAS-specific PTC due threshold once
// TODO(gloas): use Gloas-specific PTC due threshold once
// `get_payload_attestation_due_ms` is on ChainSpec.
block_timeliness_ptc_threshold: is_genesis
|| (is_current_slot && time_into_slot < spec.get_slot_duration() / 2),

View File

@@ -33,6 +33,47 @@ pub struct VoteTracker {
next_payload_present: bool,
}
// Can be deleted once the V28 schema migration is buried.
// Matches the on-disk format from schema v28: current_root, next_root, next_epoch.
#[derive(Default, PartialEq, Clone, Encode, Decode)]
pub struct VoteTrackerV28 {
current_root: Hash256,
next_root: Hash256,
next_epoch: Epoch,
}
// This impl is only used upon upgrade from pre-Gloas to Gloas with all pre-Gloas nodes.
// The payload status is `false` for pre-Gloas nodes.
impl From<VoteTrackerV28> for VoteTracker {
fn from(v: VoteTrackerV28) -> Self {
VoteTracker {
current_root: v.current_root,
next_root: v.next_root,
// The v28 format stored next_epoch rather than slots. Default to 0 since the
// vote tracker will be updated on the next attestation.
current_slot: Slot::new(0),
next_slot: Slot::new(0),
current_payload_present: false,
next_payload_present: false,
}
}
}
// This impl is only used upon downgrade from V29 to V28, with exclusively pre-Gloas nodes.
impl From<VoteTracker> for VoteTrackerV28 {
fn from(v: VoteTracker) -> Self {
// Drop the payload_present fields. This is safe because this is only called on pre-Gloas
// nodes.
VoteTrackerV28 {
current_root: v.current_root,
next_root: v.next_root,
// The v28 format stored next_epoch. Default to 0 since the vote tracker will be
// updated on the next attestation.
next_epoch: Epoch::new(0),
}
}
}
pub struct LatestMessage {
pub slot: Slot,
pub root: Hash256,
@@ -479,6 +520,7 @@ impl ProtoArrayForkChoice {
execution_status: ExecutionStatus,
execution_payload_parent_hash: Option<ExecutionBlockHash>,
execution_payload_block_hash: Option<ExecutionBlockHash>,
proposer_index: u64,
spec: &ChainSpec,
) -> Result<Self, String> {
let mut proto_array = ProtoArray {
@@ -505,7 +547,7 @@ impl ProtoArrayForkChoice {
unrealized_finalized_checkpoint: Some(finalized_checkpoint),
execution_payload_parent_hash,
execution_payload_block_hash,
proposer_index: Some(0),
proposer_index: Some(proposer_index),
};
proto_array
@@ -988,7 +1030,7 @@ impl ProtoArrayForkChoice {
.unwrap_or_else(|_| ExecutionStatus::irrelevant()),
unrealized_justified_checkpoint: block.unrealized_justified_checkpoint(),
unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint(),
execution_payload_parent_hash: None,
execution_payload_parent_hash: block.execution_payload_parent_hash().ok(),
execution_payload_block_hash: block.execution_payload_block_hash().ok(),
proposer_index: block.proposer_index().ok(),
})
@@ -997,11 +1039,16 @@ impl ProtoArrayForkChoice {
/// Returns the `block.execution_status` field, if the block is present.
pub fn get_block_execution_status(&self, block_root: &Hash256) -> Option<ExecutionStatus> {
let block = self.get_proto_node(block_root)?;
block.execution_status().ok()
Some(
block
.execution_status()
.unwrap_or_else(|_| ExecutionStatus::irrelevant()),
)
}
/// Returns whether the execution payload for a block has been received.
/// Returns `false` for pre-GLOAS (V17) nodes or unknown blocks.
///
/// Returns `false` for pre-Gloas (V17) nodes or unknown blocks.
pub fn is_payload_received(&self, block_root: &Hash256) -> bool {
self.get_proto_node(block_root)
.and_then(|node| node.payload_received().ok())
@@ -1313,6 +1360,7 @@ mod test_compute_deltas {
execution_status,
None,
None,
0,
&spec,
)
.unwrap();
@@ -1467,6 +1515,7 @@ mod test_compute_deltas {
execution_status,
None,
None,
0,
&spec,
)
.unwrap();

View File

@@ -2,7 +2,7 @@ use crate::proto_array::ProposerBoost;
use crate::{
Error, JustifiedBalances,
proto_array::{ProtoArray, ProtoNode, ProtoNodeV17},
proto_array_fork_choice::{ElasticList, ProtoArrayForkChoice, VoteTracker},
proto_array_fork_choice::{ElasticList, ProtoArrayForkChoice, VoteTracker, VoteTrackerV28},
};
use ssz::{Encode, four_byte_option_impl};
use ssz_derive::{Decode, Encode};
@@ -22,6 +22,9 @@ pub type SszContainer = SszContainerV29;
no_enum
)]
pub struct SszContainer {
#[superstruct(only(V28))]
pub votes_v28: Vec<VoteTrackerV28>,
#[superstruct(only(V29))]
pub votes: Vec<VoteTracker>,
pub prune_threshold: usize,
// Deprecated, remove in a future schema migration
@@ -75,9 +78,19 @@ impl TryFrom<(SszContainerV29, JustifiedBalances)> for ProtoArrayForkChoice {
impl From<SszContainerV28> for SszContainerV29 {
fn from(v28: SszContainerV28) -> Self {
Self {
votes: v28.votes,
votes: v28.votes_v28.into_iter().map(Into::into).collect(),
prune_threshold: v28.prune_threshold,
nodes: v28.nodes.into_iter().map(ProtoNode::V17).collect(),
nodes: v28
.nodes
.into_iter()
.map(|mut node| {
// best_child/best_descendant are no longer used (replaced by
// the virtual tree walk). Clear during conversion.
node.best_child = None;
node.best_descendant = None;
ProtoNode::V17(node)
})
.collect(),
indices: v28.indices,
previous_proposer_boost: v28.previous_proposer_boost,
}
@@ -88,7 +101,7 @@ impl From<SszContainerV28> for SszContainerV29 {
impl From<SszContainerV29> for SszContainerV28 {
fn from(v29: SszContainerV29) -> Self {
Self {
votes: v29.votes,
votes_v28: v29.votes.into_iter().map(Into::into).collect(),
prune_threshold: v29.prune_threshold,
// These checkpoints are not consumed in v28 paths since the upgrade from v17,
// we can safely default the values.