diff --git a/beacon_node/beacon_processor/src/lib.rs b/beacon_node/beacon_processor/src/lib.rs index d6233ebaf92..561fa16f039 100644 --- a/beacon_node/beacon_processor/src/lib.rs +++ b/beacon_node/beacon_processor/src/lib.rs @@ -424,6 +424,8 @@ pub enum Work { process_fn: AsyncFn, }, RpcCustodyColumn(AsyncFn), + /// An execution payload envelope fetched via RPC for a single-block lookup. Shares the + /// `rpc_blob_queue` for scheduling (similar latency/priority profile). RpcEnvelope(AsyncFn), ColumnReconstruction(AsyncFn), IgnoredRpcBlock { diff --git a/beacon_node/network/src/network_beacon_processor/sync_methods.rs b/beacon_node/network/src/network_beacon_processor/sync_methods.rs index 87d11946bd5..f6396e7e06e 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -1000,7 +1000,7 @@ impl From> for BlockProcessingR return Self::Imported(true, "duplicate"); } BlockError::GenesisBlock => return Self::Imported(true, "genesis"), - BlockError::ParentUnknown { parent_root, .. } => { + BlockError::ParentUnknown { parent_root } => { return Self::ParentUnknown { parent_root: *parent_root, }; diff --git a/beacon_node/network/src/sync/backfill_sync/mod.rs b/beacon_node/network/src/sync/backfill_sync/mod.rs index f3dab7f3954..657aff9662f 100644 --- a/beacon_node/network/src/sync/backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/backfill_sync/mod.rs @@ -1227,6 +1227,14 @@ mod tests { #[test] fn request_batches_should_not_loop_infinitely() { + // Backfill sync doesn't yet support Gloas (the harness can't build a Gloas interop genesis + // here); skip under a Gloas genesis. TODO(gloas): support backfill sync. + if beacon_chain::test_utils::test_spec::() + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } let harness = BeaconChainHarness::builder(MinimalEthSpec) .default_spec() .deterministic_keypairs(4) diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index a265373e3fc..15b55947475 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -22,7 +22,7 @@ use self::parent_chain::{NodeChain, compute_parent_chains}; pub use self::single_block_lookup::DownloadResult; -use self::single_block_lookup::{LookupRequestError, LookupResult, SingleBlockLookup}; +use self::single_block_lookup::{LookupRequestError, LookupResult, PeerType, SingleBlockLookup}; use super::manager::{BlockProcessType, SLOT_IMPORT_TOLERANCE}; use super::network_context::{RpcResponseError, SyncNetworkContext}; use crate::metrics; @@ -39,7 +39,10 @@ use std::sync::Arc; use std::time::Duration; use store::Hash256; use tracing::{debug, error, warn}; -use types::{DataColumnSidecarList, EthSpec, SignedBeaconBlock}; +use types::{ + DataColumnSidecarList, EthSpec, ExecutionBlockHash, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, +}; pub mod parent_chain; mod single_block_lookup; @@ -73,6 +76,8 @@ const MAX_LOOKUPS: usize = 200; type BlockDownloadResponse = Result>>, RpcResponseError>; type CustodyDownloadResponse = Result>, RpcResponseError>; +type PayloadDownloadResponse = + Result>>, RpcResponseError>; pub enum BlockComponent { Block(DownloadResult>>), @@ -169,11 +174,12 @@ impl BlockLookups { block_root: Hash256, block_component: BlockComponent, parent_root: Hash256, + parent_block_hash: Option, peer_id: PeerId, cx: &mut SyncNetworkContext, ) -> bool { let parent_lookup_exists = - self.search_parent_of_child(parent_root, block_root, &[peer_id], cx); + self.search_parent_of_child(parent_root, parent_block_hash, block_root, &[peer_id], cx); // Only create the child lookup if the parent exists if parent_lookup_exists { // `search_parent_of_child` ensures that the parent lookup exists so we can safely wait for it @@ -183,8 +189,11 @@ impl BlockLookups { Some(parent_root), // On a `UnknownParentBlock` or `UnknownParentSidecarHeader` event the peer is not // required to have the rest of the block components. Create the lookup with zero - // peers to house the block components. + // peers to house the block components. We don't know the child's fork yet, so use + // `PreGloas` conservatively; the correct peer set is established when the child's + // block downloads and its FULL children begin attesting. &[], + &PeerType::PreGloas, cx, ) } else { @@ -202,7 +211,7 @@ impl BlockLookups { peer_source: &[PeerId], cx: &mut SyncNetworkContext, ) -> bool { - self.new_current_lookup(block_root, None, None, peer_source, cx) + self.new_current_lookup(block_root, None, None, peer_source, &PeerType::PreGloas, cx) } /// A block or blob triggers the search of a parent. @@ -215,10 +224,17 @@ impl BlockLookups { pub fn search_parent_of_child( &mut self, block_root_to_search: Hash256, + // Post-Gloas only: the child's bid `parent_block_hash` (the parent's execution hash). Peers + // that imported the FULL child can serve the parent's payload envelope and data columns. + parent_block_hash: Option, child_block_root_trigger: Hash256, peers: &[PeerId], cx: &mut SyncNetworkContext, ) -> bool { + let peer_type = match parent_block_hash { + Some(execution_hash) => PeerType::PostGloas(execution_hash), + None => PeerType::PreGloas, + }; let parent_chains = self.active_parent_lookups(); for (chain_idx, parent_chain) in parent_chains.iter().enumerate() { @@ -307,7 +323,7 @@ impl BlockLookups { } // `block_root_to_search` is a failed chain check happens inside new_current_lookup - self.new_current_lookup(block_root_to_search, None, None, peers, cx) + self.new_current_lookup(block_root_to_search, None, None, peers, &peer_type, cx) } /// Searches for a single block hash. If the blocks parent is unknown, a chain of blocks is @@ -320,6 +336,7 @@ impl BlockLookups { block_component: Option>, awaiting_parent: Option, peers: &[PeerId], + peer_type: &PeerType, cx: &mut SyncNetworkContext, ) -> bool { // If this block or it's parent is part of a known ignored chain, ignore it. @@ -341,7 +358,8 @@ impl BlockLookups { } } - if let Err(e) = self.add_peers_to_lookup_and_ancestors(lookup_id, peers, cx) { + if let Err(e) = self.add_peers_to_lookup_and_ancestors(lookup_id, peers, peer_type, cx) + { warn!(error = ?e, "Error adding peers to ancestor lookup"); } @@ -368,7 +386,8 @@ impl BlockLookups { // If we know that this lookup has unknown parent (is awaiting a parent lookup to resolve), // signal here to hold processing downloaded data. - let mut lookup = SingleBlockLookup::new(block_root, peers, cx.next_id(), awaiting_parent); + let mut lookup = + SingleBlockLookup::new(block_root, peers, peer_type, cx.next_id(), awaiting_parent); let _guard = lookup.span.clone().entered(); // Add block components to the new request @@ -438,6 +457,23 @@ impl BlockLookups { self.on_lookup_result(id.lookup_id, result, "custody_download_response", cx); } + pub fn on_payload_download_response( + &mut self, + id: SingleLookupReqId, + response: PayloadDownloadResponse, + cx: &mut SyncNetworkContext, + ) { + let Some(lookup) = self.single_block_lookups.get_mut(&id.lookup_id) else { + debug!( + ?id, + "Payload envelope returned for single block lookup not present" + ); + return; + }; + let result = lookup.on_payload_download_response(id.req_id, response, cx); + self.on_lookup_result(id.lookup_id, result, "payload_download_response", cx); + } + /* Error responses */ pub fn peer_disconnected(&mut self, peer_id: &PeerId) { @@ -476,8 +512,9 @@ impl BlockLookups { BlockProcessType::SingleCustodyColumn(_) => { lookup.on_data_processing_result(result, cx) } - // TODO(gloas): route into the payload envelope lookup state machine. - BlockProcessType::SinglePayloadEnvelope(_) => Ok(LookupResult::Pending), + BlockProcessType::SinglePayloadEnvelope(_) => { + lookup.on_payload_processing_result(result, cx) + } }; self.on_lookup_result(lookup_id, lookup_result, "processing_result", cx); } @@ -579,10 +616,17 @@ impl BlockLookups { Ok(LookupResult::Pending) => true, Ok(LookupResult::ParentUnknown { parent_root, + parent_block_hash, block_root, peers, }) => { - if self.search_parent_of_child(parent_root, block_root, &peers, cx) { + if self.search_parent_of_child( + parent_root, + parent_block_hash, + block_root, + &peers, + cx, + ) { true } else { self.drop_lookup_and_children(id, "Failed"); @@ -762,6 +806,7 @@ impl BlockLookups { &mut self, lookup_id: SingleLookupId, peers: &[PeerId], + peer_type: &PeerType, cx: &mut SyncNetworkContext, ) -> Result<(), String> { let lookup = self @@ -771,7 +816,7 @@ impl BlockLookups { let mut added_some_peer = false; for peer in peers { - if lookup.add_peer(*peer) { + if lookup.add_peer(*peer, peer_type) { added_some_peer = true; debug!( block_root = ?lookup.block_root(), @@ -782,12 +827,16 @@ impl BlockLookups { } if let Some(parent_root) = lookup.awaiting_parent() { + // When recursing from child to parent, the parent's peer set is keyed by the child's + // bid `parent_block_hash` (post-Gloas). A peer that imported this FULL child holds the + // parent's payload + columns. + let parent_peer_type = lookup.awaiting_parent_peer_type(); if let Some((&parent_id, _)) = self .single_block_lookups .iter() .find(|(_, l)| l.block_root() == parent_root) { - self.add_peers_to_lookup_and_ancestors(parent_id, peers, cx) + self.add_peers_to_lookup_and_ancestors(parent_id, peers, &parent_peer_type, cx) } else { Err(format!("Lookup references unknown parent {parent_root:?}")) } diff --git a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs index 8eb58da4e6e..fef6d6b2b24 100644 --- a/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs +++ b/beacon_node/network/src/sync/block_lookups/single_block_lookup.rs @@ -1,6 +1,8 @@ use super::{BlockComponent, PeerId, SINGLE_BLOCK_LOOKUP_MAX_ATTEMPTS}; use crate::network_beacon_processor::BlockProcessingResult; -use crate::sync::block_lookups::{BlockDownloadResponse, CustodyDownloadResponse}; +use crate::sync::block_lookups::{ + BlockDownloadResponse, CustodyDownloadResponse, PayloadDownloadResponse, +}; use crate::sync::manager::BlockProcessType; use crate::sync::network_context::{ LookupRequestResult, PeerGroup, ReqId, RpcRequestSendError, RpcResponseError, @@ -11,13 +13,16 @@ use beacon_chain::block_verification_types::AsBlock; use educe::Educe; use lighthouse_network::service::api_types::Id; use parking_lot::RwLock; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Duration, Instant}; use store::Hash256; use strum::IntoStaticStr; use tracing::{Span, debug_span}; -use types::{DataColumnSidecarList, EthSpec, SignedBeaconBlock, Slot}; +use types::{ + DataColumnSidecarList, EthSpec, ExecutionBlockHash, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, Slot, +}; // Dedicated enum for LookupResult to force its usage #[must_use = "LookupResult must be handled with on_lookup_result"] @@ -29,6 +34,9 @@ pub enum LookupResult { /// Block's parent is not known to fork-choice, a parent lookup is needed ParentUnknown { parent_root: Hash256, + /// Post-Gloas only: the child's bid `parent_block_hash`. Lets the parent lookup partition + /// peers (a peer that imported this FULL child holds the parent's payload + columns). + parent_block_hash: Option, block_root: Hash256, peers: Vec, }, @@ -57,6 +65,12 @@ pub enum LookupRequestError { }, } +type PeerSet = Arc>>; +/// Peers that claim to have imported a FULL child of this lookup's block, keyed by the child's bid +/// `parent_block_hash` (which equals this block's bid `block_hash` when the child is FULL). Only +/// such peers are proven to hold this block's execution payload envelope and its data columns. +type GloasChildPeers = Arc>>; + #[derive(Debug)] struct BlockRequest { state: SingleLookupRequestState>>, @@ -79,6 +93,9 @@ enum DataRequest { WaitingForBlock, Request { slot: Slot, + /// Peers to fetch the data columns from. Pre-Gloas this is the lookup's `peers`; for FULL + /// Gloas blocks this is the `gloas_child_peers` set proven to hold the columns. + peers: PeerSet, state: SingleLookupRequestState>, }, NoData, @@ -94,7 +111,51 @@ impl DataRequest { } } -type PeerSet = Arc>>; +/// Tracks the download + processing of a Gloas execution payload envelope. For FULL Gloas blocks the +/// execution payload arrives as a separate `SignedExecutionPayloadEnvelope`, mirroring the way data +/// columns are fetched and processed by `DataRequest`. +#[derive(Debug)] +enum PayloadRequest { + /// Block not yet downloaded, can't tell if a payload is needed. + WaitingForBlock, + /// Post-Gloas block: an execution payload envelope must be fetched and processed *if* the block + /// is FULL. We can't tell FULL from EMPTY from the block alone: only a FULL child of this block + /// proves a payload was published, which is signalled by `peers` becoming non-empty. While + /// `peers` is empty the block is assumed EMPTY and this request is considered complete. + Request { + peers: PeerSet, + state: SingleLookupRequestState>>, + }, + /// Pre-Gloas block: no payload envelope exists, nothing to fetch. + PreGloas, +} + +impl PayloadRequest { + fn is_complete(&self) -> bool { + match &self { + PayloadRequest::WaitingForBlock => false, + PayloadRequest::Request { peers, state } => { + // EMPTY Gloas block: no FULL child has proven a payload exists, so there is nothing + // to fetch and the request never made it past `AwaitingDownload`. + if !state.is_awaiting_event() && peers.read().is_empty() { + return true; + } + state.is_processed() + } + PayloadRequest::PreGloas => true, + } + } +} + +/// Classifies how a peer relates to a lookup, controlling which peer set it is added to. +pub enum PeerType { + /// Pre-Gloas: the peer can serve the block and its data columns. + PreGloas, + /// Post-Gloas: the peer claims to have imported a child of this block whose bid references + /// `ExecutionBlockHash` as its parent. Such peers can serve this block's payload envelope and + /// data columns (only if this block is FULL). + PostGloas(ExecutionBlockHash), +} #[derive(Educe)] #[educe(Debug(bound(T: BeaconChainTypes)))] @@ -103,12 +164,18 @@ pub struct SingleBlockLookup { block_root: Hash256, block_request: BlockRequest, data_request: DataRequest, + payload_request: PayloadRequest, /// Peers that claim to have imported this set of block components. This state is shared with /// the custody request to have an updated view of the peers that claim to have imported the /// block associated with this lookup. The peer set of a lookup can change rapidly, and faster /// than the lifetime of a custody request. #[educe(Debug(method(fmt_peer_set_as_len)))] peers: PeerSet, + /// Post-Gloas only: peers that claim to have imported a FULL child of this block, keyed by the + /// child's bid `parent_block_hash`. These (not `peers`) are the peers proven to hold this + /// block's payload envelope and data columns. + #[educe(Debug(method(fmt_peer_map_as_len)))] + gloas_child_peers: GloasChildPeers, awaiting_parent: Option, created: Instant, pub(crate) span: Span, @@ -118,6 +185,7 @@ impl SingleBlockLookup { pub fn new( requested_block_root: Hash256, peers: &[PeerId], + peer_type: &PeerType, id: Id, awaiting_parent: Option, ) -> Self { @@ -127,12 +195,23 @@ impl SingleBlockLookup { id = id, ); + let block_peers: PeerSet = Arc::new(RwLock::new(peers.iter().copied().collect())); + let mut gloas_child_peers = HashMap::new(); + match peer_type { + PeerType::PreGloas => {} + PeerType::PostGloas(execution_hash) => { + gloas_child_peers.insert(*execution_hash, block_peers.clone()); + } + } + Self { id, block_root: requested_block_root, block_request: BlockRequest::new(), data_request: DataRequest::WaitingForBlock, - peers: Arc::new(RwLock::new(peers.iter().copied().collect())), + payload_request: PayloadRequest::WaitingForBlock, + peers: block_peers, + gloas_child_peers: Arc::new(RwLock::new(gloas_child_peers)), awaiting_parent, created: Instant::now(), span: lookup_span, @@ -143,6 +222,7 @@ impl SingleBlockLookup { pub fn reset_requests(&mut self) { self.block_request = BlockRequest::new(); self.data_request = DataRequest::WaitingForBlock; + self.payload_request = PayloadRequest::WaitingForBlock; } /// Return the slot of this lookup's block if it's currently cached @@ -165,7 +245,7 @@ impl SingleBlockLookup { /// Mark this lookup as awaiting a parent lookup from being processed. Meanwhile don't send /// components for processing. pub fn set_awaiting_parent(&mut self, parent_root: Hash256) { - self.awaiting_parent = Some(parent_root) + self.awaiting_parent = Some(parent_root); } /// Mark this lookup as no longer awaiting a parent lookup. Components can be sent for @@ -174,6 +254,23 @@ impl SingleBlockLookup { self.awaiting_parent = None; } + /// This block's bid `parent_block_hash` (the parent's execution hash), derived from the + /// downloaded block. Post-Gloas only; `None` pre-Gloas or before the block is downloaded. + fn bid_parent_block_hash(&self) -> Option { + self.block_request + .state + .peek_downloaded_data() + .and_then(|block| block.parent_block_hash()) + } + + /// Returns the `PeerType` to use when propagating this lookup's peers up to its parent lookup. + pub fn awaiting_parent_peer_type(&self) -> PeerType { + match self.bid_parent_block_hash() { + Some(execution_hash) => PeerType::PostGloas(execution_hash), + None => PeerType::PreGloas, + } + } + /// Returns the time elapsed since this lookup was created pub fn elapsed_since_created(&self) -> Duration { self.created.elapsed() @@ -207,6 +304,11 @@ impl SingleBlockLookup { DataRequest::Request { state, .. } => state.is_awaiting_event(), DataRequest::NoData => false, } + || match &self.payload_request { + PayloadRequest::WaitingForBlock => true, + PayloadRequest::Request { state, .. } => state.is_awaiting_event(), + PayloadRequest::PreGloas => false, + } } /// Makes progress on all requests of this lookup. Any error is not recoverable and must result @@ -241,6 +343,7 @@ impl SingleBlockLookup { } else if cx.chain.should_fetch_custody_columns(block_epoch) { DataRequest::Request { slot: block.slot(), + peers: self.get_data_peers(block), state: SingleLookupRequestState::new(), } } else { @@ -250,14 +353,9 @@ impl SingleBlockLookup { break; } } - DataRequest::Request { slot, state } => { + DataRequest::Request { slot, peers, state } => { state.maybe_start_downloading(|| { - cx.custody_lookup_request( - self.id, - self.block_root, - *slot, - self.peers.clone(), - ) + cx.custody_lookup_request(self.id, self.block_root, *slot, peers.clone()) })?; // Wait for the parent to be imported, data column processing result handle does // not support `ParentUnknown`. @@ -279,16 +377,79 @@ impl SingleBlockLookup { } } + // === Payload request (Gloas only) === + loop { + match &mut self.payload_request { + PayloadRequest::WaitingForBlock => { + if let Some(block) = self.block_request.state.peek_downloaded_data() { + self.payload_request = if block.fork_name_unchecked().gloas_enabled() { + PayloadRequest::Request { + peers: self.get_data_peers(block), + state: SingleLookupRequestState::new(), + } + } else { + PayloadRequest::PreGloas + }; + } else { + break; + } + } + PayloadRequest::Request { peers, state } => { + state.maybe_start_downloading(|| { + cx.payload_lookup_request(self.id, peers.clone(), self.block_root) + })?; + // The envelope can only be verified once the block itself is imported; + // otherwise processing returns `BlockRootUnknown` and the lookup burns retries + // until `TooManyAttempts` while the block is parked awaiting its parent. + if self.block_request.state.is_processed() + && let Some(data) = state.maybe_start_processing() + { + cx.send_payload_for_processing( + self.block_root, + data.value, + data.seen_timestamp, + BlockProcessType::SinglePayloadEnvelope(self.id), + ) + .map_err(LookupRequestError::SendFailedProcessor)?; + } + break; + } + PayloadRequest::PreGloas => break, + } + } + // If all components of this lookup are already processed, there will be no future events // that can make progress so it must be dropped. Consider the lookup completed. // This case can happen if we receive the components from gossip during a retry. - if self.block_request.is_complete() && self.data_request.is_complete() { + if self.block_request.is_complete() + && self.data_request.is_complete() + && self.payload_request.is_complete() + { return Ok(LookupResult::Completed); } Ok(LookupResult::Pending) } + /// Returns the peers that should serve this block's data columns and payload envelope. For FULL + /// Gloas blocks these are the peers that claimed to have imported a FULL child of this block + /// (keyed by this block's bid `block_hash`). Pre-Gloas blocks carry no bid, so this returns the + /// lookup's `peers` unchanged. + fn get_data_peers(&self, block: &SignedBeaconBlock) -> PeerSet { + match block.payload_bid_block_hash() { + // Gloas: the child-attested peer set for this bid is the canonical peer set. DO NOT + // default to `self.peers`: post-Gloas `self.peers` have not claimed to import this + // block's data nor its payload. This set may remain empty until a FULL child arrives. + Ok(block_hash) => self + .gloas_child_peers + .write() + .entry(block_hash) + .or_default() + .clone(), + Err(_) => self.peers.clone(), + } + } + /// Handle block processing result. Advances the lookup state machine. pub fn on_block_processing_result( &mut self, @@ -304,10 +465,12 @@ impl SingleBlockLookup { // block request to `Downloaded` and park this lookup until the parent resolves; a // future call to `continue_requests` will re-submit the block for processing once // the parent lookup completes. + let parent_block_hash = self.bid_parent_block_hash(); self.block_request.state.revert_to_awaiting_processing()?; self.set_awaiting_parent(parent_root); return Ok(LookupResult::ParentUnknown { parent_root, + parent_block_hash, block_root: self.block_root, peers: self.all_peers(), }); @@ -379,6 +542,54 @@ impl SingleBlockLookup { self.continue_requests(cx) } + /// Handle payload envelope processing result (Gloas only). + pub fn on_payload_processing_result( + &mut self, + result: BlockProcessingResult, + cx: &mut SyncNetworkContext, + ) -> Result { + let PayloadRequest::Request { state, .. } = &mut self.payload_request else { + return Err(LookupRequestError::BadState( + "no payload_request".to_owned(), + )); + }; + + match result { + BlockProcessingResult::Imported(_fully_imported, _info) => { + state.on_processing_success()?; + } + BlockProcessingResult::ParentUnknown { .. } => { + return Err(LookupRequestError::BadState( + "payload processing returned ParentUnknown".to_owned(), + )); + } + BlockProcessingResult::Error { penalty, .. } => { + let peers = state.on_processing_failure()?; + if let Some((action, whom, msg)) = penalty { + whom.apply(action, &peers, msg, cx); + } + } + } + self.continue_requests(cx) + } + + /// Handle a payload envelope download response. Updates download state and advances the lookup. + pub fn on_payload_download_response( + &mut self, + req_id: ReqId, + result: PayloadDownloadResponse, + cx: &mut SyncNetworkContext, + ) -> Result { + let PayloadRequest::Request { state, .. } = &mut self.payload_request else { + return Err(LookupRequestError::BadState( + "no payload_request".to_owned(), + )); + }; + + state.on_download_response(req_id, result)?; + self.continue_requests(cx) + } + /// Get all unique peers that claim to have imported this set of block components pub fn all_peers(&self) -> Vec { self.peers.read().iter().copied().collect() @@ -386,18 +597,44 @@ impl SingleBlockLookup { /// Add peer to all request states. The peer must be able to serve this request. /// Returns true if the peer was newly inserted into any peer set. - pub fn add_peer(&mut self, peer_id: PeerId) -> bool { - self.peers.write().insert(peer_id) + pub fn add_peer(&mut self, peer_id: PeerId, peer_type: &PeerType) -> bool { + let mut added = false; + match peer_type { + PeerType::PostGloas(execution_hash) => { + // This peer claims to have imported a FULL child of this block whose bid references + // `execution_hash` as its parent. It is therefore proven to hold this block's + // payload envelope and data columns. + added |= self + .gloas_child_peers + .write() + .entry(*execution_hash) + .or_default() + .write() + .insert(peer_id); + } + PeerType::PreGloas => {} + } + // Always add to the main block peers, they can at least serve the block. + added |= self.peers.write().insert(peer_id); + added } /// Remove peer from available peers. pub fn remove_peer(&mut self, peer_id: &PeerId) { self.peers.write().remove(peer_id); + for set in self.gloas_child_peers.write().values_mut() { + set.write().remove(peer_id); + } } /// Returns true if this lookup has zero peers pub fn has_no_peers(&self) -> bool { self.peers.read().is_empty() + && self + .gloas_child_peers + .read() + .values() + .all(|set| set.read().is_empty()) } } @@ -708,3 +945,15 @@ fn fmt_peer_set_as_len( ) -> Result<(), std::fmt::Error> { write!(f, "{}", peer_set.read().len()) } + +fn fmt_peer_map_as_len( + peer_map: &GloasChildPeers, + f: &mut std::fmt::Formatter, +) -> Result<(), std::fmt::Error> { + let total = peer_map + .read() + .values() + .map(|set| set.read().len()) + .sum::(); + write!(f, "{}", total) +} diff --git a/beacon_node/network/src/sync/block_sidecar_coupling.rs b/beacon_node/network/src/sync/block_sidecar_coupling.rs index 5ec45c8fea6..999b3dd30eb 100644 --- a/beacon_node/network/src/sync/block_sidecar_coupling.rs +++ b/beacon_node/network/src/sync/block_sidecar_coupling.rs @@ -515,6 +515,15 @@ mod tests { } } + /// The custody-column coupling tests below build Fulu data-column sidecars directly, which is + /// incompatible with a Gloas genesis (Gloas columns have a different structure). Skip them when + /// `FORK_NAME` schedules Gloas at genesis. TODO(gloas): port the harness to build Gloas columns. + fn skip_under_gloas() -> bool { + test_spec::() + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + } + fn blocks_id(parent_request_id: ComponentsByRangeRequestId) -> BlocksByRangeRequestId { BlocksByRangeRequestId { id: 1, @@ -619,6 +628,9 @@ mod tests { #[test] fn rpc_block_with_custody_columns() { + if skip_under_gloas() { + return; + } let mut spec = test_spec::(); spec.deneb_fork_epoch = Some(Epoch::new(0)); spec.fulu_fork_epoch = Some(Epoch::new(0)); @@ -697,6 +709,9 @@ mod tests { #[test] fn rpc_block_with_custody_columns_batched() { + if skip_under_gloas() { + return; + } let mut spec = test_spec::(); spec.deneb_fork_epoch = Some(Epoch::new(0)); spec.fulu_fork_epoch = Some(Epoch::new(0)); @@ -791,6 +806,9 @@ mod tests { #[test] fn missing_custody_columns_from_faulty_peers() { + if skip_under_gloas() { + return; + } // GIVEN: A request expecting sampling columns from multiple peers let spec = Arc::new(test_spec::()); let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); @@ -886,6 +904,9 @@ mod tests { #[test] fn retry_logic_after_peer_failures() { + if skip_under_gloas() { + return; + } // GIVEN: A request expecting sampling columns where some peers initially fail let mut spec = test_spec::(); spec.deneb_fork_epoch = Some(Epoch::new(0)); @@ -1002,6 +1023,9 @@ mod tests { #[test] fn max_retries_exceeded_behavior() { + if skip_under_gloas() { + return; + } // GIVEN: A request where peers consistently fail to provide required columns let mut spec = test_spec::(); spec.deneb_fork_epoch = Some(Epoch::new(0)); diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 166c65b6e1a..04c8980bd68 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -71,8 +71,8 @@ use strum::IntoStaticStr; use tokio::sync::mpsc; use tracing::{debug, error, info, trace}; use types::{ - BlobSidecar, DataColumnSidecar, EthSpec, ForkContext, Hash256, SignedBeaconBlock, - SignedExecutionPayloadEnvelope, Slot, + BlobSidecar, DataColumnSidecar, EthSpec, ExecutionBlockHash, ForkContext, Hash256, + SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, }; /// The number of slots ahead of us that is allowed before requesting a long-range (batch) Sync @@ -857,11 +857,15 @@ impl SyncManager { SyncMessage::UnknownParentBlock(peer_id, block, block_root) => { let block_slot = block.slot(); let parent_root = block.parent_root(); + // Post-Gloas: the child's bid `parent_block_hash` lets the parent lookup partition + // peers and know it's FULL. + let parent_block_hash = block.payload_bid_parent_block_hash().ok(); debug!(%block_root, %parent_root, "Received unknown parent block message"); self.handle_unknown_parent( peer_id, block_root, parent_root, + parent_block_hash, block_slot, BlockComponent::Block(DownloadResult { value: block.block_cloned(), @@ -881,6 +885,9 @@ impl SyncManager { peer_id, block_root, parent_root, + // No block downloaded yet, so the bid hash is unknown. The correct peer set is + // established once the child's block downloads. + None, slot, BlockComponent::Sidecar, ); @@ -964,6 +971,7 @@ impl SyncManager { peer_id: PeerId, block_root: Hash256, parent_root: Hash256, + parent_block_hash: Option, slot: Slot, block_component: BlockComponent, ) { @@ -973,6 +981,7 @@ impl SyncManager { block_root, block_component, parent_root, + parent_block_hash, peer_id, &mut self.network, ) { @@ -1152,7 +1161,6 @@ impl SyncManager { } } - // TODO(gloas): dispatch into block_lookups once the envelope lookup state machine lands. fn rpc_payload_envelope_received( &mut self, sync_request_id: SyncRequestId, @@ -1207,13 +1215,17 @@ impl SyncManager { peer_id: PeerId, envelope: RpcEvent>>, ) { - if let Some(_resp) = self + if let Some(resp) = self .network .on_single_payload_envelope_response(id, peer_id, envelope) { - // TODO(gloas): dispatch into - // `block_lookups.on_download_response::>(...)` once - // the envelope lookup state machine lands. + self.block_lookups.on_payload_download_response( + id, + resp.map(|(value, seen_timestamp)| { + DownloadResult::new(value, PeerGroup::from_single(peer_id), seen_timestamp) + }), + &mut self.network, + ) } } diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index 1e35c0a72f6..6b7de27dba0 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -53,8 +53,9 @@ use task_executor::TaskExecutor; use tokio::sync::mpsc; use tracing::{Span, debug, debug_span, error, warn}; use types::{ - BlobSidecar, BlockImportSource, ColumnIndex, DataColumnSidecar, DataColumnSidecarList, EthSpec, - ForkContext, Hash256, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, + BlobSidecar, BlockImportSource, ChainSpec, ColumnIndex, DataColumnSidecar, + DataColumnSidecarList, EthSpec, ForkContext, Hash256, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, Slot, }; pub mod custody; @@ -98,6 +99,7 @@ pub type CustodyByRootResult = Result>, RpcResponseError>; #[derive(Debug)] +#[allow(private_interfaces)] pub enum RpcResponseError { RpcError(#[allow(dead_code)] RPCError), VerifyError(LookupVerifyError), @@ -310,6 +312,10 @@ impl SyncNetworkContext { } } + pub fn spec(&self) -> &ChainSpec { + &self.chain.spec + } + pub fn send_sync_message(&mut self, sync_message: SyncMessage) { self.network_beacon_processor .send_sync_message(sync_message); @@ -932,19 +938,23 @@ impl SyncNetworkContext { } /// Request a payload envelope for a block root via PayloadEnvelopesByRoot RPC. - #[allow(dead_code)] pub fn payload_lookup_request( &mut self, lookup_id: SingleLookupId, lookup_peers: Arc>>, block_root: Hash256, - ) -> Result, RpcRequestSendError> { + ) -> Result< + LookupRequestResult>>, + RpcRequestSendError, + > { // Skip the download if fork-choice already saw this envelope (e.g. imported via gossip - // before the lookup got here). - if self.chain.envelope_is_known_to_fork_choice(&block_root) { + // before the lookup got here). Return the cached envelope so the request completes. + if self.chain.envelope_is_known_to_fork_choice(&block_root) + && let Ok(Some(envelope)) = self.chain.get_payload_envelope(&block_root) + { return Ok(LookupRequestResult::NoRequestNeeded( "envelope already known to fork-choice", - (), + Arc::new(envelope), )); } @@ -1578,7 +1588,6 @@ impl SyncNetworkContext { }) } - #[allow(dead_code)] pub fn send_payload_for_processing( &self, block_root: Hash256, diff --git a/beacon_node/network/src/sync/network_context/custody.rs b/beacon_node/network/src/sync/network_context/custody.rs index e74b74ec08e..b1a4b52867d 100644 --- a/beacon_node/network/src/sync/network_context/custody.rs +++ b/beacon_node/network/src/sync/network_context/custody.rs @@ -310,11 +310,10 @@ impl ActiveCustodyRequest { // and downscore if data_columns_by_root does not return the expected custody // columns. For the rest of peers, don't downscore if columns are missing. // - // Post-Gloas, blocks and payload envelopes are decoupled. A peer may - // have the block but not yet imported the envelope and data columns. - // Don't enforce max_responses in this case. - lookup_peers.contains(&peer_id) - && !cx.fork_context.current_fork_name().gloas_enabled(), + // Post-Gloas the lookup peer set is the `gloas_child_peers`: peers that imported + // a FULL child, which requires the parent's columns. They provably custody the + // columns, so withholding is penalizable just like pre-Gloas. + lookup_peers.contains(&peer_id), ) .map_err(Error::SendFailed)?; diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index 5642f7846a6..1a0660e1f82 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -38,11 +38,17 @@ use tokio::sync::mpsc; use tracing::info; use types::{ BlobSidecar, BlockImportSource, ColumnIndex, DataColumnSidecar, DataColumnSubnetId, - ForkContext, ForkName, Hash256, MinimalEthSpec as E, SignedBeaconBlock, Slot, + ForkContext, ForkName, Hash256, MinimalEthSpec as E, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, Slot, }; const D: Duration = Duration::new(0, 0); +/// Minimum validator set size usable across every fork this rig runs under. Pre-Gloas +/// tolerates 1; Gloas genesis needs enough validators to populate `proposer_lookahead` +/// via balance-weighted selection — 8 is enough for MinimalEthSpec. +const TEST_RIG_VALIDATOR_COUNT: usize = 8; + /// Configuration for how the test rig should respond to sync requests. /// /// Controls simulated peer behavior during lookup tests, including RPC errors, @@ -59,6 +65,10 @@ pub struct SimulateConfig { return_too_few_data_n_times: usize, return_no_columns_on_indices_n_times: usize, return_no_columns_on_indices: Vec, + /// If set, only omit columns for requests of this block root. Used to scope the withholding to + /// the block under test (e.g. the parent in a Gloas parent/child lookup), so an unrelated + /// lookup's broad-pool custody requests don't consume the omission budget. + return_no_columns_for_block: Option, skip_by_range_routes: bool, // Use a callable fn because BlockProcessingResult does not implement Clone #[educe(Debug(ignore))] @@ -132,6 +142,11 @@ impl SimulateConfig { self } + fn return_no_columns_for_block(mut self, block_root: Hash256) -> Self { + self.return_no_columns_for_block = Some(block_root); + self + } + pub(super) fn return_rpc_error(mut self, error: RPCError) -> Self { self.return_rpc_error = Some(error); self @@ -221,10 +236,11 @@ impl TestRig { Duration::from_secs(12), ); - // Initialise a new beacon chain + // Initialise a new beacon chain. Gloas genesis needs more than 1 validator so the + // `proposer_lookahead` can be populated at the Fulu → Gloas upgrade. let harness = BeaconChainHarness::>::builder(E) .spec(spec.clone()) - .deterministic_keypairs(1) + .deterministic_keypairs(TEST_RIG_VALIDATOR_COUNT) .fresh_ephemeral_store() .mock_execution_layer() .testing_slot_clock(clock.clone()) @@ -304,6 +320,7 @@ impl TestRig { fork_name, network_blocks_by_root: <_>::default(), network_blocks_by_slot: <_>::default(), + network_envelopes_by_root: <_>::default(), penalties: <_>::default(), seen_lookups: <_>::default(), requests: <_>::default(), @@ -428,9 +445,9 @@ impl TestRig { process_fn.await } } - Work::RpcBlobs { process_fn } | Work::RpcCustodyColumn(process_fn) => { - process_fn.await - } + Work::RpcBlobs { process_fn } + | Work::RpcCustodyColumn(process_fn) + | Work::RpcEnvelope(process_fn) => process_fn.await, Work::ChainSegment { process_fn, process_id: (chain_id, batch_epoch), @@ -557,11 +574,14 @@ impl TestRig { } let will_omit_columns = req.data_column_ids.iter().any(|id| { - id.columns.iter().any(|c| { - self.complete_strategy - .return_no_columns_on_indices - .contains(c) - }) + self.complete_strategy + .return_no_columns_for_block + .is_none_or(|root| id.block_root == root) + && id.columns.iter().any(|c| { + self.complete_strategy + .return_no_columns_on_indices + .contains(c) + }) }); let columns_to_omit = if will_omit_columns && self.complete_strategy.return_no_columns_on_indices_n_times > 0 @@ -615,15 +635,36 @@ impl TestRig { .return_wrong_sidecar_for_block_n_times -= 1; let first = columns.first_mut().expect("empty columns"); let column = Arc::make_mut(first); - column - .signed_block_header_mut() - .expect("not fulu") - .message - .body_root = Hash256::ZERO; + // Corrupt the column so its claimed block root no longer matches the request, + // which the by-root verifier rejects with `UnrequestedBlockRoot`. Pre-Gloas + // columns derive their block root from the signed block header; Gloas columns + // carry `beacon_block_root` directly. + match column { + DataColumnSidecar::Fulu(col) => { + col.signed_block_header.message.body_root = Hash256::ZERO; + } + DataColumnSidecar::Gloas(col) => { + col.beacon_block_root = Hash256::ZERO; + } + } } self.send_rpc_columns_response(req_id, peer_id, &columns); } + (RequestType::PayloadEnvelopesByRoot(req), AppRequestId::Sync(req_id)) => { + // The lookup-sync path always requests a single envelope per request, so + // there is exactly one block_root. Serve the cached envelope if the rig + // has one — otherwise respond with an empty stream. + let block_root = req + .beacon_block_roots + .as_slice() + .first() + .copied() + .unwrap_or_else(|| panic!("empty envelope request: {req:?}")); + let envelope = self.network_envelopes_by_root.get(&block_root).cloned(); + self.send_rpc_envelope_response(req_id, peer_id, envelope); + } + (RequestType::BlocksByRange(req), AppRequestId::Sync(req_id)) => { if self.complete_strategy.skip_by_range_routes { return; @@ -883,6 +924,37 @@ impl TestRig { }); } + fn send_rpc_envelope_response( + &mut self, + sync_request_id: SyncRequestId, + peer_id: PeerId, + envelope: Option>>, + ) { + self.log(&format!( + "Completing request {sync_request_id:?} to {peer_id} with envelope {:?}", + envelope.as_ref().map(|e| e.slot()) + )); + + self.push_sync_message(SyncMessage::RpcPayloadEnvelope { + sync_request_id, + peer_id, + envelope: envelope.clone(), + seen_timestamp: D, + }); + // Stream termination + self.push_sync_message(SyncMessage::RpcPayloadEnvelope { + sync_request_id, + peer_id, + envelope: None, + seen_timestamp: D, + }); + } + + #[allow(dead_code)] + fn is_after_gloas(&self) -> bool { + self.fork_name.gloas_enabled() + } + // Preparation steps /// Returns the block root of the tip of the built chain @@ -892,7 +964,7 @@ impl TestRig { // Initialise a new beacon chain let external_harness = BeaconChainHarness::>::builder(E) .spec(self.harness.spec.clone()) - .deterministic_keypairs(1) + .deterministic_keypairs(TEST_RIG_VALIDATOR_COUNT) .fresh_ephemeral_store() .mock_execution_layer() .testing_slot_clock(self.harness.chain.slot_clock.clone()) @@ -927,6 +999,12 @@ impl TestRig { self.network_blocks_by_root .insert(block_root, block.clone()); self.network_blocks_by_slot.insert(block_slot, block); + // Gloas: pull the corresponding execution payload envelope from the external + // harness store so the rig can serve it when the lookup requests it. + if let Ok(Some(envelope)) = external_harness.chain.get_payload_envelope(&block_root) { + self.network_envelopes_by_root + .insert(block_root, Arc::new(envelope)); + } self.log(&format!( "Produced block {} index {i} in external harness", block_slot, @@ -1366,17 +1444,21 @@ impl TestRig { peer_id: PeerId, data_column: Arc>, ) { - let block_root = data_column.block_root(); - let slot = data_column.slot(); - let parent_root = match data_column.as_ref() { - DataColumnSidecar::Fulu(column) => column.block_parent_root(), - DataColumnSidecar::Gloas(_) => panic!("Gloas data column not supported in this test"), + let DataColumnSidecar::Fulu(col) = data_column.as_ref() else { + // Gloas data columns don't carry a parent block root, so the + // `UnknownParentSidecarHeader` trigger doesn't apply post-Gloas. The production + // path drops these with a `warn!` (see `manager.rs` handler). Mirror that here + // so Gloas test paths can call the same helper as Fulu without panicking. + self.log(&format!( + "trigger_unknown_parent_data_column noop (post-Gloas column has no parent root) peer {peer_id:?}" + )); + return; }; self.send_sync_message(SyncMessage::UnknownParentSidecarHeader { peer_id, - block_root, - parent_root, - slot, + block_root: col.block_root(), + parent_root: col.block_parent_root(), + slot: col.slot(), }); } @@ -1854,6 +1936,11 @@ async fn happy_path_unknown_data_parent(depth: usize) { let Some(mut r) = TestRig::new_after_fulu() else { return; }; + // Gloas data columns reference their own block, not a parent, so there is no + // unknown-parent-from-data trigger to exercise. + if r.is_after_gloas() { + return; + } r.build_chain(depth).await; r.trigger_with_last_unknown_data_column_parent(); r.simulate(SimulateConfig::happy_path()).await; @@ -1871,7 +1958,12 @@ async fn happy_path_multiple_triggers(depth: usize) { r.trigger_with_last_block(); r.trigger_with_last_unknown_block_parent(); r.trigger_with_last_unknown_block_parent(); - r.trigger_with_last_unknown_data_column_parent(); + if r.is_after_gloas() { + // Gloas data columns reference their own block, not a parent, so there is no + // unknown-parent-from-data trigger. The block triggers above already exercise dedup. + } else { + r.trigger_with_last_unknown_data_column_parent(); + } r.simulate(SimulateConfig::happy_path()).await; assert_eq!(r.created_lookups(), depth + 1, "Don't create extra lookups"); r.assert_successful_lookup_sync(); @@ -1903,7 +1995,13 @@ async fn bad_peer_empty_data_response(depth: usize) { r.simulate(SimulateConfig::new().return_no_data_once()) .await; // We register a penalty, retry and complete sync successfully - r.assert_penalties(&["NotEnoughResponsesReturned"]); + if !r.is_after_gloas() { + // TODO(gloas): the tip lookup's columns are only attributable to peers that imported a FULL + // child of the tip. The tip has no child here, so its column peer set is empty and the + // withholding peer can't be penalized. This holds at every depth, since the trigger always + // targets the tip. + r.assert_penalties(&["NotEnoughResponsesReturned"]); + } r.assert_successful_lookup_sync(); // TODO(tree-sync) Assert that a single lookup is created (no drops) } @@ -1918,7 +2016,13 @@ async fn bad_peer_too_few_data_response(depth: usize) { r.simulate(SimulateConfig::new().return_too_few_data_once()) .await; // We register a penalty, retry and complete sync successfully - r.assert_penalties(&["NotEnoughResponsesReturned"]); + if !r.is_after_gloas() { + // TODO(gloas): the tip lookup's columns are only attributable to peers that imported a FULL + // child of the tip. The tip has no child here, so its column peer set is empty and the + // withholding peer can't be penalized. This holds at every depth, since the trigger always + // targets the tip. + r.assert_penalties(&["NotEnoughResponsesReturned"]); + } r.assert_successful_lookup_sync(); // TODO(tree-sync) Assert that a single lookup is created (no drops) } @@ -2018,10 +2122,17 @@ async fn unknown_parent_does_not_add_peers_to_itself() { r.build_chain(2).await; r.trigger_with_last_unknown_block_parent(); r.trigger_with_last_unknown_block_parent(); - r.trigger_with_last_unknown_data_column_parent(); + // Gloas data columns reference their own block, not a parent, so there is no + // unknown-parent-from-data trigger — one fewer peer reaches the parent lookup. + let parent_lookup_peers = if r.is_after_gloas() { + 2 + } else { + r.trigger_with_last_unknown_data_column_parent(); + 3 + }; r.simulate(SimulateConfig::happy_path()).await; r.assert_peers_at_lookup_of_slot(2, 0); - r.assert_peers_at_lookup_of_slot(1, 3); + r.assert_peers_at_lookup_of_slot(1, parent_lookup_peers); assert_eq!(r.created_lookups(), 2, "Don't create extra lookups"); // All lookups should NOT complete on this test, however note the following for the tip lookup, // it's the lookup for the tip block which has 0 peers and a block cached: @@ -2061,6 +2172,16 @@ async fn test_single_block_lookup_ignored_response() { /// Assert that if the beacon processor returns DuplicateFullyImported, the lookup completes successfully async fn test_single_block_lookup_duplicate_response() { let mut r = TestRig::default(); + // The `with_process_result` mock only intercepts `Work::RpcBlock` and lets the real + // processing path run for blobs/columns/envelopes. On Gloas the lookup has an extra + // envelope stream; the real envelope processing fails because the block was never + // actually imported (only mock-imported), which produces real lookup retries and + // eventually `TooManyAttempts`. The pre-Gloas semantics of this test ("duplicate + // import => lookup immediately complete") don't carry over without also faking the + // envelope and column processing results, which is out of scope for this test. + if r.is_after_gloas() { + return; + } r.build_chain_and_trigger_last_block(1).await; // Send a DuplicateFullyImported response, the lookup should complete successfully r.simulate( @@ -2125,6 +2246,12 @@ async fn lookups_form_chain() { /// Assert that if a lookup chain (by appending ancestors) is too long we drop it async fn test_parent_lookup_too_deep_grow_ancestor_one() { let mut r = TestRig::default(); + // TODO(gloas): gloas range sync is not yet implemented. It must deliver payload envelopes so + // that FULL blocks can satisfy the parent-payload import gate; without it a FULL chain stalls + // after the first block and the head can't advance. Skip until range sync handles payloads. + if r.is_after_gloas() { + return; + } r.build_chain(PARENT_DEPTH_TOLERANCE + 1).await; r.trigger_with_last_block(); r.simulate(SimulateConfig::happy_path()).await; @@ -2275,6 +2402,12 @@ async fn block_in_da_checker_skips_download() { let Some(mut r) = TestRig::new_after_fulu() else { return; }; + // TODO(gloas): a gloas block also needs its payload envelope to remain in the da_checker as + // missing-components; the harness helper only inserts the block + columns, so the gloas block + // never registers as missing-components. Skip until the helper donates an envelope. + if r.is_after_gloas() { + return; + } // Add block to da_checker // Complete test with happy path // Assert that there were no requests for blocks @@ -2386,14 +2519,34 @@ async fn custody_lookup_some_custody_failures(test_type: FuluTestType) { let Some(mut r) = TestRig::new_fulu_peer_test(test_type) else { return; }; - let block_root = r.build_chain(1).await; - // Send the same trigger from all peers, so that the lookup has all peers - for peer in r.new_connected_peers_for_peerdas() { - r.trigger_unknown_block_from_attestation(block_root, peer); - } + // Gloas: a block's columns are only attributable to peers that imported a FULL child (which + // donate their peers into the parent's custody peer set). Build one level of depth and drive + // the lookup off the FULL child, so the block under test is the parent whose custody peers are + // attributable and penalizable. Pre-Gloas: attestation trigger on the single block. + let block_under_test = if r.is_after_gloas() { + r.build_chain(2).await; + let child = r.get_last_block().block_cloned(); + // Send the same child from all peers, so the parent lookup donates all peers. + for peer in r.new_connected_peers_for_peerdas() { + r.trigger_unknown_parent_block(peer, child.clone()); + } + // The block under test is the parent; the child's own custody is served from the broad + // pool and must not consume the omission budget. + Some(child.parent_root()) + } else { + let block_root = r.build_chain(1).await; + // Send the same trigger from all peers, so that the lookup has all peers + for peer in r.new_connected_peers_for_peerdas() { + r.trigger_unknown_block_from_attestation(block_root, peer); + } + None + }; let custody_columns = r.custody_columns(); - r.simulate(SimulateConfig::new().return_no_columns_on_indices(&custody_columns[..4], 3)) - .await; + let mut config = SimulateConfig::new().return_no_columns_on_indices(&custody_columns[..4], 3); + if let Some(block_root) = block_under_test { + config = config.return_no_columns_for_block(block_root); + } + r.simulate(config).await; r.assert_penalties_of_type("NotEnoughResponsesReturned"); r.assert_successful_lookup_sync(); } @@ -2402,18 +2555,36 @@ async fn custody_lookup_permanent_custody_failures(test_type: FuluTestType) { let Some(mut r) = TestRig::new_fulu_peer_test(test_type) else { return; }; - let block_root = r.build_chain(1).await; - - // Send the same trigger from all peers, so that the lookup has all peers - for peer in r.new_connected_peers_for_peerdas() { - r.trigger_unknown_block_from_attestation(block_root, peer); - } + // Gloas: a block's columns are only attributable to peers that imported a FULL child (which + // donate their peers into the parent's custody peer set). Build one level of depth and drive + // the lookup off the FULL child, so the block under test is the parent whose custody peers are + // attributable and penalizable. Pre-Gloas: attestation trigger on the single block. + let block_under_test = if r.is_after_gloas() { + r.build_chain(2).await; + let child = r.get_last_block().block_cloned(); + // Send the same child from all peers, so the parent lookup donates all peers. + for peer in r.new_connected_peers_for_peerdas() { + r.trigger_unknown_parent_block(peer, child.clone()); + } + // The block under test is the parent; the child's own custody is served from the broad + // pool and must not consume the omission budget. + Some(child.parent_root()) + } else { + let block_root = r.build_chain(1).await; + // Send the same trigger from all peers, so that the lookup has all peers + for peer in r.new_connected_peers_for_peerdas() { + r.trigger_unknown_block_from_attestation(block_root, peer); + } + None + }; let custody_columns = r.custody_columns(); - r.simulate( - SimulateConfig::new().return_no_columns_on_indices(&custody_columns[..2], usize::MAX), - ) - .await; + let mut config = + SimulateConfig::new().return_no_columns_on_indices(&custody_columns[..2], usize::MAX); + if let Some(block_root) = block_under_test { + config = config.return_no_columns_for_block(block_root); + } + r.simulate(config).await; // Every peer that does not return a column is part of the lookup because it claimed to have // imported the lookup, so we will penalize. r.assert_penalties_of_type("NotEnoughResponsesReturned"); @@ -2453,6 +2624,11 @@ async fn crypto_on_fail_with_bad_column_proposer_signature() { let Some(mut r) = TestRig::new_fulu_peer_test(FuluTestType::WeSupernodeThemSupernode) else { return; }; + // Gloas data columns carry no per-column proposer signature (no signed block header), so this + // scenario does not exist post-Gloas — column crypto failures are covered by the KZG-proof test. + if r.is_after_gloas() { + return; + } r.build_chain(1).await; r.corrupt_last_column_proposer_signature(); r.trigger_with_last_block(); diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index 4e185cc0817..2f318bfb9a0 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -21,7 +21,7 @@ use tokio::sync::mpsc; use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; -use types::{ForkName, Hash256, MinimalEthSpec as E, Slot}; +use types::{ForkName, Hash256, MinimalEthSpec as E, SignedExecutionPayloadEnvelope, Slot}; mod lookups; mod range; @@ -77,6 +77,10 @@ struct TestRig { /// Blocks that will be used in the test but may not be known to `harness` yet. network_blocks_by_root: HashMap>, network_blocks_by_slot: HashMap>, + /// Gloas execution payload envelopes keyed by block root, populated during `build_chain` + /// from the external harness store. The rig serves these when a lookup issues a + /// `PayloadEnvelopesByRoot` request. + network_envelopes_by_root: HashMap>>, penalties: Vec, /// All seen lookups through the test run seen_lookups: HashMap, diff --git a/beacon_node/network/src/sync/tests/range.rs b/beacon_node/network/src/sync/tests/range.rs index 1499ae5016e..e6890cf2425 100644 --- a/beacon_node/network/src/sync/tests/range.rs +++ b/beacon_node/network/src/sync/tests/range.rs @@ -34,6 +34,13 @@ use types::{Epoch, EthSpec, Hash256, MinimalEthSpec as E, Slot}; const SLOTS_PER_EPOCH: usize = 8; impl TestRig { + /// Range sync doesn't yet ingest Gloas blocks in these tests: the range harness doesn't serve + /// payload envelopes, so a Gloas block never becomes fully available and sync can't complete. + /// Skip the affected completion tests under a Gloas genesis. TODO(gloas): support range sync. + fn skip_range_sync_under_gloas(&self) -> bool { + self.fork_name.gloas_enabled() + } + fn add_head_peer(&mut self) -> PeerId { let local_info = self.local_info(); self.add_supernode_peer(SyncInfo { @@ -260,6 +267,9 @@ impl TestRig { #[tokio::test] async fn head_sync_completes() { let mut r = TestRig::default(); + if r.skip_range_sync_under_gloas() { + return; + } r.setup_head_sync().await; r.simulate(SimulateConfig::happy_path()).await; r.assert_head_sync_completed(); @@ -271,6 +281,9 @@ async fn head_sync_completes() { #[tokio::test] async fn finalized_to_head_transition() { let mut r = TestRig::default(); + if r.skip_range_sync_under_gloas() { + return; + } r.setup_finalized_and_head_sync().await; r.simulate(SimulateConfig::happy_path()).await; r.assert_range_sync_completed(); @@ -282,6 +295,9 @@ async fn finalized_to_head_transition() { #[tokio::test] async fn finalized_sync_completes() { let mut r = TestRig::default(); + if r.skip_range_sync_under_gloas() { + return; + } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path()).await; r.assert_range_sync_completed(); @@ -293,6 +309,9 @@ async fn finalized_sync_completes() { #[tokio::test] async fn batch_rpc_error_retries() { let mut r = TestRig::default(); + if r.skip_range_sync_under_gloas() { + return; + } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().return_rpc_error(RPCError::UnsupportedProtocol)) .await; @@ -361,6 +380,9 @@ async fn batch_peer_returns_partial_columns_then_succeeds() { #[tokio::test] async fn batch_non_faulty_failure_retries() { let mut r = TestRig::default(); + if r.skip_range_sync_under_gloas() { + return; + } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().with_range_non_faulty_failures(1)) .await; @@ -372,6 +394,9 @@ async fn batch_non_faulty_failure_retries() { #[tokio::test] async fn batch_faulty_failure_redownloads() { let mut r = TestRig::default(); + if r.skip_range_sync_under_gloas() { + return; + } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().with_range_faulty_failures(1)) .await; @@ -428,6 +453,9 @@ async fn late_response_for_removed_chain() { #[tokio::test] async fn ee_offline_then_online_resumes_sync() { let mut r = TestRig::default(); + if r.skip_range_sync_under_gloas() { + return; + } r.setup_finalized_sync().await; r.simulate(SimulateConfig::happy_path().with_ee_offline_for_n_range_responses(2)) .await; @@ -440,6 +468,9 @@ async fn ee_offline_then_online_resumes_sync() { #[tokio::test] async fn finalized_sync_with_local_head_partial() { let mut r = TestRig::default(); + if r.skip_range_sync_under_gloas() { + return; + } r.setup_finalized_sync_with_local_head(3).await; r.simulate(SimulateConfig::happy_path()).await; r.assert_range_sync_completed(); @@ -450,6 +481,9 @@ async fn finalized_sync_with_local_head_partial() { #[tokio::test] async fn finalized_sync_with_local_head_near_target() { let mut r = TestRig::default(); + if r.skip_range_sync_under_gloas() { + return; + } let target_epochs = 5; let local_slots = (target_epochs * SLOTS_PER_EPOCH) - 1; // all blocks except last r.build_chain(target_epochs * SLOTS_PER_EPOCH).await; @@ -468,7 +502,7 @@ async fn finalized_sync_with_local_head_near_target() { #[tokio::test] async fn not_enough_custody_peers_then_peers_arrive() { let mut r = TestRig::default(); - if !r.fork_name.fulu_enabled() { + if !r.fork_name.fulu_enabled() || r.skip_range_sync_under_gloas() { return; } let remote_info = r.setup_finalized_sync_insufficient_peers().await; @@ -495,7 +529,7 @@ async fn not_enough_custody_peers_then_peers_arrive() { #[tokio::test] async fn finalized_sync_not_enough_custody_peers_resume_after_peer_cgc_update() { let mut r = TestRig::default(); - if !r.fork_name.fulu_enabled() { + if !r.fork_name.fulu_enabled() || r.skip_range_sync_under_gloas() { return; } diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 96d23022666..b7835da1a1a 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -17,7 +17,7 @@ use std::{ }; use types::{ AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, - Slot, + SignedExecutionPayloadBid, Slot, }; pub const DEFAULT_PRUNE_THRESHOLD: usize = 256; @@ -292,6 +292,19 @@ impl Block { } } } + + pub fn is_child_full(&self, child_bid: &SignedExecutionPayloadBid) -> bool { + if let Some(execution_payload_block_hash) = self.execution_payload_block_hash { + execution_payload_block_hash == child_bid.message.parent_block_hash + } else if let Some(execution_block_hash) = self.execution_status.block_hash() { + // Parent is before Gloas, and child is gloas + execution_block_hash == child_bid.message.parent_block_hash + } else { + // TODO(gloas): What to return here? The child is Gloas but parent doesn't have an + // execution hash + false + } + } } /// A Vec-wrapper which will grow to match any request. diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index 1a87a519d0f..1ade0f82a3c 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -361,6 +361,14 @@ impl> SignedBeaconBlock .unwrap_or(0) } + pub fn parent_block_hash(&self) -> Option { + self.message() + .body() + .signed_execution_payload_bid() + .ok() + .map(|bid| bid.message.parent_block_hash) + } + /// Used for displaying commitments in logs. pub fn commitments_formatted(&self) -> String { let Ok(commitments) = self.message().body().blob_kzg_commitments() else {