From 28240f47128012b1b756c617714ebf40548aec81 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 24 May 2026 16:51:57 +0300 Subject: [PATCH 01/10] 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 db8f55a18aa..8c0363608ae 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 b3ab2e69756..ba89703fb9c 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 02/10] 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 ba89703fb9c..258fffdc132 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 03/10] 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 258fffdc132..799ecff1328 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 04/10] 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 61c026e0a95..a86b118038b 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 799ecff1328..91350481225 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 2de8ce7d817..f05b2741cbb 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 8ac8354f06e..05d1e469af3 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 96d23022666..6ab2398f485 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 05/10] 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 a86b118038b..f3922681068 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 91350481225..8c9a9fa773a 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 05d1e469af3..a33fccaa82c 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 06/10] 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 43b76ec7cb7..cc82e2ab2a6 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 ac4f8992c41..76348289c83 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(); + } } From fd2e7ef9368984bafd4b1922e5a2febb2cbe0603 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Wed, 24 Jun 2026 15:38:21 +0300 Subject: [PATCH 07/10] handle the case where there are no gloas payload envelopes in the non finalized portion of the chain Handle the fork boundary condition --- beacon_node/beacon_chain/src/beacon_chain.rs | 9 +- beacon_node/beacon_chain/src/builder.rs | 2 +- .../beacon_chain/src/canonical_head.rs | 93 ++++++++++++++++++- beacon_node/store/src/errors.rs | 3 + beacon_node/store/src/hot_cold_store.rs | 67 ++++++++++++- beacon_node/store/src/metadata.rs | 24 +++++ 6 files changed, 193 insertions(+), 5 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index a78a97e8c49..66bc1b1624e 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -5003,9 +5003,16 @@ impl BeaconChain { .fork_name_at_slot::(proposal_slot) .gloas_enabled() { + // If the non-finalized portion of chain has no canonical payload, the latest payload is + // the finalized one. Fork choice has pruned the block root associated with the finalized + // payload, so we fetch the relevant data from `PayloadInfo`. If a gloas payload envelope + // has ever been revealed, fall back to walking to the last pre-Gloas block on the head's chain. ( cached_head.head_random()?, - cached_head.head_block_number_gloas(), + cached_head + .head_block_number_gloas() + .or(self.store.get_payload_info().latest_finalized_block_number) + .or(self.latest_pre_gloas_block_number(cached_head.head_block_root())?), ) } else { let head_block_number = cached_head.head_block_number()?; diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index c35167ea53f..a4d0210e755 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -796,7 +796,7 @@ where .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. + // No payload revealed since finalization. None } } else { diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 4e32008d521..821f519dea5 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -56,6 +56,7 @@ use std::sync::Arc; use std::time::Duration; use store::{ Error as StoreError, KeyValueStore, KeyValueStoreOp, StoreConfig, iter::StateRootsIterator, + metadata::PayloadInfo, }; use task_executor::{JoinHandle, ShutdownReason}; use tracing::{debug, error, info, instrument, warn}; @@ -356,7 +357,7 @@ impl CanonicalHead { .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. + // No payload revealed since finalization. None } } else { @@ -778,7 +779,7 @@ impl BeaconChain { ))?; Some(envelope) } else { - // TODO(gloas) handle the case where the non-finalized portion of the chain has no canonical payload envelopes. + // No payload revealed since finalization. None } } else { @@ -1057,6 +1058,14 @@ impl BeaconChain { error!(error = ?e, "Failed to prune pending payload cache on finalization"); } } + + if let Some(gloas_fork_epoch) = self.spec.gloas_fork_epoch + && new_view.finalized_checkpoint.epoch >= gloas_fork_epoch + && let Err(e) = + self.update_finalized_payload_info(new_view.finalized_checkpoint.root) + { + error!(error = ?e, "Failed to update finalized payload info"); + } } if let Some(event_handler) = self.event_handler.as_ref() @@ -1135,6 +1144,86 @@ impl BeaconChain { Ok(()) } + /// Record the block number of the latest finalized execution payload. + /// + /// The payload of the finalized block isn't finalized, so we fetch the payload + /// that the finalized block builds upon. + fn update_finalized_payload_info( + self: &Arc, + finalized_block_root: Hash256, + ) -> Result<(), Error> { + let latest_full_block_root = { + let fork_choice = self.canonical_head.fork_choice_read_lock(); + fork_choice.latest_parent_full_block(finalized_block_root, &self.spec)? + }; + + let Some(latest_full_block_root) = latest_full_block_root else { + // A gloas payload envelope has never been revealed AND we have finalized a gloas epoch. + // The latest execution payload is from the last pre-gloas block. At this point that + // pre-gloas block is finalized so we set `PayloadInfo.latest_finalized_block_number` + // with its block number. + let payload_info = self.store.get_payload_info(); + if payload_info.latest_finalized_block_number.is_none() + && let Some(block_number) = + self.latest_pre_gloas_block_number(finalized_block_root)? + { + let new_payload_info = PayloadInfo { + latest_finalized_block_number: Some(block_number), + }; + self.store + .compare_and_set_payload_info_with_write(payload_info, new_payload_info)?; + } + return Ok(()); + }; + let Some(envelope) = self.store.get_payload_envelope(&latest_full_block_root)? else { + return Ok(()); + }; + + let payload_info = PayloadInfo { + latest_finalized_block_number: Some(envelope.message.payload.block_number), + }; + self.store + .compare_and_set_payload_info_with_write(self.store.get_payload_info(), payload_info)?; + + Ok(()) + } + + /// Walk back from `block_root` to the most recent pre-Gloas block and return its execution + /// block number. + /// + /// Note: This function is only relevant between the gloas fork boundary and before a + /// gloas epoch finalized. + pub(crate) fn latest_pre_gloas_block_number( + &self, + block_root: Hash256, + ) -> Result, Error> { + let latest_pre_gloas_root = { + let fork_choice = self.canonical_head.fork_choice_read_lock(); + fork_choice + .proto_array() + .iter_block_roots(&block_root) + .find(|(_, slot)| { + !self + .spec + .fork_name_at_slot::(*slot) + .gloas_enabled() + }) + .map(|(root, _)| root) + }; + + let Some(latest_pre_gloas_root) = latest_pre_gloas_root else { + return Ok(None); + }; + let Some(block) = self.get_blinded_block(&latest_pre_gloas_root)? else { + return Ok(None); + }; + Ok(block + .message() + .execution_payload() + .ok() + .map(|payload| payload.block_number())) + } + /// Persist fork choice to disk, writing immediately. pub fn persist_fork_choice(&self) -> Result<(), Error> { let _fork_choice_timer = metrics::start_timer(&metrics::PERSIST_FORK_CHOICE); diff --git a/beacon_node/store/src/errors.rs b/beacon_node/store/src/errors.rs index a07cc838863..dde26da6788 100644 --- a/beacon_node/store/src/errors.rs +++ b/beacon_node/store/src/errors.rs @@ -32,6 +32,8 @@ pub enum Error { BlobInfoConcurrentMutation, /// The store's `data_column_info` was mutated concurrently, the latest modification wasn't applied. DataColumnInfoConcurrentMutation, + /// The store's `payload_info` was mutated concurrently, the latest modification wasn't applied. + PayloadInfoConcurrentMutation, /// The block or state is unavailable due to weak subjectivity sync. HistoryUnavailable, /// State reconstruction cannot commence because not all historic blocks are known. @@ -92,6 +94,7 @@ pub enum Error { LoadSplit(Box), LoadBlobInfo(Box), LoadDataColumnInfo(Box), + LoadPayloadInfo(Box), LoadConfig(Box), LoadHotStateSummary(Hash256, Box), LoadHotStateSummaryForSplit(Box), diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index 7484c271aee..ed36c0eef6f 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -9,7 +9,8 @@ use crate::metadata::{ ANCHOR_INFO_KEY, ANCHOR_UNINITIALIZED, AnchorInfo, BLOB_INFO_KEY, BlobInfo, COMPACTION_TIMESTAMP_KEY, CONFIG_KEY, CURRENT_SCHEMA_VERSION, CompactionTimestamp, DATA_COLUMN_CUSTODY_INFO_KEY, DATA_COLUMN_INFO_KEY, DataColumnCustodyInfo, DataColumnInfo, - SCHEMA_VERSION_KEY, SPLIT_KEY, STATE_UPPER_LIMIT_NO_RETAIN, SchemaVersion, + PAYLOAD_INFO_KEY, PayloadInfo, SCHEMA_VERSION_KEY, SPLIT_KEY, STATE_UPPER_LIMIT_NO_RETAIN, + SchemaVersion, }; use crate::state_cache::{PutStateOutcome, StateCache}; use crate::{ @@ -60,6 +61,8 @@ pub struct HotColdDB { blob_info: RwLock, /// The starting slots for the range of data columns stored in the database. data_column_info: RwLock, + /// Metadata about the latest finalized execution payload (Gloas only). + payload_info: RwLock, pub(crate) config: StoreConfig, pub hierarchy: HierarchyModuli, /// Cold database containing compact historical data. @@ -233,6 +236,7 @@ impl HotColdDB { anchor_info: RwLock::new(ANCHOR_UNINITIALIZED), blob_info: RwLock::new(BlobInfo::default()), data_column_info: RwLock::new(DataColumnInfo::default()), + payload_info: RwLock::new(PayloadInfo::default()), cold_db: MemoryStore::open(), blobs_db: MemoryStore::open(), hot_db: MemoryStore::open(), @@ -286,6 +290,7 @@ impl HotColdDB { anchor_info, blob_info: RwLock::new(BlobInfo::default()), data_column_info: RwLock::new(DataColumnInfo::default()), + payload_info: RwLock::new(PayloadInfo::default()), blobs_db: BeaconNodeBackend::open(&config, blobs_db_path)?, cold_db: BeaconNodeBackend::open(&config, cold_path)?, hot_db, @@ -394,6 +399,12 @@ impl HotColdDB { new_data_column_info.clone(), )?; + // Load the latest finalized payload info into the in-memory cache (Gloas only). Absent on + // first start or pre-Gloas, in which case the default is retained. + if let Some(payload_info) = db.load_payload_info()? { + db.compare_and_set_payload_info_with_write(<_>::default(), payload_info)?; + } + info!( path = ?blobs_db_path, oldest_blob_slot = ?new_blob_info.oldest_blob_slot, @@ -2886,6 +2897,13 @@ impl HotColdDB { self.data_column_info.read_recursive().clone() } + /// Get a clone of the store's payload info. + /// + /// To do mutations, use `compare_and_set_payload_info`. Only populated post-Gloas. + pub fn get_payload_info(&self) -> PayloadInfo { + self.payload_info.read_recursive().clone() + } + /// Atomically update the blob info from `prev_value` to `new_value`. /// /// Return a `KeyValueStoreOp` which should be written to disk, possibly atomically with other @@ -2983,6 +3001,53 @@ impl HotColdDB { data_column_info.as_kv_store_op(DATA_COLUMN_INFO_KEY) } + /// Atomically update the payload info from `prev_value` to `new_value`. + /// + /// Return a `KeyValueStoreOp` which should be written to disk, possibly atomically with other + /// values. + /// + /// Return a `PayloadInfoConcurrentMutation` error if the `prev_value` provided + /// is not correct. + pub fn compare_and_set_payload_info( + &self, + prev_value: PayloadInfo, + new_value: PayloadInfo, + ) -> Result { + let mut payload_info = self.payload_info.write(); + if *payload_info == prev_value { + let kv_op = self.store_payload_info_in_batch(&new_value); + *payload_info = new_value; + Ok(kv_op) + } else { + Err(Error::PayloadInfoConcurrentMutation) + } + } + + /// As for `compare_and_set_payload_info`, but also writes the payload info to disk immediately. + pub fn compare_and_set_payload_info_with_write( + &self, + prev_value: PayloadInfo, + new_value: PayloadInfo, + ) -> Result<(), Error> { + let kv_store_op = self.compare_and_set_payload_info(prev_value, new_value)?; + self.hot_db.do_atomically(vec![kv_store_op]) + } + + /// Load the payload info from disk, but do not set `self.payload_info`. + fn load_payload_info(&self) -> Result, Error> { + self.hot_db + .get(&PAYLOAD_INFO_KEY) + .map_err(|e| Error::LoadPayloadInfo(e.into())) + } + + /// Store the given `payload_info` to disk. + /// + /// The argument is intended to be `self.payload_info`, but is passed manually to avoid issues + /// with recursive locking. + fn store_payload_info_in_batch(&self, payload_info: &PayloadInfo) -> KeyValueStoreOp { + payload_info.as_kv_store_op(PAYLOAD_INFO_KEY) + } + /// Return the slot-window describing the available historic states. /// /// Returns `(lower_limit, upper_limit)`. diff --git a/beacon_node/store/src/metadata.rs b/beacon_node/store/src/metadata.rs index 215cdb2b64d..560ed491226 100644 --- a/beacon_node/store/src/metadata.rs +++ b/beacon_node/store/src/metadata.rs @@ -19,6 +19,7 @@ pub const ANCHOR_INFO_KEY: Hash256 = Hash256::repeat_byte(5); pub const BLOB_INFO_KEY: Hash256 = Hash256::repeat_byte(6); pub const DATA_COLUMN_INFO_KEY: Hash256 = Hash256::repeat_byte(7); pub const DATA_COLUMN_CUSTODY_INFO_KEY: Hash256 = Hash256::repeat_byte(8); +pub const PAYLOAD_INFO_KEY: Hash256 = Hash256::repeat_byte(9); /// State upper limit value used to indicate that a node is not storing historic states. pub const STATE_UPPER_LIMIT_NO_RETAIN: Slot = Slot::new(u64::MAX); @@ -255,3 +256,26 @@ impl StoreItem for DataColumnInfo { Ok(Self::from_ssz_bytes(bytes)?) } } + +/// Tracks the latest finalized execution payload. Gloas only. +#[derive(Debug, PartialEq, Eq, Clone, Encode, Decode, Serialize, Deserialize, Default)] +pub struct PayloadInfo { + // The block number of the latest finalized execution payload. + // Pre-gloas: This is the payload from the latest finalized block. + // Post-Gloas: This is the envelope that the finalized block builds on top of. + pub latest_finalized_block_number: Option, +} + +impl StoreItem for PayloadInfo { + fn db_column() -> DBColumn { + DBColumn::BeaconMeta + } + + fn as_store_bytes(&self) -> Vec { + self.as_ssz_bytes() + } + + fn from_store_bytes(bytes: &[u8]) -> Result { + Ok(Self::from_ssz_bytes(bytes)?) + } +} From f92dd5833321aff5f97f18b6ef05e6d2c775f391 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:52:45 +0200 Subject: [PATCH 08/10] Add experimental tree-sync CLI flags Introduce three hidden, experimental network flags to test a "tree sync" mode that routes peers through lookup sync: - `--tree-sync`: direct every peer with an unknown head to block (lookup) sync instead of splitting between range and lookup sync by distance. While enabled the lookup-sync depth and lookup-count caps are removed (unbounded) so the full lookup tree is followed without dropping chains. - `--lookup-sync-max-parent-depth`: configure the parent-chain depth cap (default 32, the previous PARENT_DEPTH_TOLERANCE). - `--lookup-sync-max-lookups`: configure the max concurrent lookups (default 200, the previous MAX_LOOKUPS). The two limits are plumbed through NetworkConfig into BlockLookups, replacing the former hardcoded constants. Claude-Session: https://claude.ai/code/session_01QWQUJsFGmRtt5Y4bxvXWba --- beacon_node/lighthouse_network/src/config.rs | 21 +++++++ .../network/src/sync/block_lookups/mod.rs | 27 ++++++--- beacon_node/network/src/sync/manager.rs | 58 +++++++++++++------ beacon_node/src/cli.rs | 31 ++++++++++ beacon_node/src/config.rs | 24 ++++++++ 5 files changed, 134 insertions(+), 27 deletions(-) diff --git a/beacon_node/lighthouse_network/src/config.rs b/beacon_node/lighthouse_network/src/config.rs index f54a7ee5b95..d7326ea2fd1 100644 --- a/beacon_node/lighthouse_network/src/config.rs +++ b/beacon_node/lighthouse_network/src/config.rs @@ -23,6 +23,14 @@ pub const DEFAULT_DISC_PORT: u16 = 9000u16; pub const DEFAULT_QUIC_PORT: u16 = 9001u16; pub const DEFAULT_IDONTWANT_MESSAGE_SIZE_THRESHOLD: usize = 1000usize; +/// Default maximum depth of the parent chain searched by lookup sync. Matches the historical +/// `PARENT_DEPTH_TOLERANCE` (equal to `SLOT_IMPORT_TOLERANCE`) used by block lookup sync. +pub const DEFAULT_LOOKUP_SYNC_MAX_PARENT_DEPTH: usize = 32; + +/// Default maximum number of concurrent block lookups held by lookup sync. Matches the historical +/// `MAX_LOOKUPS` used by block lookup sync. +pub const DEFAULT_LOOKUP_SYNC_MAX_LOOKUPS: usize = 200; + pub struct GossipsubConfigParams { pub message_domain_valid_snappy: [u8; 4], pub gossipsub_max_transmit_size: usize, @@ -146,6 +154,16 @@ pub struct Config { /// Whether to enable partial data column support. pub enable_partial_columns: bool, + + /// Route every peer with an unknown head straight to block (lookup) sync instead of using the + /// range/lookup split based on how far ahead the peer is. Approximates "tree sync". + pub tree_sync: bool, + + /// Maximum depth of the parent chain that lookup sync will search before forcing range sync. + pub lookup_sync_max_parent_depth: usize, + + /// Maximum number of concurrent block lookups held by lookup sync. + pub lookup_sync_max_lookups: usize, } impl Config { @@ -372,6 +390,9 @@ impl Default for Config { idontwant_message_size_threshold: DEFAULT_IDONTWANT_MESSAGE_SIZE_THRESHOLD, advertise_false_custody_group_count: None, enable_partial_columns: false, + tree_sync: false, + lookup_sync_max_parent_depth: DEFAULT_LOOKUP_SYNC_MAX_PARENT_DEPTH, + lookup_sync_max_lookups: DEFAULT_LOOKUP_SYNC_MAX_LOOKUPS, } } } diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index d403382e9e9..93a7fc9ab16 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -70,11 +70,12 @@ const LOOKUP_MAX_DURATION_STUCK_SECS: u64 = 15 * PARENT_DEPTH_TOLERANCE as u64; /// lookup at most after 4 seconds, the lookup should gain peers. const LOOKUP_MAX_DURATION_NO_PEERS_SECS: u64 = 10; -/// Lookups contain untrusted data, including blocks that have not yet been validated. In case of -/// bugs or malicious activity we want to bound how much memory these lookups can consume. Aprox the -/// max size of a lookup is ~ 10 MB (current max size of gossip and RPC blocks). 200 lookups can -/// take at most 2 GB. 200 lookups allow 3 parallel chains of depth 64 (current maximum). -const MAX_LOOKUPS: usize = 200; +// Lookups contain untrusted data, including blocks that have not yet been validated. In case of +// bugs or malicious activity we want to bound how much memory these lookups can consume. Aprox the +// max size of a lookup is ~ 10 MB (current max size of gossip and RPC blocks). 200 lookups can +// take at most 2 GB. 200 lookups allow 3 parallel chains of depth 64 (current maximum). The bound +// is `BlockLookups::max_lookups`, defaulting to `DEFAULT_LOOKUP_SYNC_MAX_LOOKUPS` and overridable +// via the `--lookup-sync-max-lookups` CLI flag. type BlockDownloadResponse = Result>>, RpcResponseError>; type CustodyDownloadResponse = @@ -98,6 +99,14 @@ pub struct BlockLookups { // TODO: Why not index lookups by block_root? single_block_lookups: FnvHashMap>, + /// Maximum depth of the parent chain searched before forcing range sync. Defaults to + /// `PARENT_DEPTH_TOLERANCE`, overridable via the `--lookup-sync-max-parent-depth` CLI flag. + max_parent_depth: usize, + + /// Maximum number of concurrent block lookups. Defaults to `MAX_LOOKUPS`, overridable via the + /// `--lookup-sync-max-lookups` CLI flag. + max_lookups: usize, + /// Used for testing assertions metrics: BlockLookupsMetrics, } @@ -117,12 +126,14 @@ pub(crate) struct BlockLookupSummary { } impl BlockLookups { - pub fn new() -> Self { + pub fn new(max_parent_depth: usize, max_lookups: usize) -> Self { Self { ignored_chains: LRUTimeCache::new(Duration::from_secs( IGNORED_CHAINS_CACHE_EXPIRY_SECONDS, )), single_block_lookups: Default::default(), + max_parent_depth, + max_lookups, metrics: <_>::default(), } } @@ -247,7 +258,7 @@ impl BlockLookups { let trigger_is_chain_tip = parent_chain.tip == child_block_root_trigger; if (block_would_extend_chain || trigger_is_chain_tip) - && parent_chain.len() >= PARENT_DEPTH_TOLERANCE + && parent_chain.len() >= self.max_parent_depth { debug!(block_root = ?block_root_to_search, "Parent lookup chain too long"); @@ -379,7 +390,7 @@ impl BlockLookups { // Lookups contain untrusted data, bound the total count of lookups hold in memory to reduce // the risk of OOM in case of bugs of malicious activity. - if self.single_block_lookups.len() >= MAX_LOOKUPS { + if self.single_block_lookups.len() >= self.max_lookups { warn!(?block_root, "Dropping lookup reached max"); return false; } diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index b9bea21b8ce..410e15667a4 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -309,6 +309,17 @@ impl SyncManager { fork_context: Arc, ) -> Self { let network_globals = beacon_processor.network_globals.clone(); + // Tree sync follows the full tree of lookups without dropping chains, so the depth and + // lookup-count caps are removed (unbounded) when it is enabled. + let (lookup_sync_max_parent_depth, lookup_sync_max_lookups) = + if network_globals.config.tree_sync { + (usize::MAX, usize::MAX) + } else { + ( + network_globals.config.lookup_sync_max_parent_depth, + network_globals.config.lookup_sync_max_lookups, + ) + }; Self { chain: beacon_chain.clone(), input_channel: sync_recv, @@ -321,7 +332,7 @@ impl SyncManager { range_sync: RangeSync::new(beacon_chain.clone()), backfill_sync: BackFillSync::new(beacon_chain.clone(), network_globals.clone()), custody_backfill_sync: CustodyBackFillSync::new(beacon_chain.clone(), network_globals), - block_lookups: BlockLookups::new(), + block_lookups: BlockLookups::new(lookup_sync_max_parent_depth, lookup_sync_max_lookups), notified_unknown_roots: LRUTimeCache::new(Duration::from_secs( NOTIFIED_UNKNOWN_ROOT_EXPIRY_SECONDS, )), @@ -409,25 +420,34 @@ impl SyncManager { // update the state of the peer. let is_still_connected = self.update_peer_sync_state(&peer_id, &local, &remote, &sync_type); if is_still_connected { - match sync_type { - PeerSyncType::Behind => {} // Do nothing - PeerSyncType::Advanced => { - self.range_sync - .add_peer(&mut self.network, local, peer_id, remote); + if self.network_globals().config.tree_sync { + // Tree sync: direct every peer with an unknown head to block (lookup) sync + // regardless of how far ahead it is. If the unknown head turns out to be on a + // longer fork, lookup sync will force range sync once it reaches the max depth. + if !self.chain.block_is_known_to_fork_choice(&remote.head_root) { + self.handle_unknown_block_root(peer_id, remote.head_root); } - PeerSyncType::FullySynced => { - // Sync considers this peer close enough to the head to not trigger range sync. - // Range sync handles well syncing large ranges of blocks, of a least a few blocks. - // However this peer may be in a fork that we should sync but we have not discovered - // yet. If the head of the peer is unknown, attempt block lookup first. If the - // unknown head turns out to be on a longer fork, it will trigger range sync. - // - // A peer should always be considered `Advanced` if its finalized root is - // unknown and ahead of ours, so we don't check for that root here. - // - // TODO: This fork-choice check is potentially duplicated, review code - if !self.chain.block_is_known_to_fork_choice(&remote.head_root) { - self.handle_unknown_block_root(peer_id, remote.head_root); + } else { + match sync_type { + PeerSyncType::Behind => {} // Do nothing + PeerSyncType::Advanced => { + self.range_sync + .add_peer(&mut self.network, local, peer_id, remote); + } + PeerSyncType::FullySynced => { + // Sync considers this peer close enough to the head to not trigger range sync. + // Range sync handles well syncing large ranges of blocks, of a least a few blocks. + // However this peer may be in a fork that we should sync but we have not discovered + // yet. If the head of the peer is unknown, attempt block lookup first. If the + // unknown head turns out to be on a longer fork, it will trigger range sync. + // + // A peer should always be considered `Advanced` if its finalized root is + // unknown and ahead of ours, so we don't check for that root here. + // + // TODO: This fork-choice check is potentially duplicated, review code + if !self.chain.block_is_known_to_fork_choice(&remote.head_root) { + self.handle_unknown_block_root(peer_id, remote.head_root); + } } } } diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index 3cf6a6efd22..2c7674b3447 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -693,6 +693,37 @@ pub fn cli_app() -> Command { .default_missing_value("true") .display_order(0) ) + .arg( + Arg::new("tree-sync") + .long("tree-sync") + .action(ArgAction::SetTrue) + .help_heading(FLAG_HEADER) + .help("Direct every peer with an unknown head to block (lookup) sync instead of \ + splitting between range and lookup sync based on how far ahead the peer is. \ + Approximates tree sync. Experimental.") + .hide(true) + .display_order(0) + ) + .arg( + Arg::new("lookup-sync-max-parent-depth") + .long("lookup-sync-max-parent-depth") + .value_name("DEPTH") + .help("Maximum depth of the parent chain that lookup sync will search before \ + forcing range sync. Experimental.") + .action(ArgAction::Set) + .hide(true) + .display_order(0) + ) + .arg( + Arg::new("lookup-sync-max-lookups") + .long("lookup-sync-max-lookups") + .value_name("COUNT") + .help("Maximum number of concurrent block lookups held by lookup sync. \ + Experimental.") + .action(ArgAction::Set) + .hide(true) + .display_order(0) + ) /* * Monitoring metrics */ diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index d27909bddfc..88c38a7f06b 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -1503,6 +1503,30 @@ pub fn set_network_config( })?; } + config.tree_sync = cli_args.get_flag("tree-sync"); + + if let Some(lookup_sync_max_parent_depth) = + cli_args.get_one::("lookup-sync-max-parent-depth") + { + config.lookup_sync_max_parent_depth = + lookup_sync_max_parent_depth.parse::().map_err(|_| { + format!( + "Invalid lookup-sync-max-parent-depth value passed: {}", + lookup_sync_max_parent_depth + ) + })?; + } + + if let Some(lookup_sync_max_lookups) = cli_args.get_one::("lookup-sync-max-lookups") { + config.lookup_sync_max_lookups = + lookup_sync_max_lookups.parse::().map_err(|_| { + format!( + "Invalid lookup-sync-max-lookups value passed: {}", + lookup_sync_max_lookups + ) + })?; + } + Ok(()) } From 238d549e24a02c00208f994cc7a632ea6ccdb315 Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:29:54 +0200 Subject: [PATCH 09/10] Tree sync: bypass the not-synced gate so head lookups aren't rejected Under --tree-sync, add_peer routes every unknown head to handle_unknown_block_root, which calls should_search_for_block(None, peer). While the node isn't synced (e.g. right after a checkpoint sync) that gate returns Err("not synced") for every slot-less head lookup, so no lookup is created and the node never starts syncing (catch-22). Skip the gate under tree_sync, matching the flag's intent of searching regardless of distance. Found via mainnet testing: the node stalled at the anchor with repeated "Ignoring unknown block request: not synced". --- beacon_node/network/src/sync/manager.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 410e15667a4..006002bfcf6 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -1044,7 +1044,11 @@ impl SyncManager { block_slot: Option, peer_id: &PeerId, ) -> Result<(), &'static str> { - if !self.network_globals().sync_state.read().is_synced() { + // Tree sync intentionally searches for blocks regardless of how far behind we are; the + // not-synced distance gate would otherwise reject every tree-sync head lookup and stall. + if !self.network_globals().config.tree_sync + && !self.network_globals().sync_state.read().is_synced() + { let Some(block_slot) = block_slot else { return Err("not synced"); }; From a0d73cbc3fdf18a9ac98ea44b775a4d6a07d7d7e Mon Sep 17 00:00:00 2001 From: dapplion <35266934+dapplion@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:46:11 +0200 Subject: [PATCH 10/10] Rename flag to --disable-range-sync, drop lookup-* knobs Rename the experimental `--tree-sync` flag to `--disable-range-sync`, which describes the actual effect: range sync is disabled and every peer with an unknown head goes through block (lookup) sync. Drop the `--lookup-sync-max-parent-depth` / `--lookup-sync-max-lookups` customization flags. The lookup depth and count caps are simply removed (set to unbounded) when range sync is disabled, since lookup sync is then the only sync method and there is no range-sync fallback to escalate to; otherwise they keep their default PARENT_DEPTH_TOLERANCE / MAX_LOOKUPS values. Claude-Session: https://claude.ai/code/session_01QWQUJsFGmRtt5Y4bxvXWba --- beacon_node/lighthouse_network/src/config.rs | 24 +++----------- .../network/src/sync/block_lookups/mod.rs | 29 ++++++++++------- beacon_node/network/src/sync/manager.rs | 32 +++++++------------ beacon_node/src/cli.rs | 29 +++-------------- beacon_node/src/config.rs | 24 +------------- 5 files changed, 38 insertions(+), 100 deletions(-) diff --git a/beacon_node/lighthouse_network/src/config.rs b/beacon_node/lighthouse_network/src/config.rs index d7326ea2fd1..2974d5006ee 100644 --- a/beacon_node/lighthouse_network/src/config.rs +++ b/beacon_node/lighthouse_network/src/config.rs @@ -23,14 +23,6 @@ pub const DEFAULT_DISC_PORT: u16 = 9000u16; pub const DEFAULT_QUIC_PORT: u16 = 9001u16; pub const DEFAULT_IDONTWANT_MESSAGE_SIZE_THRESHOLD: usize = 1000usize; -/// Default maximum depth of the parent chain searched by lookup sync. Matches the historical -/// `PARENT_DEPTH_TOLERANCE` (equal to `SLOT_IMPORT_TOLERANCE`) used by block lookup sync. -pub const DEFAULT_LOOKUP_SYNC_MAX_PARENT_DEPTH: usize = 32; - -/// Default maximum number of concurrent block lookups held by lookup sync. Matches the historical -/// `MAX_LOOKUPS` used by block lookup sync. -pub const DEFAULT_LOOKUP_SYNC_MAX_LOOKUPS: usize = 200; - pub struct GossipsubConfigParams { pub message_domain_valid_snappy: [u8; 4], pub gossipsub_max_transmit_size: usize, @@ -155,15 +147,9 @@ pub struct Config { /// Whether to enable partial data column support. pub enable_partial_columns: bool, - /// Route every peer with an unknown head straight to block (lookup) sync instead of using the - /// range/lookup split based on how far ahead the peer is. Approximates "tree sync". - pub tree_sync: bool, - - /// Maximum depth of the parent chain that lookup sync will search before forcing range sync. - pub lookup_sync_max_parent_depth: usize, - - /// Maximum number of concurrent block lookups held by lookup sync. - pub lookup_sync_max_lookups: usize, + /// Disable range sync and route every peer with an unknown head straight to block (lookup) + /// sync, regardless of how far ahead the peer is. Experimental. + pub disable_range_sync: bool, } impl Config { @@ -390,9 +376,7 @@ impl Default for Config { idontwant_message_size_threshold: DEFAULT_IDONTWANT_MESSAGE_SIZE_THRESHOLD, advertise_false_custody_group_count: None, enable_partial_columns: false, - tree_sync: false, - lookup_sync_max_parent_depth: DEFAULT_LOOKUP_SYNC_MAX_PARENT_DEPTH, - lookup_sync_max_lookups: DEFAULT_LOOKUP_SYNC_MAX_LOOKUPS, + disable_range_sync: false, } } } diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index 93a7fc9ab16..249e53b8b2d 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -70,12 +70,11 @@ const LOOKUP_MAX_DURATION_STUCK_SECS: u64 = 15 * PARENT_DEPTH_TOLERANCE as u64; /// lookup at most after 4 seconds, the lookup should gain peers. const LOOKUP_MAX_DURATION_NO_PEERS_SECS: u64 = 10; -// Lookups contain untrusted data, including blocks that have not yet been validated. In case of -// bugs or malicious activity we want to bound how much memory these lookups can consume. Aprox the -// max size of a lookup is ~ 10 MB (current max size of gossip and RPC blocks). 200 lookups can -// take at most 2 GB. 200 lookups allow 3 parallel chains of depth 64 (current maximum). The bound -// is `BlockLookups::max_lookups`, defaulting to `DEFAULT_LOOKUP_SYNC_MAX_LOOKUPS` and overridable -// via the `--lookup-sync-max-lookups` CLI flag. +/// Lookups contain untrusted data, including blocks that have not yet been validated. In case of +/// bugs or malicious activity we want to bound how much memory these lookups can consume. Aprox the +/// max size of a lookup is ~ 10 MB (current max size of gossip and RPC blocks). 200 lookups can +/// take at most 2 GB. 200 lookups allow 3 parallel chains of depth 64 (current maximum). +const MAX_LOOKUPS: usize = 200; type BlockDownloadResponse = Result>>, RpcResponseError>; type CustodyDownloadResponse = @@ -99,12 +98,12 @@ pub struct BlockLookups { // TODO: Why not index lookups by block_root? single_block_lookups: FnvHashMap>, - /// Maximum depth of the parent chain searched before forcing range sync. Defaults to - /// `PARENT_DEPTH_TOLERANCE`, overridable via the `--lookup-sync-max-parent-depth` CLI flag. + /// Maximum depth of the parent chain searched before forcing range sync. `PARENT_DEPTH_TOLERANCE` + /// normally, unbounded when range sync is disabled (`--disable-range-sync`). max_parent_depth: usize, - /// Maximum number of concurrent block lookups. Defaults to `MAX_LOOKUPS`, overridable via the - /// `--lookup-sync-max-lookups` CLI flag. + /// Maximum number of concurrent block lookups. `MAX_LOOKUPS` normally, unbounded when range sync + /// is disabled (`--disable-range-sync`). max_lookups: usize, /// Used for testing assertions @@ -126,7 +125,15 @@ pub(crate) struct BlockLookupSummary { } impl BlockLookups { - pub fn new(max_parent_depth: usize, max_lookups: usize) -> Self { + pub fn new(disable_range_sync: bool) -> Self { + // Range sync is the fallback when a parent chain grows too long or too many lookups pile + // up. With range sync disabled there is no fallback, so the caps are removed (unbounded) + // and lookup sync follows the full tree of lookups without dropping chains. + let (max_parent_depth, max_lookups) = if disable_range_sync { + (usize::MAX, usize::MAX) + } else { + (PARENT_DEPTH_TOLERANCE, MAX_LOOKUPS) + }; Self { ignored_chains: LRUTimeCache::new(Duration::from_secs( IGNORED_CHAINS_CACHE_EXPIRY_SECONDS, diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 006002bfcf6..872f3ed052c 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -309,17 +309,7 @@ impl SyncManager { fork_context: Arc, ) -> Self { let network_globals = beacon_processor.network_globals.clone(); - // Tree sync follows the full tree of lookups without dropping chains, so the depth and - // lookup-count caps are removed (unbounded) when it is enabled. - let (lookup_sync_max_parent_depth, lookup_sync_max_lookups) = - if network_globals.config.tree_sync { - (usize::MAX, usize::MAX) - } else { - ( - network_globals.config.lookup_sync_max_parent_depth, - network_globals.config.lookup_sync_max_lookups, - ) - }; + let disable_range_sync = network_globals.config.disable_range_sync; Self { chain: beacon_chain.clone(), input_channel: sync_recv, @@ -332,7 +322,7 @@ impl SyncManager { range_sync: RangeSync::new(beacon_chain.clone()), backfill_sync: BackFillSync::new(beacon_chain.clone(), network_globals.clone()), custody_backfill_sync: CustodyBackFillSync::new(beacon_chain.clone(), network_globals), - block_lookups: BlockLookups::new(lookup_sync_max_parent_depth, lookup_sync_max_lookups), + block_lookups: BlockLookups::new(disable_range_sync), notified_unknown_roots: LRUTimeCache::new(Duration::from_secs( NOTIFIED_UNKNOWN_ROOT_EXPIRY_SECONDS, )), @@ -420,10 +410,9 @@ impl SyncManager { // update the state of the peer. let is_still_connected = self.update_peer_sync_state(&peer_id, &local, &remote, &sync_type); if is_still_connected { - if self.network_globals().config.tree_sync { - // Tree sync: direct every peer with an unknown head to block (lookup) sync - // regardless of how far ahead it is. If the unknown head turns out to be on a - // longer fork, lookup sync will force range sync once it reaches the max depth. + if self.network_globals().config.disable_range_sync { + // Range sync disabled: direct every peer with an unknown head to block (lookup) + // sync regardless of how far ahead it is. if !self.chain.block_is_known_to_fork_choice(&remote.head_root) { self.handle_unknown_block_root(peer_id, remote.head_root); } @@ -1044,11 +1033,12 @@ impl SyncManager { block_slot: Option, peer_id: &PeerId, ) -> Result<(), &'static str> { - // Tree sync intentionally searches for blocks regardless of how far behind we are; the - // not-synced distance gate would otherwise reject every tree-sync head lookup and stall. - if !self.network_globals().config.tree_sync - && !self.network_globals().sync_state.read().is_synced() - { + // With range sync disabled, lookup sync is the only sync method, so search for blocks + // regardless of how far behind we are; the not-synced distance gate would otherwise reject + // every head lookup and stall. + if self.network_globals().config.disable_range_sync { + // Skip the not-synced distance gate. + } else if !self.network_globals().sync_state.read().is_synced() { let Some(block_slot) = block_slot else { return Err("not synced"); }; diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index 2c7674b3447..253aaf121b9 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -694,33 +694,12 @@ pub fn cli_app() -> Command { .display_order(0) ) .arg( - Arg::new("tree-sync") - .long("tree-sync") + Arg::new("disable-range-sync") + .long("disable-range-sync") .action(ArgAction::SetTrue) .help_heading(FLAG_HEADER) - .help("Direct every peer with an unknown head to block (lookup) sync instead of \ - splitting between range and lookup sync based on how far ahead the peer is. \ - Approximates tree sync. Experimental.") - .hide(true) - .display_order(0) - ) - .arg( - Arg::new("lookup-sync-max-parent-depth") - .long("lookup-sync-max-parent-depth") - .value_name("DEPTH") - .help("Maximum depth of the parent chain that lookup sync will search before \ - forcing range sync. Experimental.") - .action(ArgAction::Set) - .hide(true) - .display_order(0) - ) - .arg( - Arg::new("lookup-sync-max-lookups") - .long("lookup-sync-max-lookups") - .value_name("COUNT") - .help("Maximum number of concurrent block lookups held by lookup sync. \ - Experimental.") - .action(ArgAction::Set) + .help("Disable range sync and route every peer with an unknown head to block \ + (lookup) sync regardless of how far ahead it is. Experimental.") .hide(true) .display_order(0) ) diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index 88c38a7f06b..337bcb13683 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -1503,29 +1503,7 @@ pub fn set_network_config( })?; } - config.tree_sync = cli_args.get_flag("tree-sync"); - - if let Some(lookup_sync_max_parent_depth) = - cli_args.get_one::("lookup-sync-max-parent-depth") - { - config.lookup_sync_max_parent_depth = - lookup_sync_max_parent_depth.parse::().map_err(|_| { - format!( - "Invalid lookup-sync-max-parent-depth value passed: {}", - lookup_sync_max_parent_depth - ) - })?; - } - - if let Some(lookup_sync_max_lookups) = cli_args.get_one::("lookup-sync-max-lookups") { - config.lookup_sync_max_lookups = - lookup_sync_max_lookups.parse::().map_err(|_| { - format!( - "Invalid lookup-sync-max-lookups value passed: {}", - lookup_sync_max_lookups - ) - })?; - } + config.disable_range_sync = cli_args.get_flag("disable-range-sync"); Ok(()) }