From 28240f47128012b1b756c617714ebf40548aec81 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 24 May 2026 16:51:57 +0300 Subject: [PATCH 1/6] calculate head block number for gloas --- beacon_node/beacon_chain/src/beacon_chain.rs | 8 ++++---- beacon_node/beacon_chain/src/canonical_head.rs | 13 ++++++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index db8f55a18a..8c0363608a 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -5055,16 +5055,16 @@ impl BeaconChain { return Ok(None); }; - // TODO(gloas) not sure what to do here see this issue - // https://github.com/sigp/lighthouse/issues/8817 let (prev_randao, parent_block_number) = if self .spec .fork_name_at_slot::(proposal_slot) .gloas_enabled() { - (cached_head.head_random()?, None) + ( + cached_head.head_random()?, + cached_head.head_block_number_gloas(), + ) } else { - // Get the `prev_randao` and parent block number. let head_block_number = cached_head.head_block_number()?; if proposer_head == head_parent_block_root { ( diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index b3ab2e6975..ba89703fb9 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -178,7 +178,7 @@ impl CachedHead { /// Returns the execution block number of the block at the head of the chain. /// - /// Returns an error if the chain is prior to Bellatrix. + /// Returns an error if the chain is prior to Bellatrix or post-Gloas pub fn head_block_number(&self) -> Result { self.snapshot .beacon_block @@ -187,6 +187,17 @@ impl CachedHead { .map(|payload| payload.block_number()) } + /// Returns the execution block number of the block at the head of the chain. + /// + /// Returns an error if the chain is prior to Gloas. + pub fn head_block_number_gloas(&self) -> Result { + self.snapshot + .execution_envelope + .as_ref() + .map(|envelope| envelope.message.payload.block_number) + .ok() + } + /// Returns the active validator count for the current epoch of the head state. /// /// Should only return `None` if the caches have not been built on the head state (this should From 26d0a99a7b7871c2fa6977df28621eba97d8bf0c Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 24 May 2026 17:13:46 +0300 Subject: [PATCH 2/6] cleanup --- beacon_node/beacon_chain/src/canonical_head.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index ba89703fb9..258fffdc13 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -190,12 +190,21 @@ impl CachedHead { /// Returns the execution block number of the block at the head of the chain. /// /// Returns an error if the chain is prior to Gloas. - pub fn head_block_number_gloas(&self) -> Result { - self.snapshot + pub fn head_block_number_gloas(&self) -> Option { + if let Some(head_block_number) = self + .snapshot .execution_envelope .as_ref() .map(|envelope| envelope.message.payload.block_number) - .ok() + { + Some(head_block_number) + } else { + // This fallback is strictly for the fork boundary case when self.snapshot.execution_envelope is `None`. + // Note: If there is a missed/orphaned envelope at the fork boundary we wont be able to get the block number using this fallback. + // We could try handling that edge case but it doesn't seem worth it. Returning `None` here just means that we don't + // emit a payload attributes SSE event further upstream. + self.head_block_number().ok() + } } /// Returns the active validator count for the current epoch of the head state. From 309719d2c6b891bc2ac5bf8b9d7a4cb78f37e043 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 24 May 2026 19:07:03 +0300 Subject: [PATCH 3/6] Apply suggestion from @eserilev --- beacon_node/beacon_chain/src/canonical_head.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 258fffdc13..799ecff132 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -189,7 +189,7 @@ impl CachedHead { /// Returns the execution block number of the block at the head of the chain. /// - /// Returns an error if the chain is prior to Gloas. + /// Returns `None` if the chain is prior to Gloas. pub fn head_block_number_gloas(&self) -> Option { if let Some(head_block_number) = self .snapshot From eead95f11263e9c664f660131e840da8b66f4e45 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Mon, 25 May 2026 17:11:57 +0300 Subject: [PATCH 4/6] Use fork choice to ensure that the execution envelope snapshot is populated in most cases --- beacon_node/beacon_chain/src/builder.rs | 19 +++++++++- .../beacon_chain/src/canonical_head.rs | 37 ++++++++++++++++--- consensus/fork_choice/src/fork_choice.rs | 16 ++++++++ consensus/proto_array/src/proto_array.rs | 23 ++++++++++++ .../src/proto_array_fork_choice.rs | 15 ++++++++ 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 61c026e0a9..a86b118038 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -802,7 +802,24 @@ where .map_err(|e| format!("Error loading head execution envelope: {:?}", e))? .map(Arc::new) } else { - None + let latest_full_block_root_opt = fork_choice + .latest_parent_full_block(head_block_root, &self.spec) + .map_err(|e| { + format!( + "Error fetching latest full beacon block root from fork choice: {:?}", + e + ) + })?; + + if let Some(latest_full_block_root) = latest_full_block_root_opt { + store + .get_payload_envelope(&latest_full_block_root) + .map_err(|e| format!("Error loading latest execution envelope: {:?}", e))? + .map(Arc::new) + } else { + // TODO(gloas) handle the case where the non-finalized portion of the chain has no canonical payload envelopes. + None + } }; let mut head_snapshot = BeaconSnapshot { diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 799ecff132..9135048122 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -200,9 +200,9 @@ impl CachedHead { Some(head_block_number) } else { // This fallback is strictly for the fork boundary case when self.snapshot.execution_envelope is `None`. - // Note: If there is a missed/orphaned envelope at the fork boundary we wont be able to get the block number using this fallback. - // We could try handling that edge case but it doesn't seem worth it. Returning `None` here just means that we don't - // emit a payload attributes SSE event further upstream. + // TODO(gloas) If there is a missed/orphaned envelope at the fork boundary we wont be able to get the block number using this fallback. + // We might want to try handling that edge case. Returning `None` here means that we don't emit a payload attributes SSE event which + // might be important for upstream consumers (i.e. the builder client). self.head_block_number().ok() } } @@ -345,7 +345,17 @@ impl CanonicalHead { .get_payload_envelope(&beacon_block_root)? .map(Arc::new) } else { - None + let latest_full_block_root_opt = + fork_choice.latest_parent_full_block(beacon_block_root, spec)?; + + if let Some(latest_full_block_root) = latest_full_block_root_opt { + store + .get_payload_envelope(&latest_full_block_root)? + .map(Arc::new) + } else { + // TODO(gloas) handle the case where the non-finalized portion of the chain has no canonical payload envelopes. + None + } }; let snapshot = BeaconSnapshot { @@ -744,7 +754,24 @@ impl BeaconChain { Some(envelope) } else { - None + let fork_choice = self.canonical_head.fork_choice_read_lock(); + let latest_full_block_root_opt = fork_choice + .latest_parent_full_block(new_view.head_block_root, &self.spec)?; + drop(fork_choice); + + if let Some(latest_full_block_root) = latest_full_block_root_opt { + let envelope = self + .store + .get_payload_envelope(&latest_full_block_root)? + .map(Arc::new) + .ok_or(Error::MissingExecutionPayloadEnvelope( + latest_full_block_root, + ))?; + Some(envelope) + } else { + // TODO(gloas) handle the case where the non-finalized portion of the chain has no canonical payload envelopes. + None + } }; let (_, beacon_state) = self .store diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 2de8ce7d81..f05b2741cb 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -1565,6 +1565,22 @@ where } } + /// Returns the latest ancestor of `block_root` whose `PayloadStatus` is `Full`. + pub fn latest_parent_full_block( + &self, + block_root: Hash256, + spec: &ChainSpec, + ) -> Result, Error> { + if self.is_finalized_checkpoint_or_descendant(block_root) { + let proposer_boost_root = self.fc_store.proposer_boost_root(); + self.proto_array + .latest_parent_full_block::(block_root, proposer_boost_root, spec) + .map_err(Error::ProtoArrayError) + } else { + Err(Error::DoesNotDescendFromFinalizedCheckpoint) + } + } + /// Returns the canonical payload status of a block. See /// `ProtoArrayForkChoice::get_canonical_payload_status`. pub fn get_canonical_payload_status( diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 8ac8354f06..05d1e469af 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1276,6 +1276,29 @@ impl ProtoArray { } } + /// Returns the latest ancestor of `block_root` whose `PayloadStatus` is `Full`. + pub(crate) fn latest_parent_full_block( + &self, + block_root: Hash256, + proposer_boost_root: Hash256, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result, Error> { + for node in self.iter_nodes(&block_root) { + if self.get_canonical_payload_status::( + node.root(), + node.slot(), + proposer_boost_root, + justified_balances, + spec, + )? == PayloadStatus::Full + { + return Ok(Some(node.root())); + } + } + Ok(None) + } + /// Returns the canonical payload status of a block, matching the decision /// `get_head` would make between `(root, FULL)` and `(root, EMPTY)`. pub(crate) fn get_canonical_payload_status( diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 96d2302266..6ab2398f48 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -1083,6 +1083,21 @@ impl ProtoArrayForkChoice { .unwrap_or(false) } + /// Returns the latest ancestor of `block_root` whose `PayloadStatus` is `Full`. + pub fn latest_parent_full_block( + &self, + block_root: Hash256, + proposer_boost_root: Hash256, + spec: &ChainSpec, + ) -> Result, Error> { + self.proto_array.latest_parent_full_block::( + block_root, + proposer_boost_root, + &self.balances, + spec, + ) + } + /// Returns the canonical payload status of a block, matching the decision /// `get_head` would make between `(root, FULL)` and `(root, EMPTY)`. pub fn get_canonical_payload_status( From 27a8246e95f1aec976fd9d390d5bf19b9127fe3d Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Mon, 25 May 2026 17:24:07 +0300 Subject: [PATCH 5/6] fix --- beacon_node/beacon_chain/src/builder.rs | 8 +++++++- beacon_node/beacon_chain/src/canonical_head.rs | 16 ++++++++++++++-- consensus/proto_array/src/proto_array.rs | 4 ++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index a86b118038..f392268106 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -801,7 +801,11 @@ where .get_payload_envelope(&head_block_root) .map_err(|e| format!("Error loading head execution envelope: {:?}", e))? .map(Arc::new) - } else { + } else if self + .spec + .fork_name_at_slot::(head_block.slot()) + .gloas_enabled() + { let latest_full_block_root_opt = fork_choice .latest_parent_full_block(head_block_root, &self.spec) .map_err(|e| { @@ -820,6 +824,8 @@ where // TODO(gloas) handle the case where the non-finalized portion of the chain has no canonical payload envelopes. None } + } else { + None }; let mut head_snapshot = BeaconSnapshot { diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 9135048122..8c9a9fa773 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -344,7 +344,10 @@ impl CanonicalHead { store .get_payload_envelope(&beacon_block_root)? .map(Arc::new) - } else { + } else if spec + .fork_name_at_slot::(beacon_block.slot()) + .gloas_enabled() + { let latest_full_block_root_opt = fork_choice.latest_parent_full_block(beacon_block_root, spec)?; @@ -356,6 +359,8 @@ impl CanonicalHead { // TODO(gloas) handle the case where the non-finalized portion of the chain has no canonical payload envelopes. None } + } else { + None }; let snapshot = BeaconSnapshot { @@ -753,7 +758,11 @@ impl BeaconChain { ))?; Some(envelope) - } else { + } else if self + .spec + .fork_name_at_slot::(beacon_block.slot()) + .gloas_enabled() + { let fork_choice = self.canonical_head.fork_choice_read_lock(); let latest_full_block_root_opt = fork_choice .latest_parent_full_block(new_view.head_block_root, &self.spec)?; @@ -772,7 +781,10 @@ impl BeaconChain { // TODO(gloas) handle the case where the non-finalized portion of the chain has no canonical payload envelopes. None } + } else { + None }; + let (_, beacon_state) = self .store .get_advanced_hot_state(new_view.head_block_root, current_slot, state_root)? diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 05d1e469af..a33fccaa82 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1285,6 +1285,10 @@ impl ProtoArray { spec: &ChainSpec, ) -> Result, Error> { for node in self.iter_nodes(&block_root) { + if node.as_v29().is_err() { + return Ok(None); + } + if self.get_canonical_payload_status::( node.root(), node.slot(), From a09d3f26a4509ca641d40670c5e04f5c8a4cbb28 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 31 May 2026 12:28:33 +0300 Subject: [PATCH 6/6] gloas-head-block-number --- .../src/fork_choice_test_definition.rs | 26 +++ .../gloas_payload.rs | 154 ++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index 43b76ec7cb..cc82e2ab2a 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -124,6 +124,14 @@ pub enum Operation { #[serde(default)] proposer_boost_root: Option, }, + /// Assert the root returned by `latest_parent_full_block` for `block_root`. + AssertLatestFullPayloadBlock { + block_root: Hash256, + expected: Option, + /// Override the proposer boost root. Defaults to `Hash256::zero()`. + #[serde(default)] + proposer_boost_root: Option, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -606,6 +614,24 @@ impl ForkChoiceTestDefinition { op_index ); } + Operation::AssertLatestFullPayloadBlock { + block_root, + expected, + proposer_boost_root, + } => { + let actual = fork_choice + .latest_parent_full_block::( + block_root, + proposer_boost_root.unwrap_or_else(Hash256::zero), + &spec, + ) + .unwrap(); + assert_eq!( + actual, expected, + "latest_parent_full_block mismatch at op index {}", + op_index + ); + } } } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs index ac4f8992c4..76348289c8 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs @@ -1235,4 +1235,158 @@ mod tests { } .run(); } + + /// `latest_parent_full_block` returns the block itself when its own payload is Full. + #[test] + fn latest_full_payload_block_returns_head_when_full() { + let mut ops = vec![]; + + // Gloas block with a received payload, so it is Full at its own slot. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + ops.push(Operation::AssertLatestFullPayloadBlock { + block_root: get_root(1), + expected: Some(get_root(1)), + proposer_boost_root: None, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(get_hash(42)), + execution_payload_block_hash: Some(get_hash(0)), + spec: Some(gloas_spec()), + } + .run(); + } + + /// `latest_parent_full_block` walks back past Empty descendants to the latest Full ancestor. + /// + /// root_1 (Full) -> root_2 (Empty) -> root_3 (Empty) + #[test] + fn latest_full_payload_block_walks_back_to_full_ancestor() { + let mut ops = vec![]; + + // root_1: payload received -> Full. + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(0)), + execution_payload_block_hash: Some(get_hash(1)), + }); + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // root_2 and root_3: no payload received -> Empty. + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + 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)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(3), + root: get_root(3), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(2)), + execution_payload_block_hash: Some(get_hash(3)), + }); + + ops.push(Operation::AssertLatestFullPayloadBlock { + block_root: get_root(3), + expected: Some(get_root(1)), + proposer_boost_root: None, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(get_hash(42)), + execution_payload_block_hash: Some(get_hash(0)), + spec: Some(gloas_spec()), + } + .run(); + } + + /// `latest_parent_full_block` returns `None` when the walk reaches the pre-Gloas boundary + /// without finding a Full payload (the documented TODO case). + /// + /// root_1 (V17, slot 31) -> root_2 (V29 Empty) -> root_3 (V29 Empty) + #[test] + fn latest_full_payload_block_none_at_pre_gloas_boundary() { + let mut ops = vec![]; + + // Pre-Gloas (V17) block at the last pre-Gloas slot. + 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, + }); + + // Two Gloas (V29) blocks with no payload received -> Empty. + ops.push(Operation::ProcessBlock { + slot: Slot::new(32), + 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)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(33), + root: get_root(3), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(2)), + execution_payload_block_hash: Some(get_hash(3)), + }); + + // The walk hits the V17 boundary block before any Full payload, so returns `None`. + ops.push(Operation::AssertLatestFullPayloadBlock { + block_root: get_root(3), + expected: None, + proposer_boost_root: None, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: Some(gloas_fork_boundary_spec()), + } + .run(); + } }