diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index d175c54be70..66bc1b1624e 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4998,16 +4998,23 @@ 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) + // 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() + .or(self.store.get_payload_info().latest_finalized_block_number) + .or(self.latest_pre_gloas_block_number(cached_head.head_block_root())?), + ) } 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/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 91a9dcc7c8a..a4d0210e755 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -776,6 +776,29 @@ where .get_payload_envelope(&head_block_root) .map_err(|e| format!("Error loading head execution envelope: {:?}", e))? .map(Arc::new) + } 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| { + 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 { + // No payload revealed since finalization. + None + } } else { None }; diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 1eab7ccf7ac..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}; @@ -178,7 +179,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 +188,26 @@ impl CachedHead { .map(|payload| payload.block_number()) } + /// Returns the execution block number of the block at the head of the chain. + /// + /// 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 + .execution_envelope + .as_ref() + .map(|envelope| envelope.message.payload.block_number) + { + Some(head_block_number) + } else { + // This fallback is strictly for the fork boundary case when self.snapshot.execution_envelope is `None`. + // 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() + } + } + /// 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 @@ -324,6 +345,21 @@ impl CanonicalHead { store .get_payload_envelope(&beacon_block_root)? .map(Arc::new) + } 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)?; + + if let Some(latest_full_block_root) = latest_full_block_root_opt { + store + .get_payload_envelope(&latest_full_block_root)? + .map(Arc::new) + } else { + // No payload revealed since finalization. + None + } } else { None }; @@ -723,9 +759,33 @@ impl BeaconChain { ))?; Some(envelope) + } 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)?; + 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 { + // No payload revealed since finalization. + None + } } else { None }; + let (_, beacon_state) = self .store .get_advanced_hot_state(new_view.head_block_root, current_slot, state_root)? @@ -998,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() @@ -1076,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/lighthouse_network/src/config.rs b/beacon_node/lighthouse_network/src/config.rs index f54a7ee5b95..2974d5006ee 100644 --- a/beacon_node/lighthouse_network/src/config.rs +++ b/beacon_node/lighthouse_network/src/config.rs @@ -146,6 +146,10 @@ pub struct Config { /// Whether to enable partial data column support. pub enable_partial_columns: bool, + + /// 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 { @@ -372,6 +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, + 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 d403382e9e9..249e53b8b2d 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -98,6 +98,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. `PARENT_DEPTH_TOLERANCE` + /// normally, unbounded when range sync is disabled (`--disable-range-sync`). + max_parent_depth: usize, + + /// 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 metrics: BlockLookupsMetrics, } @@ -117,12 +125,22 @@ pub(crate) struct BlockLookupSummary { } impl BlockLookups { - pub fn new() -> 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, )), single_block_lookups: Default::default(), + max_parent_depth, + max_lookups, metrics: <_>::default(), } } @@ -247,7 +265,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 +397,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..872f3ed052c 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -309,6 +309,7 @@ impl SyncManager { fork_context: Arc, ) -> Self { let network_globals = beacon_processor.network_globals.clone(); + let disable_range_sync = network_globals.config.disable_range_sync; Self { chain: beacon_chain.clone(), input_channel: sync_recv, @@ -321,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(), + block_lookups: BlockLookups::new(disable_range_sync), notified_unknown_roots: LRUTimeCache::new(Duration::from_secs( NOTIFIED_UNKNOWN_ROOT_EXPIRY_SECONDS, )), @@ -409,25 +410,33 @@ 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.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); } - 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); + } } } } @@ -1024,7 +1033,12 @@ impl SyncManager { block_slot: Option, peer_id: &PeerId, ) -> Result<(), &'static str> { - if !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 3cf6a6efd22..253aaf121b9 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -693,6 +693,16 @@ pub fn cli_app() -> Command { .default_missing_value("true") .display_order(0) ) + .arg( + Arg::new("disable-range-sync") + .long("disable-range-sync") + .action(ArgAction::SetTrue) + .help_heading(FLAG_HEADER) + .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) + ) /* * Monitoring metrics */ diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index d27909bddfc..337bcb13683 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -1503,6 +1503,8 @@ pub fn set_network_config( })?; } + config.disable_range_sync = cli_args.get_flag("disable-range-sync"); + Ok(()) } 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)?) + } +} diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index aca5ab78510..fbdfe577e97 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -1646,6 +1646,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/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index d9acda12586..7299aae550f 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, + }, /// Assert the result of `should_build_on_full` for the parent `block_root`, where /// `parent_payload_status` is the status the proposer would build on and `proposal_slot` /// is the slot being proposed. @@ -616,6 +624,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, + ); + } Operation::AssertShouldBuildOnFull { block_root, parent_payload_status, 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 07262fb0d7e..e7033ef0a3a 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 @@ -1325,4 +1325,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(); + } } diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 04113e2c0e5..c6db57aebde 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1302,6 +1302,33 @@ 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 node.as_v29().is_err() { + return Ok(None); + } + + 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 c6a7829c27b..65a4aeb819d 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -1040,6 +1040,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(