From 96bc5617d0210e502b1103ecd12ca97b0c4e64fa Mon Sep 17 00:00:00 2001 From: Sergey Yakovlev Date: Thu, 12 Feb 2026 21:33:00 +0200 Subject: [PATCH 001/189] fix: auto-populate ENR UDP port from discovery listen port (#8804) Co-Authored-By: Sergey Yakovlev --- .../lighthouse_network/src/discovery/enr.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/beacon_node/lighthouse_network/src/discovery/enr.rs b/beacon_node/lighthouse_network/src/discovery/enr.rs index 4c285ea86c..01a01d55ab 100644 --- a/beacon_node/lighthouse_network/src/discovery/enr.rs +++ b/beacon_node/lighthouse_network/src/discovery/enr.rs @@ -200,11 +200,23 @@ pub fn build_enr( builder.ip6(*ip); } - if let Some(udp4_port) = config.enr_udp4_port { + // If the ENR port is not set, and we are listening over that ip version, use the listening + // discovery port instead. + if let Some(udp4_port) = config.enr_udp4_port.or_else(|| { + config + .listen_addrs() + .v4() + .and_then(|v4_addr| v4_addr.disc_port.try_into().ok()) + }) { builder.udp4(udp4_port.get()); } - if let Some(udp6_port) = config.enr_udp6_port { + if let Some(udp6_port) = config.enr_udp6_port.or_else(|| { + config + .listen_addrs() + .v6() + .and_then(|v6_addr| v6_addr.disc_port.try_into().ok()) + }) { builder.udp6(udp6_port.get()); } From 036ba1f221fd1f7a346a0c10a971e9f7582099f3 Mon Sep 17 00:00:00 2001 From: Mac L Date: Fri, 13 Feb 2026 00:51:26 +0400 Subject: [PATCH 002/189] Add `network` feature to `eth2` (#8558) This reverts some of the changes from #8524 by adding back the typed network endpoints with an optional `network` feature. Without the `network` feature, these endpoints (and associated dependencies) will not be built. This means the `enr`, `multiaddr` and `libp2p-identity` dependencies have returned but are now optional Co-Authored-By: Mac L --- Cargo.lock | 3 +++ beacon_node/beacon_chain/Cargo.toml | 2 +- beacon_node/execution_layer/Cargo.toml | 2 +- beacon_node/http_api/Cargo.toml | 2 +- beacon_node/http_api/src/lib.rs | 9 +++------ beacon_node/http_api/tests/tests.rs | 18 ++++-------------- common/eth2/Cargo.toml | 4 ++++ common/eth2/src/lib.rs | 8 ++++++-- common/eth2/src/types.rs | 11 ++++++++--- 9 files changed, 31 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69204ccaec..7683e67624 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3120,13 +3120,16 @@ dependencies = [ "context_deserialize", "educe", "eip_3076", + "enr", "eth2_keystore", "ethereum_serde_utils", "ethereum_ssz", "ethereum_ssz_derive", "futures", "futures-util", + "libp2p-identity", "mediatype", + "multiaddr", "pretty_reqwest_error", "proto_array", "rand 0.9.2", diff --git a/beacon_node/beacon_chain/Cargo.toml b/beacon_node/beacon_chain/Cargo.toml index 5e1c41b830..eec8836ff4 100644 --- a/beacon_node/beacon_chain/Cargo.toml +++ b/beacon_node/beacon_chain/Cargo.toml @@ -19,7 +19,7 @@ alloy-primitives = { workspace = true } bitvec = { workspace = true } bls = { workspace = true } educe = { workspace = true } -eth2 = { workspace = true, features = ["lighthouse"] } +eth2 = { workspace = true, features = ["lighthouse", "network"] } eth2_network_config = { workspace = true } ethereum_hashing = { workspace = true } ethereum_serde_utils = { workspace = true } diff --git a/beacon_node/execution_layer/Cargo.toml b/beacon_node/execution_layer/Cargo.toml index c443e94574..a23ea948e4 100644 --- a/beacon_node/execution_layer/Cargo.toml +++ b/beacon_node/execution_layer/Cargo.toml @@ -13,7 +13,7 @@ arc-swap = "1.6.0" bls = { workspace = true } builder_client = { path = "../builder_client" } bytes = { workspace = true } -eth2 = { workspace = true, features = ["events", "lighthouse"] } +eth2 = { workspace = true, features = ["events", "lighthouse", "network"] } ethereum_serde_utils = { workspace = true } ethereum_ssz = { workspace = true } fixed_bytes = { workspace = true } diff --git a/beacon_node/http_api/Cargo.toml b/beacon_node/http_api/Cargo.toml index 6211ac6726..78e7af71f4 100644 --- a/beacon_node/http_api/Cargo.toml +++ b/beacon_node/http_api/Cargo.toml @@ -14,7 +14,7 @@ bytes = { workspace = true } context_deserialize = { workspace = true } directory = { workspace = true } either = { workspace = true } -eth2 = { workspace = true, features = ["lighthouse"] } +eth2 = { workspace = true, features = ["lighthouse", "network"] } ethereum_serde_utils = { workspace = true } ethereum_ssz = { workspace = true } execution_layer = { workspace = true } diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 095c52fb29..c4b2cded51 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -2140,12 +2140,9 @@ pub fn serve( let discovery_addresses = enr.multiaddr_p2p_udp(); Ok(api_types::GenericResponse::from(api_types::IdentityData { peer_id: network_globals.local_peer_id().to_base58(), - enr: enr.to_base64(), - p2p_addresses: p2p_addresses.iter().map(|a| a.to_string()).collect(), - discovery_addresses: discovery_addresses - .iter() - .map(|a| a.to_string()) - .collect(), + enr, + p2p_addresses, + discovery_addresses, metadata: utils::from_meta_data::( &network_globals.local_metadata, &chain.spec, diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index a49362d815..367a0e3f05 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -2855,19 +2855,9 @@ impl ApiTester { let expected = IdentityData { peer_id: self.local_enr.peer_id().to_string(), - enr: self.local_enr.to_base64(), - p2p_addresses: self - .local_enr - .multiaddr_p2p_tcp() - .iter() - .map(|a| a.to_string()) - .collect(), - discovery_addresses: self - .local_enr - .multiaddr_p2p_udp() - .iter() - .map(|a| a.to_string()) - .collect(), + enr: self.local_enr.clone(), + p2p_addresses: self.local_enr.multiaddr_p2p_tcp(), + discovery_addresses: self.local_enr.multiaddr_p2p_udp(), metadata: MetaData::V2(MetaDataV2 { seq_number: 0, attnets: "0x0000000000000000".to_string(), @@ -2896,7 +2886,7 @@ impl ApiTester { pub async fn test_get_node_peers_by_id(self) -> Self { let result = self .client - .get_node_peers_by_id(&self.external_peer_id.to_string()) + .get_node_peers_by_id(self.external_peer_id) .await .unwrap() .data; diff --git a/common/eth2/Cargo.toml b/common/eth2/Cargo.toml index da8aba5ded..974508492a 100644 --- a/common/eth2/Cargo.toml +++ b/common/eth2/Cargo.toml @@ -8,19 +8,23 @@ edition = { workspace = true } default = [] lighthouse = ["proto_array", "eth2_keystore", "eip_3076", "zeroize"] events = ["reqwest-eventsource", "futures", "futures-util"] +network = ["libp2p-identity", "enr", "multiaddr"] [dependencies] bls = { workspace = true } context_deserialize = { workspace = true } educe = { workspace = true } eip_3076 = { workspace = true, optional = true } +enr = { version = "0.13.0", features = ["ed25519"], optional = true } eth2_keystore = { workspace = true, optional = true } ethereum_serde_utils = { workspace = true } ethereum_ssz = { workspace = true } ethereum_ssz_derive = { workspace = true } futures = { workspace = true, optional = true } futures-util = { version = "0.3.8", optional = true } +libp2p-identity = { version = "0.2", features = ["peerid"], optional = true } mediatype = "0.19.13" +multiaddr = { version = "0.18.2", optional = true } pretty_reqwest_error = { workspace = true } proto_array = { workspace = true, optional = true } reqwest = { workspace = true } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 10382b028a..95744a4137 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -35,6 +35,8 @@ use educe::Educe; use futures::Stream; #[cfg(feature = "events")] use futures_util::StreamExt; +#[cfg(feature = "network")] +use libp2p_identity::PeerId; use reqwest::{ Body, IntoUrl, RequestBuilder, Response, header::{HeaderMap, HeaderValue}, @@ -1939,6 +1941,7 @@ impl BeaconNodeHttpClient { } /// `GET node/identity` + #[cfg(feature = "network")] pub async fn get_node_identity(&self) -> Result, Error> { let mut path = self.eth_path(V1)?; @@ -1986,9 +1989,10 @@ impl BeaconNodeHttpClient { } /// `GET node/peers/{peer_id}` + #[cfg(feature = "network")] pub async fn get_node_peers_by_id( &self, - peer_id: &str, + peer_id: PeerId, ) -> Result, Error> { let mut path = self.eth_path(V1)?; @@ -1996,7 +2000,7 @@ impl BeaconNodeHttpClient { .map_err(|()| Error::InvalidUrl(self.server.clone()))? .push("node") .push("peers") - .push(peer_id); + .push(&peer_id.to_string()); self.get(path).await } diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 4acfe3a640..8b33a4dfb9 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -9,7 +9,11 @@ use crate::{ }; use bls::{PublicKeyBytes, SecretKey, Signature, SignatureBytes}; use context_deserialize::ContextDeserialize; +#[cfg(feature = "network")] +use enr::{CombinedKey, Enr}; use mediatype::{MediaType, MediaTypeList, names}; +#[cfg(feature = "network")] +use multiaddr::Multiaddr; use reqwest::header::HeaderMap; use serde::{Deserialize, Deserializer, Serialize}; use serde_utils::quoted_u64::Quoted; @@ -559,12 +563,13 @@ pub struct ChainHeadData { pub execution_optimistic: Option, } +#[cfg(feature = "network")] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct IdentityData { pub peer_id: String, - pub enr: String, - pub p2p_addresses: Vec, - pub discovery_addresses: Vec, + pub enr: Enr, + pub p2p_addresses: Vec, + pub discovery_addresses: Vec, pub metadata: MetaData, } From c59e4a0cee78d311ffe17d8045cbd82032b501c9 Mon Sep 17 00:00:00 2001 From: Mac L Date: Fri, 13 Feb 2026 00:51:39 +0400 Subject: [PATCH 003/189] Disable `legacy-arith` by default in `consensus/types` (#8695) Currently, `consensus/types` cannot build with `no-default-features` since we use "legacy" standard arithmetic operations. - Remove the offending arithmetic to fix compilation. - Rename `legacy-arith` to `saturating-arith` and disable it by default. Co-Authored-By: Mac L --- Cargo.toml | 2 +- beacon_node/beacon_chain/src/beacon_chain.rs | 2 +- .../beacon_chain/src/early_attester_cache.rs | 8 +- consensus/state_processing/Cargo.toml | 3 +- consensus/types/Cargo.toml | 7 +- consensus/types/src/core/slot_epoch.rs | 2 +- consensus/types/src/core/slot_epoch_macros.rs | 6 +- consensus/types/src/state/beacon_state.rs | 7 +- consensus/types/src/state/committee_cache.rs | 125 ++++++++++-------- consensus/types/src/state/mod.rs | 2 +- consensus/types/tests/committee_cache.rs | 4 +- 11 files changed, 91 insertions(+), 77 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 100a916c50..98e8c057b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -271,7 +271,7 @@ tracing_samplers = { path = "common/tracing_samplers" } tree_hash = "0.12.0" tree_hash_derive = "0.12.0" typenum = "1" -types = { path = "consensus/types" } +types = { path = "consensus/types", features = ["saturating-arith"] } url = "2" uuid = { version = "0.8", features = ["serde", "v4"] } validator_client = { path = "validator_client" } diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index ec79153785..4ae7871758 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -1665,7 +1665,7 @@ impl BeaconChain { let validator_index = *validator_index as usize; committee_cache.get_attestation_duties(validator_index) }) - .collect(); + .collect::, _>>()?; Ok((duties, dependent_root)) }, diff --git a/beacon_node/beacon_chain/src/early_attester_cache.rs b/beacon_node/beacon_chain/src/early_attester_cache.rs index 8d9eb950f3..752e4d1a96 100644 --- a/beacon_node/beacon_chain/src/early_attester_cache.rs +++ b/beacon_node/beacon_chain/src/early_attester_cache.rs @@ -2,6 +2,7 @@ use crate::data_availability_checker::{AvailableBlock, AvailableBlockData}; use crate::{BeaconChainError as Error, metrics}; use parking_lot::RwLock; use proto_array::Block as ProtoBlock; +use safe_arith::SafeArith; use std::sync::Arc; use tracing::instrument; use types::*; @@ -59,12 +60,13 @@ impl CommitteeLengths { slots_per_epoch, committees_per_slot, committee_index as usize, - ); + )?; + let epoch_committee_count = committees_per_slot.safe_mul(slots_per_epoch)?; let range = compute_committee_range_in_epoch( - epoch_committee_count(committees_per_slot, slots_per_epoch), + epoch_committee_count, index_in_epoch, self.active_validator_indices_len, - ) + )? .ok_or(Error::EarlyAttesterCacheError)?; range diff --git a/consensus/state_processing/Cargo.toml b/consensus/state_processing/Cargo.toml index a08035d583..a83e443e80 100644 --- a/consensus/state_processing/Cargo.toml +++ b/consensus/state_processing/Cargo.toml @@ -5,9 +5,8 @@ authors = ["Paul Hauner ", "Michael Sproul { impl Add<$other> for $main { @@ -321,9 +321,9 @@ macro_rules! impl_common { impl_u64_eq_ord!($type); impl_safe_arith!($type, $type); impl_safe_arith!($type, u64); - #[cfg(feature = "legacy-arith")] + #[cfg(feature = "saturating-arith")] impl_math_between!($type, $type); - #[cfg(feature = "legacy-arith")] + #[cfg(feature = "saturating-arith")] impl_math_between!($type, u64); impl_math!($type); impl_display!($type); diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 6838b588eb..6228e40ef8 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -876,7 +876,7 @@ impl BeaconState { relative_epoch: RelativeEpoch, ) -> Result { let cache = self.committee_cache(relative_epoch)?; - Ok(cache.epoch_committee_count() as u64) + Ok(cache.epoch_committee_count()? as u64) } /// Return the cached active validator indices at some epoch. @@ -2150,7 +2150,7 @@ impl BeaconState { ) -> Result, BeaconStateError> { let cache = self.committee_cache(relative_epoch)?; - Ok(cache.get_attestation_duties(validator_index)) + Ok(cache.get_attestation_duties(validator_index)?) } /// Check if the attestation is for the block proposed at the attestation slot. @@ -2909,7 +2909,6 @@ impl BeaconState { } } - #[allow(clippy::arithmetic_side_effects)] pub fn rebase_on(&mut self, base: &Self, spec: &ChainSpec) -> Result<(), BeaconStateError> { // Required for macros (which use type-hints internally). @@ -3218,7 +3217,6 @@ impl BeaconState { )) } - #[allow(clippy::arithmetic_side_effects)] pub fn apply_pending_mutations(&mut self) -> Result<(), BeaconStateError> { match self { Self::Base(inner) => { @@ -3321,7 +3319,6 @@ impl BeaconState { pub fn get_beacon_state_leaves(&self) -> Vec { let mut leaves = vec![]; - #[allow(clippy::arithmetic_side_effects)] match self { BeaconState::Base(state) => { map_beacon_state_base_fields!(state, |_, field| { diff --git a/consensus/types/src/state/committee_cache.rs b/consensus/types/src/state/committee_cache.rs index 39e9011ef4..4a28f3c689 100644 --- a/consensus/types/src/state/committee_cache.rs +++ b/consensus/types/src/state/committee_cache.rs @@ -1,9 +1,7 @@ -#![allow(clippy::arithmetic_side_effects)] - use std::{num::NonZeroUsize, ops::Range, sync::Arc}; use educe::Educe; -use safe_arith::SafeArith; +use safe_arith::{ArithError, SafeArith}; use serde::{Deserialize, Serialize}; use ssz::{Decode, DecodeError, Encode, four_byte_option_impl}; use ssz_derive::{Decode, Encode}; @@ -79,7 +77,13 @@ impl CommitteeCache { .saturating_sub(spec.min_seed_lookahead) .saturating_sub(1u64); - if reqd_randao_epoch < state.min_randao_epoch() || epoch > state.current_epoch() + 1 { + if reqd_randao_epoch < state.min_randao_epoch() + || epoch + > state + .current_epoch() + .safe_add(1) + .map_err(BeaconStateError::ArithError)? + { return Err(BeaconStateError::EpochOutOfBounds); } @@ -118,7 +122,7 @@ impl CommitteeCache { *shuffling_positions .get_mut(v) .ok_or(BeaconStateError::ShuffleIndexOutOfBounds(v))? = - NonZeroUsize::new(i + 1).into(); + NonZeroUsize::new(i.safe_add(1).map_err(BeaconStateError::ArithError)?).into(); } Ok(Arc::new(CommitteeCache { @@ -177,8 +181,9 @@ impl CommitteeCache { self.slots_per_epoch as usize, self.committees_per_slot as usize, index as usize, - ); - let committee = self.compute_committee(committee_index)?; + ) + .ok()?; + let committee = self.compute_committee(committee_index).ok()??; Some(BeaconCommittee { slot, @@ -212,8 +217,9 @@ impl CommitteeCache { .initialized_epoch .ok_or(BeaconStateError::CommitteeCacheUninitialized(None))?; + let capacity = self.epoch_committee_count()?; initialized_epoch.slot_iter(self.slots_per_epoch).try_fold( - Vec::with_capacity(self.epoch_committee_count()), + Vec::with_capacity(capacity), |mut vec, slot| { vec.append(&mut self.get_beacon_committees_at_slot(slot)?); Ok(vec) @@ -225,43 +231,53 @@ impl CommitteeCache { /// /// Returns `None` if the `validator_index` does not exist, does not have duties or `Self` is /// non-initialized. - pub fn get_attestation_duties(&self, validator_index: usize) -> Option { - let i = self.shuffled_position(validator_index)?; + pub fn get_attestation_duties( + &self, + validator_index: usize, + ) -> Result, ArithError> { + let Some(i) = self.shuffled_position(validator_index) else { + return Ok(None); + }; - (0..self.epoch_committee_count()) - .map(|nth_committee| (nth_committee, self.compute_committee_range(nth_committee))) - .find(|(_, range)| { - if let Some(range) = range { - range.start <= i && range.end > i - } else { - false - } - }) - .and_then(|(nth_committee, range)| { - let (slot, index) = self.convert_to_slot_and_index(nth_committee as u64)?; - let range = range?; - let committee_position = i - range.start; - let committee_len = range.end - range.start; + for nth_committee in 0..self.epoch_committee_count()? { + let Some(range) = self.compute_committee_range(nth_committee)? else { + continue; + }; - Some(AttestationDuty { + if range.start <= i && range.end > i { + let Some((slot, index)) = self.convert_to_slot_and_index(nth_committee as u64)? + else { + return Ok(None); + }; + + let committee_position = i.safe_sub(range.start)?; + let committee_len = range.end.safe_sub(range.start)?; + + return Ok(Some(AttestationDuty { slot, index, committee_position, committee_len, committees_at_slot: self.committees_per_slot(), - }) - }) + })); + } + } + + Ok(None) } /// Convert an index addressing the list of all epoch committees into a slot and per-slot index. fn convert_to_slot_and_index( &self, global_committee_index: u64, - ) -> Option<(Slot, CommitteeIndex)> { - let epoch_start_slot = self.initialized_epoch?.start_slot(self.slots_per_epoch); - let slot_offset = global_committee_index / self.committees_per_slot; - let index = global_committee_index % self.committees_per_slot; - Some((epoch_start_slot.safe_add(slot_offset).ok()?, index)) + ) -> Result, ArithError> { + let Some(epoch) = self.initialized_epoch else { + return Ok(None); + }; + let epoch_start_slot = epoch.start_slot(self.slots_per_epoch); + let slot_offset = global_committee_index.safe_div(self.committees_per_slot)?; + let index = global_committee_index.safe_rem(self.committees_per_slot)?; + Ok(Some((epoch_start_slot.safe_add(slot_offset)?, index))) } /// Returns the number of active validators in the initialized epoch. @@ -278,11 +294,8 @@ impl CommitteeCache { /// Always returns `usize::default()` for a non-initialized epoch. /// /// Spec v0.12.1 - pub fn epoch_committee_count(&self) -> usize { - epoch_committee_count( - self.committees_per_slot as usize, - self.slots_per_epoch as usize, - ) + pub fn epoch_committee_count(&self) -> Result { + (self.committees_per_slot as usize).safe_mul(self.slots_per_epoch as usize) } /// Returns the number of committees per slot for this cache's epoch. @@ -293,19 +306,23 @@ impl CommitteeCache { /// Returns a slice of `self.shuffling` that represents the `index`'th committee in the epoch. /// /// Spec v0.12.1 - fn compute_committee(&self, index: usize) -> Option<&[usize]> { - self.shuffling.get(self.compute_committee_range(index)?) + fn compute_committee(&self, index: usize) -> Result, ArithError> { + if let Some(range) = self.compute_committee_range(index)? { + Ok(self.shuffling.get(range)) + } else { + Ok(None) + } } /// Returns a range of `self.shuffling` that represents the `index`'th committee in the epoch. /// - /// To avoid a divide-by-zero, returns `None` if `self.committee_count` is zero. + /// To avoid a divide-by-zero, returns `Ok(None)` if `self.committee_count` is zero. /// - /// Will also return `None` if the index is out of bounds. + /// Will also return `Ok(None)` if the index is out of bounds. /// /// Spec v0.12.1 - fn compute_committee_range(&self, index: usize) -> Option> { - compute_committee_range_in_epoch(self.epoch_committee_count(), index, self.shuffling.len()) + fn compute_committee_range(&self, index: usize) -> Result>, ArithError> { + compute_committee_range_in_epoch(self.epoch_committee_count()?, index, self.shuffling.len()) } /// Returns the index of some validator in `self.shuffling`. @@ -329,8 +346,10 @@ pub fn compute_committee_index_in_epoch( slots_per_epoch: usize, committees_per_slot: usize, committee_index: usize, -) -> usize { - (slot.as_usize() % slots_per_epoch) * committees_per_slot + committee_index +) -> Result { + (slot.as_usize().safe_rem(slots_per_epoch)?) + .safe_mul(committees_per_slot)? + .safe_add(committee_index) } /// Computes the range for slicing the shuffled indices to determine the members of a committee. @@ -341,20 +360,16 @@ pub fn compute_committee_range_in_epoch( epoch_committee_count: usize, index_in_epoch: usize, shuffling_len: usize, -) -> Option> { +) -> Result>, ArithError> { if epoch_committee_count == 0 || index_in_epoch >= epoch_committee_count { - return None; + return Ok(None); } - let start = (shuffling_len * index_in_epoch) / epoch_committee_count; - let end = (shuffling_len * (index_in_epoch + 1)) / epoch_committee_count; + let start = (shuffling_len.safe_mul(index_in_epoch))?.safe_div(epoch_committee_count)?; + let end = + (shuffling_len.safe_mul(index_in_epoch.safe_add(1)?))?.safe_div(epoch_committee_count)?; - Some(start..end) -} - -/// Returns the total number of committees in an epoch. -pub fn epoch_committee_count(committees_per_slot: usize, slots_per_epoch: usize) -> usize { - committees_per_slot * slots_per_epoch + Ok(Some(start..end)) } /// Returns a list of all `validators` indices where the validator is active at the given diff --git a/consensus/types/src/state/mod.rs b/consensus/types/src/state/mod.rs index ea064fb7ac..096bb67167 100644 --- a/consensus/types/src/state/mod.rs +++ b/consensus/types/src/state/mod.rs @@ -21,7 +21,7 @@ pub use beacon_state::{ }; pub use committee_cache::{ CommitteeCache, compute_committee_index_in_epoch, compute_committee_range_in_epoch, - epoch_committee_count, get_active_validator_indices, + get_active_validator_indices, }; pub use epoch_cache::{EpochCache, EpochCacheError, EpochCacheKey}; pub use exit_cache::ExitCache; diff --git a/consensus/types/tests/committee_cache.rs b/consensus/types/tests/committee_cache.rs index 751ef05d29..0bb8aa1da2 100644 --- a/consensus/types/tests/committee_cache.rs +++ b/consensus/types/tests/committee_cache.rs @@ -33,9 +33,9 @@ fn default_values() { assert!(!cache.is_initialized_at(Epoch::new(0))); assert!(&cache.active_validator_indices().is_empty()); assert_eq!(cache.get_beacon_committee(Slot::new(0), 0), None); - assert_eq!(cache.get_attestation_duties(0), None); + assert_eq!(cache.get_attestation_duties(0), Ok(None)); assert_eq!(cache.active_validator_count(), 0); - assert_eq!(cache.epoch_committee_count(), 0); + assert_eq!(cache.epoch_committee_count(), Ok(0)); assert!(cache.get_beacon_committees_at_slot(Slot::new(0)).is_err()); } From f4a6b8d9b97bfcbf901423c109901561d3e7e928 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:24:51 -0700 Subject: [PATCH 004/189] Tree-sync friendly lookup sync tests (#8592) - Step 0 of the tree-sync roadmap https://github.com/sigp/lighthouse/issues/7678 Current lookup sync tests are written in an explicit way that assume how the internals of lookup sync work. For example the test would do: - Emit unknown block parent message - Expect block request for X - Respond with successful block request - Expect block processing request for X - Response with successful processing request - etc.. This is unnecessarily verbose. And it will requires a complete re-write when something changes in the internals of lookup sync (has happened a few times, mostly for deneb and fulu). What we really want to assert is: - WHEN: we receive an unknown block parent message - THEN: Lookup sync can sync that block - ASSERT: Without penalizing peers, without unnecessary retries Keep all existing tests and add new cases but written in the new style described above. The logic to serve and respond to request is in this function `fn simulate` https://github.com/dapplion/lighthouse/blob/2288a3aeb11164bb1960dc803f41696c984c69ff/beacon_node/network/src/sync/tests/lookups.rs#L301 - It controls peer behavior based on a `CompleteStrategy` where you can set for example "respond to BlocksByRoot requests with empty" - It actually runs beacon processor messages running their clousures. Now sync tests actually import blocks, increasing the test coverage to the interaction of sync and the da_checker. - To achieve the above the tests create real blocks with the test harness. To make the tests as fast as before, I disabled crypto with `TestConfig` Along the way I found a couple bugs, which I documented on the diff. Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- Cargo.lock | 1 + Makefile | 13 +- .../src/block_verification_types.rs | 15 - .../overflow_lru_cache.rs | 2 + beacon_node/beacon_chain/src/test_utils.rs | 6 +- beacon_node/beacon_processor/src/lib.rs | 15 +- .../test_utils/execution_block_generator.rs | 16 + .../src/test_utils/handle_rpc.rs | 9 + .../lighthouse_network/src/rpc/protocol.rs | 2 +- .../src/service/api_types.rs | 2 +- beacon_node/network/Cargo.toml | 2 + .../src/network_beacon_processor/mod.rs | 5 +- .../network_beacon_processor/sync_methods.rs | 2 +- .../src/network_beacon_processor/tests.rs | 20 +- .../network/src/sync/block_lookups/mod.rs | 75 +- .../sync/block_lookups/single_block_lookup.rs | 6 + beacon_node/network/src/sync/manager.rs | 33 +- .../network/src/sync/network_context.rs | 7 +- .../src/sync/network_context/custody.rs | 20 +- .../src/sync/range_sync/chain_collection.rs | 25 + .../network/src/sync/range_sync/range.rs | 5 + beacon_node/network/src/sync/tests/lookups.rs | 4258 ++++++++--------- beacon_node/network/src/sync/tests/mod.rs | 73 +- beacon_node/network/src/sync/tests/range.rs | 26 +- crypto/bls/src/impls/fake_crypto.rs | 25 +- crypto/kzg/Cargo.toml | 4 + crypto/kzg/src/lib.rs | 12 + 27 files changed, 2298 insertions(+), 2381 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7683e67624..5a63ab1e72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6081,6 +6081,7 @@ dependencies = [ "metrics", "operation_pool", "parking_lot", + "paste", "rand 0.8.5", "rand 0.9.2", "rand_chacha 0.3.1", diff --git a/Makefile b/Makefile index 9e2b1d24c5..0995a869f4 100644 --- a/Makefile +++ b/Makefile @@ -36,8 +36,12 @@ PROFILE ?= release RECENT_FORKS_BEFORE_GLOAS=electra fulu # List of all recent hard forks. This list is used to set env variables for http_api tests +# Include phase0 to test the code paths in sync that are pre blobs RECENT_FORKS=electra fulu gloas +# For network tests include phase0 to cover genesis syncing (blocks without blobs or columns) +TEST_NETWORK_FORKS=phase0 $(RECENT_FORKS_BEFORE_GLOAS) + # Extra flags for Cargo CARGO_INSTALL_EXTRA_FLAGS?= @@ -226,12 +230,15 @@ test-op-pool-%: # Run the tests in the `network` crate for all known forks. # TODO(EIP-7732) Extend to support gloas by using RECENT_FORKS instead -test-network: $(patsubst %,test-network-%,$(RECENT_FORKS_BEFORE_GLOAS)) +test-network: $(patsubst %,test-network-%,$(TEST_NETWORK_FORKS)) test-network-%: - env FORK_NAME=$* cargo nextest run --release \ - --features "fork_from_env,$(TEST_FEATURES)" \ + env FORK_NAME=$* cargo nextest run --no-fail-fast --release \ + --features "fork_from_env,fake_crypto,$(TEST_FEATURES)" \ -p network + env FORK_NAME=$* cargo nextest run --no-fail-fast --release \ + --features "fork_from_env,$(TEST_FEATURES)" \ + -p network crypto_on # Run the tests in the `slasher` crate for all supported database backends. test-slasher: diff --git a/beacon_node/beacon_chain/src/block_verification_types.rs b/beacon_node/beacon_chain/src/block_verification_types.rs index 6a028e6c98..f98cd40d08 100644 --- a/beacon_node/beacon_chain/src/block_verification_types.rs +++ b/beacon_node/beacon_chain/src/block_verification_types.rs @@ -287,21 +287,6 @@ pub struct BlockImportData { pub consensus_context: ConsensusContext, } -impl BlockImportData { - pub fn __new_for_test( - block_root: Hash256, - state: BeaconState, - parent_block: SignedBeaconBlock>, - ) -> Self { - Self { - block_root, - state, - parent_block, - consensus_context: ConsensusContext::new(Slot::new(0)), - } - } -} - /// Trait for common block operations. pub trait AsBlock { fn slot(&self) -> Slot; diff --git a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs index f7bd646f82..7260a4aca0 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs @@ -698,6 +698,8 @@ impl DataAvailabilityCheckerInner { pub fn remove_pre_execution_block(&self, block_root: &Hash256) { // The read lock is immediately dropped so we can safely remove the block from the cache. if let Some(BlockProcessStatus::NotValidated(_, _)) = self.get_cached_block(block_root) { + // If the block is execution invalid, this status is permanent and idempotent to this + // block_root. We drop its components (e.g. columns) because they will never be useful. self.critical.write().pop(block_root); } } diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index f816dbac53..096a0516fc 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -818,7 +818,11 @@ where } pub fn get_full_block(&self, block_root: &Hash256) -> RpcBlock { - let block = self.chain.get_blinded_block(block_root).unwrap().unwrap(); + let block = self + .chain + .get_blinded_block(block_root) + .unwrap() + .unwrap_or_else(|| panic!("block root does not exist in harness {block_root:?}")); let full_block = self.chain.store.make_full_block(block_root, block).unwrap(); self.build_rpc_block_from_store_blobs(Some(*block_root), Arc::new(full_block)) } diff --git a/beacon_node/beacon_processor/src/lib.rs b/beacon_node/beacon_processor/src/lib.rs index d3e9133542..33a00bfa49 100644 --- a/beacon_node/beacon_processor/src/lib.rs +++ b/beacon_node/beacon_processor/src/lib.rs @@ -243,12 +243,15 @@ impl From for WorkEvent { }, }, ReadyWork::RpcBlock(QueuedRpcBlock { - beacon_block_root: _, + beacon_block_root, process_fn, ignore_fn: _, }) => Self { drop_during_sync: false, - work: Work::RpcBlock { process_fn }, + work: Work::RpcBlock { + process_fn, + beacon_block_root, + }, }, ReadyWork::IgnoredRpcBlock(IgnoredRpcBlock { process_fn }) => Self { drop_during_sync: false, @@ -389,6 +392,7 @@ pub enum Work { GossipLightClientFinalityUpdate(BlockingFn), GossipLightClientOptimisticUpdate(BlockingFn), RpcBlock { + beacon_block_root: Hash256, process_fn: AsyncFn, }, RpcBlobs { @@ -479,7 +483,7 @@ pub enum WorkType { } impl Work { - fn str_id(&self) -> &'static str { + pub fn str_id(&self) -> &'static str { self.to_type().into() } @@ -1432,7 +1436,10 @@ impl BeaconProcessor { beacon_block_root: _, process_fn, } => task_spawner.spawn_async(process_fn), - Work::RpcBlock { process_fn } + Work::RpcBlock { + process_fn, + beacon_block_root: _, + } | Work::RpcBlobs { process_fn } | Work::RpcCustodyColumn(process_fn) | Work::ColumnReconstruction(process_fn) => task_spawner.spawn_async(process_fn), diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index 6b247a4cd4..8591359f15 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -18,6 +18,7 @@ use ssz_types::VariableList; use std::cmp::max; use std::collections::HashMap; use std::sync::Arc; +use tracing::warn; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use types::{ @@ -537,6 +538,21 @@ impl ExecutionBlockGenerator { .contains_key(&forkchoice_state.finalized_block_hash); if unknown_head_block_hash || unknown_safe_block_hash || unknown_finalized_block_hash { + if unknown_head_block_hash { + warn!(?head_block_hash, "Received unknown head block hash"); + } + if unknown_safe_block_hash { + warn!( + safe_block_hash = ?forkchoice_state.safe_block_hash, + "Received unknown safe block hash" + ); + } + if unknown_finalized_block_hash { + warn!( + finalized_block_hash = ?forkchoice_state.finalized_block_hash, + "Received unknown finalized block hash" + ) + } return Ok(JsonForkchoiceUpdatedV1Response { payload_status: JsonPayloadStatusV1 { status: JsonPayloadStatusV1Status::Syncing, diff --git a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs index 2168ed8961..53eb3b5166 100644 --- a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs +++ b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs @@ -5,6 +5,7 @@ use crate::test_utils::{DEFAULT_CLIENT_VERSION, DEFAULT_MOCK_EL_PAYLOAD_VALUE_WE use serde::{Deserialize, de::DeserializeOwned}; use serde_json::Value as JsonValue; use std::sync::Arc; +use tracing::debug; pub const GENERIC_ERROR_CODE: i64 = -1234; pub const BAD_PARAMS_ERROR_CODE: i64 = -32602; @@ -28,6 +29,8 @@ pub async fn handle_rpc( .ok_or_else(|| "missing/invalid params field".to_string()) .map_err(|s| (s, GENERIC_ERROR_CODE))?; + debug!(method, "Mock execution engine"); + match method { ETH_SYNCING => ctx .syncing_response @@ -517,6 +520,12 @@ pub async fn handle_rpc( _ => unreachable!(), }; + debug!( + ?payload_attributes, + ?forkchoice_state, + "ENGINE_FORKCHOICE_UPDATED" + ); + // validate method called correctly according to fork time if let Some(pa) = payload_attributes.as_ref() { match ctx diff --git a/beacon_node/lighthouse_network/src/rpc/protocol.rs b/beacon_node/lighthouse_network/src/rpc/protocol.rs index 34d8efccd1..b75ca72eda 100644 --- a/beacon_node/lighthouse_network/src/rpc/protocol.rs +++ b/beacon_node/lighthouse_network/src/rpc/protocol.rs @@ -731,7 +731,7 @@ where } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, IntoStaticStr)] pub enum RequestType { Status(StatusMessage), Goodbye(GoodbyeReason), diff --git a/beacon_node/lighthouse_network/src/service/api_types.rs b/beacon_node/lighthouse_network/src/service/api_types.rs index f1a4d87de7..d0323bab52 100644 --- a/beacon_node/lighthouse_network/src/service/api_types.rs +++ b/beacon_node/lighthouse_network/src/service/api_types.rs @@ -135,7 +135,7 @@ pub struct CustodyId { pub struct CustodyRequester(pub SingleLookupReqId); /// Application level requests sent to the network. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum AppRequestId { Sync(SyncRequestId), Router, diff --git a/beacon_node/network/Cargo.toml b/beacon_node/network/Cargo.toml index 78dc0c48a7..68c77252ab 100644 --- a/beacon_node/network/Cargo.toml +++ b/beacon_node/network/Cargo.toml @@ -8,6 +8,7 @@ edition = { workspace = true } # NOTE: This can be run via cargo build --bin lighthouse --features network/disable-backfill disable-backfill = [] fork_from_env = ["beacon_chain/fork_from_env"] +fake_crypto = ["bls/fake_crypto", "kzg/fake_crypto"] portable = ["beacon_chain/portable"] test_logger = [] @@ -57,6 +58,7 @@ k256 = "0.13.4" kzg = { workspace = true } libp2p = { workspace = true } matches = "0.1.8" +paste = { workspace = true } rand_08 = { package = "rand", version = "0.8.5" } rand_chacha = "0.9.0" rand_chacha_03 = { package = "rand_chacha", version = "0.3.1" } diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index fd67fcde82..e1adf860de 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -526,7 +526,10 @@ impl NetworkBeaconProcessor { ); self.try_send(BeaconWorkEvent { drop_during_sync: false, - work: Work::RpcBlock { process_fn }, + work: Work::RpcBlock { + process_fn, + beacon_block_root: block_root, + }, }) } 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 a6b3ea9e4b..629a42c688 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -219,7 +219,7 @@ impl NetworkBeaconProcessor { // to be sent from the peers if we already have them. let publish_blobs = false; self.fetch_engine_blobs_and_publish(signed_beacon_block, block_root, publish_blobs) - .await + .await; } _ => {} } diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index 49b1c0c262..aa03ee931d 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -940,20 +940,20 @@ async fn data_column_reconstruction_at_deadline() { .set_current_time(slot_start + Duration::from_millis(reconstruction_deadline_millis)); let min_columns_for_reconstruction = E::number_of_columns() / 2; + + // Enqueue all columns first - at deadline, reconstruction races with gossip drain for i in 0..min_columns_for_reconstruction { rig.enqueue_gossip_data_columns(i); - rig.assert_event_journal_completes(&[WorkType::GossipDataColumnSidecar]) - .await; } - // Since we're at the reconstruction deadline, reconstruction should be triggered immediately - rig.assert_event_journal_with_timeout( - &[WorkType::ColumnReconstruction.into()], - Duration::from_millis(50), - false, - false, - ) - .await; + // Expect all gossip events + reconstruction + let mut expected_events: Vec = (0..min_columns_for_reconstruction) + .map(|_| WorkType::GossipDataColumnSidecar) + .collect(); + expected_events.push(WorkType::ColumnReconstruction); + + rig.assert_event_journal_contains_ordered(&expected_events) + .await; } // Test the column reconstruction is delayed for columns that arrive for a previous slot. diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index 9065f05753..cbf65505ef 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -121,15 +121,24 @@ pub struct BlockLookups { // TODO: Why not index lookups by block_root? single_block_lookups: FnvHashMap>, + + /// Used for testing assertions + metrics: BlockLookupsMetrics, } #[cfg(test)] use lighthouse_network::service::api_types::Id; #[cfg(test)] -/// Tuple of `SingleLookupId`, requested block root, awaiting parent block root (if any), -/// and list of peers that claim to have imported this set of block components. -pub(crate) type BlockLookupSummary = (Id, Hash256, Option, Vec); +#[derive(Debug)] +pub(crate) struct BlockLookupSummary { + /// Lookup ID + pub id: Id, + /// Requested block root + pub block_root: Hash256, + /// List of peers that claim to have imported this set of block components. + pub peers: Vec, +} impl BlockLookups { pub fn new() -> Self { @@ -138,9 +147,15 @@ impl BlockLookups { IGNORED_CHAINS_CACHE_EXPIRY_SECONDS, )), single_block_lookups: Default::default(), + metrics: <_>::default(), } } + #[cfg(test)] + pub(crate) fn metrics(&self) -> &BlockLookupsMetrics { + &self.metrics + } + #[cfg(test)] pub(crate) fn insert_ignored_chain(&mut self, block_root: Hash256) { self.ignored_chains.insert(block_root); @@ -155,7 +170,11 @@ impl BlockLookups { pub(crate) fn active_single_lookups(&self) -> Vec { self.single_block_lookups .iter() - .map(|(id, l)| (*id, l.block_root(), l.awaiting_parent(), l.all_peers())) + .map(|(id, l)| BlockLookupSummary { + id: *id, + block_root: l.block_root(), + peers: l.all_peers(), + }) .collect() } @@ -306,7 +325,7 @@ impl BlockLookups { // attributability. A peer can send us garbage blocks over blocks_by_root, and // then correct blocks via blocks_by_range. - self.drop_lookup_and_children(*lookup_id); + self.drop_lookup_and_children(*lookup_id, "chain_too_long"); } else { // Should never happen error!( @@ -414,6 +433,7 @@ impl BlockLookups { "Created block lookup" ); metrics::inc_counter(&metrics::SYNC_LOOKUP_CREATED); + self.metrics.created_lookups += 1; let result = lookup.continue_requests(cx); if self.on_lookup_result(id, result, "new_current_lookup", cx) { @@ -513,8 +533,11 @@ impl BlockLookups { /* Error responses */ pub fn peer_disconnected(&mut self, peer_id: &PeerId) { - for (_, lookup) in self.single_block_lookups.iter_mut() { + for (id, lookup) in self.single_block_lookups.iter_mut() { lookup.remove_peer(peer_id); + if lookup.has_no_peers() { + debug!(%id, "Lookup has no peers"); + } } } @@ -566,7 +589,8 @@ impl BlockLookups { let action = match result { BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(_)) - | BlockProcessingResult::Err(BlockError::DuplicateFullyImported(..)) => { + | BlockProcessingResult::Err(BlockError::DuplicateFullyImported(..)) + | BlockProcessingResult::Err(BlockError::GenesisBlock) => { // Successfully imported request_state.on_processing_success()?; Action::Continue @@ -747,6 +771,15 @@ impl BlockLookups { let lookup_result = if imported { Ok(LookupResult::Completed) } else { + // A lookup may be in the following state: + // - Block awaiting processing from a different source + // - Blobs downloaded processed, and inserted into the da_checker + // + // At this point the block fails processing (e.g. execution engine offline) and it is + // removed from the da_checker. Note that ALL components are removed from the da_checker + // so when we re-download and process the block we get the error + // MissingComponentsAfterAllProcessed and get stuck. + lookup.reset_requests(); lookup.continue_requests(cx) }; let id = *id; @@ -779,14 +812,17 @@ impl BlockLookups { /// Drops `dropped_id` lookup and all its children recursively. Lookups awaiting a parent need /// the parent to make progress to resolve, therefore we must drop them if the parent is /// dropped. - pub fn drop_lookup_and_children(&mut self, dropped_id: SingleLookupId) { + pub fn drop_lookup_and_children(&mut self, dropped_id: SingleLookupId, reason: &'static str) { if let Some(dropped_lookup) = self.single_block_lookups.remove(&dropped_id) { debug!( id = ?dropped_id, block_root = ?dropped_lookup.block_root(), awaiting_parent = ?dropped_lookup.awaiting_parent(), + reason, "Dropping lookup" ); + metrics::inc_counter_vec(&metrics::SYNC_LOOKUP_DROPPED, &[reason]); + self.metrics.dropped_lookups += 1; let child_lookups = self .single_block_lookups @@ -796,7 +832,7 @@ impl BlockLookups { .collect::>(); for id in child_lookups { - self.drop_lookup_and_children(id); + self.drop_lookup_and_children(id, reason); } } } @@ -814,8 +850,13 @@ impl BlockLookups { Ok(LookupResult::Pending) => true, // no action Ok(LookupResult::Completed) => { if let Some(lookup) = self.single_block_lookups.remove(&id) { - debug!(block = ?lookup.block_root(), id, "Dropping completed lookup"); + debug!( + block = ?lookup.block_root(), + id, + "Dropping completed lookup" + ); metrics::inc_counter(&metrics::SYNC_LOOKUP_COMPLETED); + self.metrics.completed_lookups += 1; // Block imported, continue the requests of pending child blocks self.continue_child_lookups(lookup.block_root(), cx); self.update_metrics(); @@ -829,8 +870,7 @@ impl BlockLookups { Err(LookupRequestError::UnknownLookup) => false, Err(error) => { debug!(id, source, ?error, "Dropping lookup on request error"); - metrics::inc_counter_vec(&metrics::SYNC_LOOKUP_DROPPED, &[error.into()]); - self.drop_lookup_and_children(id); + self.drop_lookup_and_children(id, error.into()); self.update_metrics(); false } @@ -897,7 +937,7 @@ impl BlockLookups { %block_root, "Dropping lookup with no peers" ); - self.drop_lookup_and_children(lookup_id); + self.drop_lookup_and_children(lookup_id, "no_peers"); } } @@ -946,7 +986,7 @@ impl BlockLookups { } metrics::inc_counter(&metrics::SYNC_LOOKUPS_STUCK); - self.drop_lookup_and_children(ancestor_stuck_lookup.id); + self.drop_lookup_and_children(ancestor_stuck_lookup.id, "lookup_stuck"); } } @@ -1022,3 +1062,10 @@ impl BlockLookups { } } } + +#[derive(Default, Clone, Debug)] +pub(crate) struct BlockLookupsMetrics { + pub created_lookups: usize, + pub dropped_lookups: usize, + pub completed_lookups: usize, +} 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 43bfe29a84..919526c238 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 @@ -109,6 +109,12 @@ impl SingleBlockLookup { } } + /// Reset the status of all internal requests + pub fn reset_requests(&mut self) { + self.block_request_state = BlockRequestState::new(self.block_root); + self.component_requests = ComponentRequests::WaitingForBlock; + } + /// Return the slot of this lookup's block if it's currently cached as `AwaitingProcessing` pub fn peek_downloaded_block_slot(&self) -> Option { self.block_request_state diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 096ed9c328..c1ab6221dd 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -70,6 +70,7 @@ use slot_clock::SlotClock; use std::ops::Sub; use std::sync::Arc; use std::time::Duration; +use strum::IntoStaticStr; use tokio::sync::mpsc; use tracing::{debug, error, info, trace}; use types::{ @@ -90,7 +91,7 @@ pub const SLOT_IMPORT_TOLERANCE: usize = 32; /// arbitrary number that covers a full slot, but allows recovery if sync get stuck for a few slots. const NOTIFIED_UNKNOWN_ROOT_EXPIRY_SECONDS: u64 = 30; -#[derive(Debug)] +#[derive(Debug, IntoStaticStr)] /// A message that can be sent to the sync manager thread. pub enum SyncMessage { /// A useful peer has been discovered. @@ -323,17 +324,18 @@ impl SyncManager { } #[cfg(test)] - pub(crate) fn active_single_lookups(&self) -> Vec { - self.block_lookups.active_single_lookups() + pub(crate) fn send_sync_message(&mut self, sync_message: SyncMessage<::EthSpec>) { + self.network.send_sync_message(sync_message); } #[cfg(test)] - pub(crate) fn active_parent_lookups(&self) -> Vec> { - self.block_lookups - .active_parent_lookups() - .iter() - .map(|c| c.chain.clone()) - .collect() + pub(crate) fn block_lookups(&self) -> &BlockLookups { + &self.block_lookups + } + + #[cfg(test)] + pub(crate) fn range_sync(&self) -> &RangeSync { + &self.range_sync } #[cfg(test)] @@ -512,17 +514,18 @@ impl SyncManager { /// there is no way to guarantee that libp2p always emits a error along with /// the disconnect. fn peer_disconnect(&mut self, peer_id: &PeerId) { - // Inject a Disconnected error on all requests associated with the disconnected peer - // to retry all batches/lookups - for sync_request_id in self.network.peer_disconnected(peer_id) { - self.inject_error(*peer_id, sync_request_id, RPCError::Disconnected); - } - // Remove peer from all data structures self.range_sync.peer_disconnect(&mut self.network, peer_id); let _ = self.backfill_sync.peer_disconnected(peer_id); self.block_lookups.peer_disconnected(peer_id); + // Inject a Disconnected error on all requests associated with the disconnected peer + // to retry all batches/lookups. Only after removing the peer from the data structures to + // avoid sending retry requests to the disconnecting peer. + for sync_request_id in self.network.peer_disconnected(peer_id) { + self.inject_error(*peer_id, sync_request_id, RPCError::Disconnected); + } + // Regardless of the outcome, we update the sync status. self.update_sync_state(); } diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index 542625b8a3..7e2c0d9a94 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -17,7 +17,7 @@ use crate::sync::block_lookups::SingleLookupId; use crate::sync::block_sidecar_coupling::CouplingError; use crate::sync::network_context::requests::BlobsByRootSingleBlockRequest; use crate::sync::range_data_column_batch_request::RangeDataColumnBatchRequest; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::{AsBlock, RpcBlock}; use beacon_chain::{BeaconChain, BeaconChainTypes, BlockProcessStatus, EngineState}; use custody::CustodyRequestResult; use fnv::FnvHashMap; @@ -1095,13 +1095,14 @@ impl SyncNetworkContext { })?; // Include only the blob indexes not yet imported (received through gossip) - let custody_indexes_to_fetch = self + let mut custody_indexes_to_fetch = self .chain .sampling_columns_for_epoch(current_epoch) .iter() .copied() .filter(|index| !custody_indexes_imported.contains(index)) .collect::>(); + custody_indexes_to_fetch.sort_unstable(); if custody_indexes_to_fetch.is_empty() { // No indexes required, do not issue any request @@ -1595,7 +1596,7 @@ impl SyncNetworkContext { ) .map_err(|_| SendErrorProcessor::SendError)?; - debug!(block = ?block_root, id, "Sending block for processing"); + debug!(block = ?block_root, block_slot = %block.slot(), id, "Sending block for processing"); // Lookup sync event safety: If `beacon_processor.send_rpc_beacon_block` returns Ok() sync // must receive a single `SyncMessage::BlockComponentProcessed` with this process type beacon_processor diff --git a/beacon_node/network/src/sync/network_context/custody.rs b/beacon_node/network/src/sync/network_context/custody.rs index 61ae95ee70..de5d9b6e0b 100644 --- a/beacon_node/network/src/sync/network_context/custody.rs +++ b/beacon_node/network/src/sync/network_context/custody.rs @@ -198,7 +198,14 @@ impl ActiveCustodyRequest { cx: &mut SyncNetworkContext, ) -> CustodyRequestResult { let _guard = self.span.clone().entered(); - if self.column_requests.values().all(|r| r.is_downloaded()) { + let total_requests = self.column_requests.len(); + let completed_requests = self + .column_requests + .values() + .filter(|r| r.is_downloaded()) + .count(); + + if completed_requests >= total_requests { // All requests have completed successfully. let mut peers = HashMap::>::new(); let mut seen_timestamps = vec![]; @@ -222,6 +229,7 @@ impl ActiveCustodyRequest { let active_request_count_by_peer = cx.active_request_count_by_peer(); let mut columns_to_request_by_peer = HashMap::>::new(); + let mut columns_without_peers = vec![]; let lookup_peers = self.lookup_peers.read(); // Create deterministic hasher per request to ensure consistent peer ordering within // this request (avoiding fragmentation) while varying selection across different requests @@ -256,6 +264,7 @@ impl ActiveCustodyRequest { return Err(Error::NoPeer(*column_index)); } else { // Do not issue requests if there is no custody peer on this column + columns_without_peers.push(*column_index); } } } @@ -270,10 +279,13 @@ impl ActiveCustodyRequest { lookup_peers = lookup_peers.len(), "Requesting {} columns from {} peers", columns_requested_count, peer_requests, ); - } else { + } else if !columns_without_peers.is_empty() { debug!( lookup_peers = lookup_peers.len(), - "No column peers found for look up", + total_requests, + completed_requests, + ?columns_without_peers, + "No column peers found for lookup", ); } @@ -288,7 +300,7 @@ impl ActiveCustodyRequest { }, // If peer is in the lookup peer set, it claims to have imported the block and // must have its columns in custody. In that case, set `true = enforce max_requests` - // and downscore if data_columns_by_root does not returned the expected custody + // 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. lookup_peers.contains(&peer_id), ) diff --git a/beacon_node/network/src/sync/range_sync/chain_collection.rs b/beacon_node/network/src/sync/range_sync/chain_collection.rs index 1d57ee6c3d..b91b88b55c 100644 --- a/beacon_node/network/src/sync/range_sync/chain_collection.rs +++ b/beacon_node/network/src/sync/range_sync/chain_collection.rs @@ -41,6 +41,13 @@ pub enum RangeSyncState { pub type SyncChainStatus = Result, &'static str>; +#[cfg(test)] +#[derive(Default, Debug)] +pub struct ChainCollectionMetrics { + pub chains_added: usize, + pub chains_removed: usize, +} + /// A collection of finalized and head chains currently being processed. pub struct ChainCollection { /// The beacon chain for processing. @@ -51,6 +58,9 @@ pub struct ChainCollection { head_chains: FnvHashMap>, /// The current sync state of the process. state: RangeSyncState, + #[cfg(test)] + /// Used for testing assertions + metrics: ChainCollectionMetrics, } impl ChainCollection { @@ -60,12 +70,23 @@ impl ChainCollection { finalized_chains: FnvHashMap::default(), head_chains: FnvHashMap::default(), state: RangeSyncState::Idle, + #[cfg(test)] + metrics: <_>::default(), } } + #[cfg(test)] + pub(crate) fn metrics(&self) -> &ChainCollectionMetrics { + &self.metrics + } + /// Updates the Syncing state of the collection after a chain is removed. fn on_chain_removed(&mut self, id: &ChainId, was_syncing: bool, sync_type: RangeSyncType) { metrics::inc_counter_vec(&metrics::SYNCING_CHAINS_REMOVED, &[sync_type.as_str()]); + #[cfg(test)] + { + self.metrics.chains_removed += 1; + } self.update_metrics(); match self.state { @@ -510,6 +531,10 @@ impl ChainCollection { ); collection.insert(id, new_chain); metrics::inc_counter_vec(&metrics::SYNCING_CHAINS_ADDED, &[sync_type.as_str()]); + #[cfg(test)] + { + self.metrics.chains_added += 1; + } self.update_metrics(); } } diff --git a/beacon_node/network/src/sync/range_sync/range.rs b/beacon_node/network/src/sync/range_sync/range.rs index c9656ad1d0..86625444be 100644 --- a/beacon_node/network/src/sync/range_sync/range.rs +++ b/beacon_node/network/src/sync/range_sync/range.rs @@ -98,6 +98,11 @@ where self.failed_chains.keys().copied().collect() } + #[cfg(test)] + pub(crate) fn metrics(&self) -> &super::chain_collection::ChainCollectionMetrics { + self.chains.metrics() + } + pub fn state(&self) -> SyncChainStatus { self.chains.state() } diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index b6e96737d6..769a11d976 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -1,79 +1,171 @@ +use super::*; use crate::NetworkMessage; -use crate::network_beacon_processor::NetworkBeaconProcessor; -use crate::sync::block_lookups::{ - BlockLookupSummary, PARENT_DEPTH_TOLERANCE, SINGLE_BLOCK_LOOKUP_MAX_ATTEMPTS, -}; +use crate::network_beacon_processor::{InvalidBlockStorage, NetworkBeaconProcessor}; +use crate::sync::block_lookups::{BlockLookupSummary, PARENT_DEPTH_TOLERANCE}; use crate::sync::{ SyncMessage, manager::{BlockProcessType, BlockProcessingResult, SyncManager}, }; -use std::sync::Arc; -use std::time::Duration; - -use super::*; - -use crate::sync::block_lookups::common::ResponseType; -use beacon_chain::observed_data_sidecars::Observe; +use beacon_chain::blob_verification::KzgVerifiedBlob; +use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::{ - AvailabilityPendingExecutedBlock, AvailabilityProcessingStatus, BlockError, - PayloadVerificationOutcome, PayloadVerificationStatus, - blob_verification::GossipVerifiedBlob, - block_verification_types::{AsBlock, BlockImportData}, - custody_context::NodeCustodyType, + AvailabilityProcessingStatus, BlockError, NotifyExecutionLayer, + block_verification_types::{AsBlock, AvailableBlockData}, data_availability_checker::Availability, test_utils::{ - BeaconChainHarness, EphemeralHarnessType, NumBlobs, generate_rand_block_and_blobs, - generate_rand_block_and_data_columns, test_spec, + AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, NumBlobs, + generate_rand_block_and_blobs, test_spec, }, - validator_monitor::timestamp_now, }; -use beacon_processor::WorkEvent; +use beacon_processor::{BeaconProcessorChannels, DuplicateCache, Work, WorkEvent}; +use educe::Educe; +use itertools::Itertools; use lighthouse_network::discovery::CombinedKey; use lighthouse_network::{ NetworkConfig, NetworkGlobals, PeerId, - rpc::{RPCError, RequestType, RpcErrorResponse}, - service::api_types::{ - AppRequestId, DataColumnsByRootRequestId, DataColumnsByRootRequester, Id, - SingleLookupReqId, SyncRequestId, - }, + rpc::{RPCError, RequestType}, + service::api_types::{AppRequestId, SyncRequestId}, types::SyncState, }; use slot_clock::{SlotClock, TestingSlotClock}; +use std::sync::Arc; +use std::time::Duration; use tokio::sync::mpsc; use tracing::info; use types::{ - BeaconState, BeaconStateBase, BlobSidecar, BlockImportSource, DataColumnSidecar, EthSpec, - ForkContext, ForkName, Hash256, MinimalEthSpec as E, SignedBeaconBlock, Slot, - data::ColumnIndex, - test_utils::{SeedableRng, TestRandom, XorShiftRng}, + BlobSidecar, BlockImportSource, ColumnIndex, DataColumnSidecar, EthSpec, ForkContext, ForkName, + Hash256, MinimalEthSpec as E, SignedBeaconBlock, Slot, + test_utils::{SeedableRng, XorShiftRng}, }; const D: Duration = Duration::new(0, 0); -const PARENT_FAIL_TOLERANCE: u8 = SINGLE_BLOCK_LOOKUP_MAX_ATTEMPTS; -type DCByRootIds = Vec; -type DCByRootId = (SyncRequestId, Vec); -impl TestRig { - pub fn test_setup() -> Self { - Self::test_setup_with_custody_type(NodeCustodyType::Fullnode) +/// Configuration for how the test rig should respond to sync requests. +/// +/// Controls simulated peer behavior during lookup tests, including RPC errors, +/// invalid responses, and custom block processing results. Use builder methods +/// to configure specific failure scenarios. +#[derive(Default, Educe)] +#[educe(Debug)] +pub struct SimulateConfig { + return_rpc_error: Option, + return_wrong_blocks_n_times: usize, + return_wrong_sidecar_for_block_n_times: usize, + return_no_blocks_n_times: usize, + return_no_data_n_times: usize, + return_too_few_data_n_times: usize, + return_no_columns_on_indices_n_times: usize, + return_no_columns_on_indices: Vec, + skip_by_range_routes: bool, + // Use a callable fn because BlockProcessingResult does not implement Clone + #[educe(Debug(ignore))] + process_result_conditional: + Option Option + Send + Sync>>, + // Import a block directly before processing it (for simulating race conditions) + import_block_before_process: HashSet, +} + +impl SimulateConfig { + fn new() -> Self { + Self::default() } - pub fn test_setup_with_custody_type(node_custody_type: NodeCustodyType) -> Self { + fn happy_path() -> Self { + Self::default() + } + + fn return_no_blocks_always(mut self) -> Self { + self.return_no_blocks_n_times = usize::MAX; + self + } + + fn return_no_blocks_once(mut self) -> Self { + self.return_no_blocks_n_times = 1; + self + } + + fn return_no_data_once(mut self) -> Self { + self.return_no_data_n_times = 1; + self + } + + fn return_wrong_blocks_once(mut self) -> Self { + self.return_wrong_blocks_n_times = 1; + self + } + + fn return_wrong_sidecar_for_block_once(mut self) -> Self { + self.return_wrong_sidecar_for_block_n_times = 1; + self + } + + fn return_too_few_data_once(mut self) -> Self { + self.return_too_few_data_n_times = 1; + self + } + + fn return_no_columns_on_indices(mut self, indices: &[ColumnIndex], times: usize) -> Self { + self.return_no_columns_on_indices_n_times = times; + self.return_no_columns_on_indices = indices.to_vec(); + self + } + + fn return_rpc_error(mut self, error: RPCError) -> Self { + self.return_rpc_error = Some(error); + self + } + + fn no_range_sync(mut self) -> Self { + self.skip_by_range_routes = true; + self + } + + fn with_process_result(mut self, f: F) -> Self + where + F: Fn() -> BlockProcessingResult + Send + Sync + 'static, + { + self.process_result_conditional = Some(Box::new(move |_| Some(f()))); + self + } + + fn with_import_block_before_process(mut self, block_root: Hash256) -> Self { + self.import_block_before_process.insert(block_root); + self + } +} + +fn genesis_fork() -> ForkName { + test_spec::().fork_name_at_slot::(Slot::new(0)) +} + +pub(crate) struct TestRigConfig { + fulu_test_type: FuluTestType, + /// Override the node custody type derived from `fulu_test_type` + node_custody_type_override: Option, +} + +impl TestRig { + pub(crate) fn new(test_rig_config: TestRigConfig) -> Self { // Use `fork_from_env` logic to set correct fork epochs - let spec = test_spec::(); + let spec = Arc::new(test_spec::()); + let clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + Duration::from_secs(12), + ); // Initialise a new beacon chain let harness = BeaconChainHarness::>::builder(E) - .spec(Arc::new(spec)) + .spec(spec.clone()) .deterministic_keypairs(1) .fresh_ephemeral_store() .mock_execution_layer() - .testing_slot_clock(TestingSlotClock::new( - Slot::new(0), - Duration::from_secs(0), - Duration::from_secs(12), - )) - .node_custody_type(node_custody_type) + .testing_slot_clock(clock.clone()) + .node_custody_type( + test_rig_config + .node_custody_type_override + .unwrap_or_else(|| test_rig_config.fulu_test_type.we_node_custody_type()), + ) .build(); let chain = harness.chain.clone(); @@ -93,12 +185,23 @@ impl TestRig { network_config, chain.spec.clone(), )); - let (beacon_processor, beacon_processor_rx) = NetworkBeaconProcessor::null_for_testing( - globals, + + let BeaconProcessorChannels { + beacon_processor_tx, + beacon_processor_rx, + } = <_>::default(); + + let beacon_processor = NetworkBeaconProcessor { + beacon_processor_send: beacon_processor_tx, + duplicate_cache: DuplicateCache::default(), + chain: chain.clone(), + // TODO: What is this sender used for? + network_tx: mpsc::unbounded_channel().0, sync_tx, - chain.clone(), - harness.runtime.task_executor.clone(), - ); + network_globals: globals.clone(), + invalid_block_storage: InvalidBlockStorage::Disabled, + executor: harness.runtime.task_executor.clone(), + }; let fork_name = chain.spec.fork_name_at_slot::(chain.slot().unwrap()); @@ -119,6 +222,7 @@ impl TestRig { network_rx, network_rx_queue: vec![], sync_rx, + sync_rx_queue: vec![], rng_08, rng, network_globals: beacon_processor.network_globals.clone(), @@ -132,36 +236,985 @@ impl TestRig { ), harness, fork_name, + network_blocks_by_root: <_>::default(), + network_blocks_by_slot: <_>::default(), + penalties: <_>::default(), + seen_lookups: <_>::default(), + requests: <_>::default(), + complete_strategy: <_>::default(), + initial_block_lookups_metrics: <_>::default(), + fulu_test_type: test_rig_config.fulu_test_type, } } - fn test_setup_after_deneb_before_fulu() -> Option { - let r = Self::test_setup(); - if r.after_deneb() && !r.fork_name.fulu_enabled() { - Some(r) + pub fn default() -> Self { + // Before Fulu, FuluTestType is irrelevant + Self::new(TestRigConfig { + fulu_test_type: FuluTestType::WeFullnodeThemSupernode, + node_custody_type_override: None, + }) + } + + pub fn with_custody_type(node_custody_type: NodeCustodyType) -> Self { + Self::new(TestRigConfig { + fulu_test_type: FuluTestType::WeFullnodeThemSupernode, + node_custody_type_override: Some(node_custody_type), + }) + } + + /// Runs the sync simulation until all event queues are empty. + /// + /// Processes events from sync_rx (sink), beacon processor, and network queues in fixed + /// priority order each tick. Handles completed work before pulling new requests. + async fn simulate(&mut self, complete_strategy: SimulateConfig) { + self.complete_strategy = complete_strategy; + self.log(&format!( + "Running simulate with config {:?}", + self.complete_strategy + )); + + let mut i = 0; + + loop { + i += 1; + + // Record current status + for BlockLookupSummary { + id, + block_root, + peers, + .. + } in self.active_single_lookups() + { + let lookup = self.seen_lookups.entry(id).or_insert(SeenLookup { + id, + block_root, + seen_peers: <_>::default(), + }); + for peer in peers { + lookup.seen_peers.insert(peer); + } + } + + // Drain all channels into queues + while let Ok(ev) = self.network_rx.try_recv() { + self.network_rx_queue.push(ev); + } + while let Ok(ev) = self.beacon_processor_rx.try_recv() { + self.beacon_processor_rx_queue.push(ev); + } + while let Ok(ev) = self.sync_rx.try_recv() { + self.sync_rx_queue.push(ev); + } + + // Process one event per tick in fixed priority: sink → processor → network + if !self.sync_rx_queue.is_empty() { + let sync_message = self.sync_rx_queue.remove(0); + self.log(&format!( + "Tick {i}: sync_rx event: {}", + Into::<&'static str>::into(&sync_message) + )); + self.sync_manager.handle_message(sync_message); + } else if !self.beacon_processor_rx_queue.is_empty() { + let event = self.beacon_processor_rx_queue.remove(0); + self.log(&format!("Tick {i}: beacon_processor event: {event:?}")); + match event.work { + Work::RpcBlock { + process_fn, + beacon_block_root, + } => { + // Import block before processing if configured (for simulating race conditions) + if self + .complete_strategy + .import_block_before_process + .contains(&beacon_block_root) + { + self.log(&format!( + "Importing block {} before processing (race condition simulation)", + beacon_block_root + )); + self.import_block_by_root(beacon_block_root).await; + } + + if let Some(f) = self.complete_strategy.process_result_conditional.as_ref() + && let Some(result) = f(beacon_block_root) + { + let id = self.lookup_by_root(beacon_block_root).id; + self.log(&format!( + "Sending custom process result to lookup id {id}: {result:?}" + )); + self.push_sync_message(SyncMessage::BlockComponentProcessed { + process_type: BlockProcessType::SingleBlock { id }, + result, + }); + } else { + process_fn.await + } + } + Work::RpcBlobs { process_fn } + | Work::RpcCustodyColumn(process_fn) + | Work::ChainSegment(process_fn) => process_fn.await, + Work::Reprocess(_) => {} // ignore + other => panic!("Unsupported Work event {}", other.str_id()), + } + } else if !self.network_rx_queue.is_empty() { + let event = self.network_rx_queue.remove(0); + self.log(&format!("Tick {i}: network_rx event: {event:?}")); + match event { + NetworkMessage::SendRequest { + peer_id, + request, + app_request_id, + } => { + self.simulate_on_request(peer_id, request, app_request_id); + } + NetworkMessage::ReportPeer { peer_id, msg, .. } => { + self.penalties.push(ReportedPenalty { peer_id, msg }); + } + _ => {} + } + } else { + break; + } + } + + self.log("No more events in simulation"); + self.log(&format!( + "Lookup metrics: {:?}", + self.sync_manager.block_lookups().metrics() + )); + self.log(&format!( + "Range sync metrics: {:?}", + self.sync_manager.range_sync().metrics() + )); + self.log(&format!( + "Max known slot: {}, Head slot: {}", + self.max_known_slot(), + self.head_slot() + )); + self.log(&format!("Penalties: {:?}", self.penalties)); + self.log(&format!( + "Total requests {}: {:?}", + self.requests.len(), + self.requests_count() + )) + } + + fn simulate_on_request( + &mut self, + peer_id: PeerId, + request: RequestType, + app_req_id: AppRequestId, + ) { + self.requests.push((request.clone(), app_req_id)); + + if let AppRequestId::Sync(req_id) = app_req_id + && let Some(error) = self.complete_strategy.return_rpc_error.take() + { + self.log(&format!( + "Completing request {req_id:?} to {peer_id} with RPCError {error:?}" + )); + self.send_sync_message(SyncMessage::RpcError { + sync_request_id: req_id, + peer_id, + error, + }); + return; + } + + match (request, app_req_id) { + (RequestType::BlocksByRoot(req), AppRequestId::Sync(req_id)) => { + let blocks = + req.block_roots() + .iter() + .filter_map(|block_root| { + if self.complete_strategy.return_no_blocks_n_times > 0 { + self.complete_strategy.return_no_blocks_n_times -= 1; + None + } else if self.complete_strategy.return_wrong_blocks_n_times > 0 { + self.complete_strategy.return_wrong_blocks_n_times -= 1; + Some(Arc::new(self.rand_block())) + } else { + Some(self.network_blocks_by_root + .get(block_root) + .unwrap_or_else(|| { + panic!("Test consumer requested unknown block: {block_root:?}") + }) + .block_cloned()) + } + }) + .collect::>(); + + self.send_rpc_blocks_response(req_id, peer_id, &blocks); + } + + (RequestType::BlobsByRoot(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.return_no_data_n_times > 0 { + self.complete_strategy.return_no_data_n_times -= 1; + return self.send_rpc_blobs_response(req_id, peer_id, &[]); + } + + let mut blobs = req + .blob_ids + .iter() + .map(|id| { + self.network_blocks_by_root + .get(&id.block_root) + .unwrap_or_else(|| { + panic!("Test consumer requested unknown block: {id:?}") + }) + .block_data() + .and_then(|d| d.blobs()) + .unwrap_or_else(|| panic!("Block {id:?} has no blobs")) + .iter() + .find(|blob| blob.index == id.index) + .unwrap_or_else(|| panic!("Blob id {id:?} not avail")) + .clone() + }) + .collect::>(); + + if self.complete_strategy.return_too_few_data_n_times > 0 { + self.complete_strategy.return_too_few_data_n_times -= 1; + blobs.pop(); + } + + if self + .complete_strategy + .return_wrong_sidecar_for_block_n_times + > 0 + { + self.complete_strategy + .return_wrong_sidecar_for_block_n_times -= 1; + let first = blobs.first_mut().expect("empty blobs"); + let mut blob = Arc::make_mut(first).clone(); + blob.signed_block_header.message.body_root = Hash256::ZERO; + *first = Arc::new(blob); + } + + self.send_rpc_blobs_response(req_id, peer_id, &blobs); + } + + (RequestType::DataColumnsByRoot(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.return_no_data_n_times > 0 { + self.complete_strategy.return_no_data_n_times -= 1; + return self.send_rpc_columns_response(req_id, peer_id, &[]); + } + + 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) + }) + }); + let columns_to_omit = if will_omit_columns + && self.complete_strategy.return_no_columns_on_indices_n_times > 0 + { + self.log(&format!("OMIT {:?}", req)); + self.complete_strategy.return_no_columns_on_indices_n_times -= 1; + self.complete_strategy.return_no_columns_on_indices.clone() + } else { + vec![] + }; + + let mut columns = req + .data_column_ids + .iter() + .flat_map(|id| { + let block_columns = self + .network_blocks_by_root + .get(&id.block_root) + .unwrap_or_else(|| { + panic!("Test consumer requested unknown block: {id:?}") + }) + .block_data() + .and_then(|d| d.data_columns()) + .unwrap_or_else(|| panic!("Block id {id:?} has no columns")); + id.columns + .iter() + .filter(|index| !columns_to_omit.contains(index)) + .map(move |index| { + block_columns + .iter() + .find(|c| *c.index() == *index) + .unwrap_or_else(|| { + panic!("Column {index:?} {:?} not found", id.block_root) + }) + .clone() + }) + }) + .collect::>(); + + if self.complete_strategy.return_too_few_data_n_times > 0 { + self.complete_strategy.return_too_few_data_n_times -= 1; + columns.pop(); + } + + if self + .complete_strategy + .return_wrong_sidecar_for_block_n_times + > 0 + { + self.complete_strategy + .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; + } + self.send_rpc_columns_response(req_id, peer_id, &columns); + } + + (RequestType::BlocksByRange(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.skip_by_range_routes { + return; + } + let blocks = (*req.start_slot()..req.start_slot() + req.count()) + .filter_map(|slot| { + self.network_blocks_by_slot + .get(&Slot::new(slot)) + .map(|block| block.block_cloned()) + }) + .collect::>(); + + self.send_rpc_blocks_response(req_id, peer_id, &blocks); + } + + (RequestType::BlobsByRange(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.skip_by_range_routes { + return; + } + + // Note: This function is permissive, blocks may have zero blobs and it won't + // error. Some caveats: + // - The genesis block never has blobs + // - Some blocks may not have blobs as the blob count is random + let blobs = (req.start_slot..req.start_slot + req.count) + .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) + .filter_map(|block| block.block_data().and_then(|d| d.blobs())) + .flat_map(|blobs| blobs.into_iter()) + .collect::>(); + self.send_rpc_blobs_response(req_id, peer_id, &blobs); + } + + (RequestType::DataColumnsByRange(req), AppRequestId::Sync(req_id)) => { + if self.complete_strategy.skip_by_range_routes { + return; + } + // Note: This function is permissive, blocks may have zero columns and it won't + // error. Some caveats: + // - The genesis block never has columns + // - Some blocks may not have columns as the blob count is random + let columns = (req.start_slot..req.start_slot + req.count) + .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) + .filter_map(|block| block.block_data().and_then(|d| d.data_columns())) + .flat_map(|columns| { + columns + .into_iter() + .filter(|c| req.columns.contains(c.index())) + }) + .collect::>(); + self.send_rpc_columns_response(req_id, peer_id, &columns); + } + + (RequestType::Status(_req), AppRequestId::Router) => { + // Ignore Status requests for now + } + + other => panic!("Request not supported: {app_req_id:?} {other:?}"), + } + } + + fn send_rpc_blocks_response( + &mut self, + sync_request_id: SyncRequestId, + peer_id: PeerId, + blocks: &[Arc>], + ) { + let slots = blocks.iter().map(|block| block.slot()).collect::>(); + self.log(&format!( + "Completing request {sync_request_id:?} to {peer_id} with blocks {slots:?}" + )); + + for block in blocks { + self.push_sync_message(SyncMessage::RpcBlock { + sync_request_id, + peer_id, + beacon_block: Some(block.clone()), + seen_timestamp: D, + }); + } + self.push_sync_message(SyncMessage::RpcBlock { + sync_request_id, + peer_id, + beacon_block: None, + seen_timestamp: D, + }); + } + + fn send_rpc_blobs_response( + &mut self, + sync_request_id: SyncRequestId, + peer_id: PeerId, + blobs: &[Arc>], + ) { + let slots = blobs + .iter() + .map(|block| block.slot()) + .unique() + .collect::>(); + self.log(&format!( + "Completing request {sync_request_id:?} to {peer_id} with blobs {slots:?}" + )); + + for blob in blobs { + self.push_sync_message(SyncMessage::RpcBlob { + sync_request_id, + peer_id, + blob_sidecar: Some(blob.clone()), + seen_timestamp: D, + }); + } + self.push_sync_message(SyncMessage::RpcBlob { + sync_request_id, + peer_id, + blob_sidecar: None, + seen_timestamp: D, + }); + } + + fn send_rpc_columns_response( + &mut self, + sync_request_id: SyncRequestId, + peer_id: PeerId, + columns: &[Arc>], + ) { + let slots = columns + .iter() + .map(|block| block.slot()) + .unique() + .collect::>(); + let indices = columns + .iter() + .map(|column| *column.index()) + .unique() + .collect::>(); + self.log(&format!( + "Completing request {sync_request_id:?} to {peer_id} with columns {slots:?} indices {indices:?}" + )); + + for column in columns { + self.push_sync_message(SyncMessage::RpcDataColumn { + sync_request_id, + peer_id, + data_column: Some(column.clone()), + seen_timestamp: D, + }); + } + self.push_sync_message(SyncMessage::RpcDataColumn { + sync_request_id, + peer_id, + data_column: None, + seen_timestamp: D, + }); + } + + // Preparation steps + + /// Returns the block root of the tip of the built chain + async fn build_chain(&mut self, block_count: usize) -> Hash256 { + let mut blocks = vec![]; + + // Initialise a new beacon chain + let external_harness = BeaconChainHarness::>::builder(E) + .spec(self.harness.spec.clone()) + .deterministic_keypairs(1) + .fresh_ephemeral_store() + .mock_execution_layer() + .testing_slot_clock(self.harness.chain.slot_clock.clone()) + // Make the external harness a supernode so all columns are available + .node_custody_type(NodeCustodyType::Supernode) + .build(); + // Ensure all blocks have data. Otherwise, the triggers for unknown blob parent and unknown + // data column parent fail. + external_harness + .execution_block_generator() + .set_min_blob_count(1); + + // Add genesis block for completeness + let genesis_block = external_harness.get_head_block(); + self.network_blocks_by_root + .insert(genesis_block.canonical_root(), genesis_block.clone()); + self.network_blocks_by_slot + .insert(genesis_block.slot(), genesis_block); + + for i in 0..block_count { + external_harness.advance_slot(); + let block_root = external_harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + let block = external_harness.get_full_block(&block_root); + let block_root = block.canonical_root(); + let block_slot = block.slot(); + self.network_blocks_by_root + .insert(block_root, block.clone()); + self.network_blocks_by_slot.insert(block_slot, block); + self.log(&format!( + "Produced block {} index {i} in external harness", + block_slot, + )); + blocks.push((block_slot, block_root)); + } + + // Re-log to have a nice list of block roots at the end + for block in &blocks { + self.log(&format!("Build chain {block:?}")); + } + + // Auto-update the clock on the main harness to accept the blocks + self.harness + .set_current_slot(external_harness.get_current_slot()); + + blocks.last().expect("empty blocks").1 + } + + fn corrupt_last_block_signature(&mut self) { + let rpc_block = self.get_last_block().clone(); + let mut block = (*rpc_block.block_cloned()).clone(); + let blobs = rpc_block.block_data().and_then(|d| d.blobs()); + let columns = rpc_block.block_data().and_then(|d| d.data_columns()); + *block.signature_mut() = self.valid_signature(); + self.re_insert_block(Arc::new(block), blobs, columns); + } + + fn valid_signature(&mut self) -> bls::Signature { + let keypair = bls::Keypair::random(); + let msg = Hash256::random(); + keypair.sk.sign(msg) + } + + fn corrupt_last_blob_proposer_signature(&mut self) { + let rpc_block = self.get_last_block().clone(); + let block = rpc_block.block_cloned(); + let mut blobs = rpc_block + .block_data() + .and_then(|d| d.blobs()) + .expect("no blobs") + .into_iter() + .collect::>(); + let columns = rpc_block.block_data().and_then(|d| d.data_columns()); + let first = blobs.first_mut().expect("empty blobs"); + Arc::make_mut(first).signed_block_header.signature = self.valid_signature(); + let max_blobs = + self.harness + .spec + .max_blobs_per_block(block.slot().epoch(E::slots_per_epoch())) as usize; + let blobs = + types::BlobSidecarList::new(blobs, max_blobs).expect("invalid blob sidecar list"); + self.re_insert_block(block, Some(blobs), columns); + } + + fn corrupt_last_blob_kzg_proof(&mut self) { + let rpc_block = self.get_last_block().clone(); + let block = rpc_block.block_cloned(); + let mut blobs = rpc_block + .block_data() + .and_then(|d| d.blobs()) + .expect("no blobs") + .into_iter() + .collect::>(); + let columns = rpc_block.block_data().and_then(|d| d.data_columns()); + let first = blobs.first_mut().expect("empty blobs"); + Arc::make_mut(first).kzg_proof = kzg::KzgProof::empty(); + let max_blobs = + self.harness + .spec + .max_blobs_per_block(block.slot().epoch(E::slots_per_epoch())) as usize; + let blobs = + types::BlobSidecarList::new(blobs, max_blobs).expect("invalid blob sidecar list"); + self.re_insert_block(block, Some(blobs), columns); + } + + fn corrupt_last_column_proposer_signature(&mut self) { + let rpc_block = self.get_last_block().clone(); + let block = rpc_block.block_cloned(); + let blobs = rpc_block.block_data().and_then(|d| d.blobs()); + let mut columns = rpc_block + .block_data() + .and_then(|d| d.data_columns()) + .expect("no columns"); + let first = columns.first_mut().expect("empty columns"); + Arc::make_mut(first) + .signed_block_header_mut() + .expect("not fulu") + .signature = self.valid_signature(); + self.re_insert_block(block, blobs, Some(columns)); + } + + fn corrupt_last_column_kzg_proof(&mut self) { + let rpc_block = self.get_last_block().clone(); + let block = rpc_block.block_cloned(); + let blobs = rpc_block.block_data().and_then(|d| d.blobs()); + let mut columns = rpc_block + .block_data() + .and_then(|d| d.data_columns()) + .expect("no columns"); + let first = columns.first_mut().expect("empty columns"); + let column = Arc::make_mut(first); + let proof = column.kzg_proofs_mut().first_mut().expect("no kzg proofs"); + *proof = kzg::KzgProof::empty(); + self.re_insert_block(block, blobs, Some(columns)); + } + + fn get_last_block(&self) -> &RpcBlock { + let (_, last_block) = self + .network_blocks_by_root + .iter() + .max_by_key(|(_, block)| block.slot()) + .expect("no blocks"); + last_block + } + + fn re_insert_block( + &mut self, + block: Arc>, + blobs: Option>, + columns: Option>, + ) { + self.network_blocks_by_slot.clear(); + self.network_blocks_by_root.clear(); + let block_root = block.canonical_root(); + let block_slot = block.slot(); + let block_data = if let Some(columns) = columns { + Some(AvailableBlockData::new_with_data_columns(columns)) + } else if let Some(blobs) = blobs { + Some(AvailableBlockData::new_with_blobs(blobs)) + } else { + Some(AvailableBlockData::NoData) + }; + let rpc_block = RpcBlock::new( + block, + block_data, + &self.harness.chain.data_availability_checker, + self.harness.chain.spec.clone(), + ) + .unwrap(); + self.network_blocks_by_slot + .insert(block_slot, rpc_block.clone()); + self.network_blocks_by_root.insert(block_root, rpc_block); + } + + /// Trigger a lookup with the last created block + fn trigger_with_last_block(&mut self) { + let peer_id = match self.fulu_test_type.them_node_custody_type() { + NodeCustodyType::Fullnode => self.new_connected_peer(), + NodeCustodyType::Supernode | NodeCustodyType::SemiSupernode => { + self.new_connected_supernode_peer() + } + }; + let last_block = self.get_last_block().canonical_root(); + self.trigger_unknown_block_from_attestation(last_block, peer_id); + } + + fn block_at_slot(&self, slot: u64) -> Arc> { + self.network_blocks_by_slot + .get(&Slot::new(slot)) + .unwrap_or_else(|| panic!("No block for slot {slot}")) + .block_cloned() + } + + fn block_root_at_slot(&self, slot: u64) -> Hash256 { + self.block_at_slot(slot).canonical_root() + } + + fn trigger_with_block_at_slot(&mut self, slot: u64) { + let peer_id = self.new_connected_supernode_peer(); + let block = self.block_at_slot(slot); + self.trigger_unknown_block_from_attestation(block.canonical_root(), peer_id); + } + + async fn build_chain_and_trigger_last_block(&mut self, block_count: usize) { + self.build_chain(block_count).await; + self.trigger_with_last_block(); + } + + /// Import a block directly into the chain without going through lookup sync + async fn import_block_by_root(&mut self, block_root: Hash256) { + let rpc_block = self + .network_blocks_by_root + .get(&block_root) + .unwrap_or_else(|| panic!("No block for root {block_root}")) + .clone(); + + self.harness + .chain + .process_block( + block_root, + rpc_block, + NotifyExecutionLayer::Yes, + BlockImportSource::Gossip, + || Ok(()), + ) + .await + .unwrap(); + + self.harness.chain.recompute_head_at_current_slot().await; + } + + fn trigger_with_last_unknown_block_parent(&mut self) { + let peer_id = self.new_connected_supernode_peer(); + let last_block = self.get_last_block().block_cloned(); + self.trigger_unknown_parent_block(peer_id, last_block); + } + + fn trigger_with_last_unknown_blob_parent(&mut self) { + let peer_id = self.new_connected_supernode_peer(); + let blobs = self + .get_last_block() + .block_data() + .and_then(|d| d.blobs()) + .expect("no blobs"); + let blob = blobs.first().expect("empty blobs"); + self.trigger_unknown_parent_blob(peer_id, blob.clone()); + } + + fn trigger_with_last_unknown_data_column_parent(&mut self) { + let peer_id = self.new_connected_supernode_peer(); + let columns = self + .get_last_block() + .block_data() + .and_then(|d| d.data_columns()) + .expect("No data columns"); + let column = columns.first().expect("empty columns"); + self.trigger_unknown_parent_column(peer_id, column.clone()); + } + + // Post-test assertions + + fn head_slot(&self) -> Slot { + self.harness.chain.head().head_slot() + } + + fn assert_head_slot(&self, slot: u64) { + assert_eq!(self.head_slot(), Slot::new(slot), "Unexpected head slot"); + } + + fn max_known_slot(&self) -> Slot { + self.network_blocks_by_slot + .keys() + .max() + .copied() + .expect("no blocks") + } + + fn assert_penalties(&self, expected_penalties: &[&'static str]) { + let penalties = self + .penalties + .iter() + .map(|penalty| penalty.msg) + .collect::>(); + if penalties != expected_penalties { + panic!( + "Expected penalties: {:#?} but got {:#?}", + expected_penalties, + self.penalties + .iter() + .map(|p| format!("{} for peer {}", p.msg, p.peer_id)) + .collect::>() + ); + } + } + + fn assert_penalties_of_type(&self, expected_penalty: &'static str) { + if self.penalties.is_empty() { + panic!("No penalties but expected some of type {expected_penalty}"); + } + let non_matching_penalties = self + .penalties + .iter() + .filter(|penalty| penalty.msg != expected_penalty) + .collect::>(); + if !non_matching_penalties.is_empty() { + panic!( + "Found non-matching penalties to {}: {:?}", + expected_penalty, non_matching_penalties + ); + } + } + + fn assert_no_penalties(&mut self) { + if !self.penalties.is_empty() { + panic!("Some downscore events: {:?}", self.penalties); + } + } + fn assert_failed_lookup_sync(&mut self) { + assert!(self.created_lookups() > 0, "no created lookups"); + assert_eq!(self.completed_lookups(), 0, "some completed lookups"); + assert_eq!( + self.dropped_lookups(), + self.created_lookups(), + "not all dropped. Current lookups {:?}", + self.active_single_lookups(), + ); + self.assert_empty_network(); + self.assert_no_active_lookups(); + } + + fn assert_successful_lookup_sync(&mut self) { + assert!(self.created_lookups() > 0, "no created lookups"); + assert_eq!(self.dropped_lookups(), 0, "some dropped lookups"); + assert_eq!( + self.completed_lookups(), + self.created_lookups(), + "not all lookups completed. Current lookups {:?}", + self.active_single_lookups(), + ); + self.assert_empty_network(); + self.assert_no_active_lookups(); + } + + /// There is a lookup created with the block that triggers the unknown message that can't be + /// completed because it has zero peers + fn assert_successful_lookup_sync_parent_trigger(&mut self) { + assert!(self.created_lookups() > 0, "no created lookups"); + assert_eq!( + self.completed_lookups() + 1, + self.created_lookups(), + "all completed" + ); + assert_eq!(self.dropped_lookups(), 0, "some dropped lookups"); + self.assert_empty_network(); + } + + fn assert_pending_lookup_sync(&self) { + assert!(self.created_lookups() > 0, "no created lookups"); + assert_eq!(self.dropped_lookups(), 0, "some dropped lookups"); + assert_eq!(self.completed_lookups(), 0, "some completed lookups"); + } + + /// Assert there is at least one range sync chain created and that all sync chains completed + fn assert_successful_range_sync(&self) { + assert!( + self.range_sync_chains_added() > 0, + "No created range sync chains" + ); + assert_eq!( + self.range_sync_chains_added(), + self.range_sync_chains_removed(), + "Not all chains completed" + ); + } + + fn lookup_at_slot(&self, slot: u64) -> &SeenLookup { + let block_root = self.block_root_at_slot(slot); + self.seen_lookups + .values() + .find(|lookup| lookup.block_root == block_root) + .unwrap_or_else(|| panic!("No lookup for block_root {block_root} of slot {slot}")) + } + + fn assert_peers_at_lookup_of_slot(&self, slot: u64, expected_peers: usize) { + let lookup = self.lookup_at_slot(slot); + if lookup.seen_peers.len() != expected_peers { + panic!( + "Expected lookup of slot {slot} to have {expected_peers} peers but had {:?}", + lookup.seen_peers + ) + } + } + + /// Total count of unique lookups created + fn created_lookups(&self) -> usize { + // Subtract initial value to allow resetting metrics mid test + self.sync_manager.block_lookups().metrics().created_lookups + - self.initial_block_lookups_metrics.created_lookups + } + + /// Total count of lookups completed or dropped + fn dropped_lookups(&self) -> usize { + // Subtract initial value to allow resetting metrics mid test + self.sync_manager.block_lookups().metrics().dropped_lookups + - self.initial_block_lookups_metrics.dropped_lookups + } + + fn completed_lookups(&self) -> usize { + // Subtract initial value to allow resetting metrics mid test + self.sync_manager + .block_lookups() + .metrics() + .completed_lookups + - self.initial_block_lookups_metrics.completed_lookups + } + + fn capture_metrics_baseline(&mut self) { + self.initial_block_lookups_metrics = self.sync_manager.block_lookups().metrics().clone() + } + + /// Returns the last lookup seen with matching block_root + fn lookup_by_root(&self, block_root: Hash256) -> &SeenLookup { + self.seen_lookups + .values() + .filter(|lookup| lookup.block_root == block_root) + .max_by_key(|lookup| lookup.id) + .unwrap_or_else(|| panic!("No loookup for block_root {block_root}")) + } + + fn range_sync_chains_added(&self) -> usize { + self.sync_manager.range_sync().metrics().chains_added + } + + fn range_sync_chains_removed(&self) -> usize { + self.sync_manager.range_sync().metrics().chains_removed + } + + fn custody_columns(&self) -> &[ColumnIndex] { + self.harness + .chain + .data_availability_checker + .custody_context() + .custody_columns_for_epoch(None, &self.harness.spec) + } + + // Test setup + + fn new_after_deneb() -> Option { + genesis_fork().deneb_enabled().then(Self::default) + } + + fn new_after_deneb_before_fulu() -> Option { + let fork = genesis_fork(); + if fork.deneb_enabled() && !fork.fulu_enabled() { + Some(Self::default()) } else { None } } - pub fn test_setup_after_fulu() -> Option { - let r = Self::test_setup(); - if r.fork_name.fulu_enabled() { - Some(r) - } else { - None - } + pub fn new_fulu_peer_test(fulu_test_type: FuluTestType) -> Option { + genesis_fork().fulu_enabled().then(|| { + Self::new(TestRigConfig { + fulu_test_type, + node_custody_type_override: None, + }) + }) } pub fn log(&self, msg: &str) { info!(msg, "TEST_RIG"); } - pub fn after_deneb(&self) -> bool { + pub fn is_after_deneb(&self) -> bool { self.fork_name.deneb_enabled() } - pub fn after_fulu(&self) -> bool { + pub fn is_after_fulu(&self) -> bool { self.fork_name.fulu_enabled() } @@ -170,8 +1223,16 @@ impl TestRig { self.send_sync_message(SyncMessage::UnknownParentBlock(peer_id, block, block_root)) } - fn trigger_unknown_parent_blob(&mut self, peer_id: PeerId, blob: BlobSidecar) { - self.send_sync_message(SyncMessage::UnknownParentBlob(peer_id, blob.into())); + fn trigger_unknown_parent_blob(&mut self, peer_id: PeerId, blob: Arc>) { + self.send_sync_message(SyncMessage::UnknownParentBlob(peer_id, blob)); + } + + fn trigger_unknown_parent_column( + &mut self, + peer_id: PeerId, + column: Arc>, + ) { + self.send_sync_message(SyncMessage::UnknownParentDataColumn(peer_id, column)); } fn trigger_unknown_block_from_attestation(&mut self, block_root: Hash256, peer_id: PeerId) { @@ -180,13 +1241,6 @@ impl TestRig { )); } - /// Drain all sync messages in the sync_rx attached to the beacon processor - fn drain_sync_rx(&mut self) { - while let Ok(sync_message) = self.sync_rx.try_recv() { - self.send_sync_message(sync_message); - } - } - fn rand_block(&mut self) -> SignedBeaconBlock { self.rand_block_and_blobs(NumBlobs::None).0 } @@ -200,105 +1254,36 @@ impl TestRig { generate_rand_block_and_blobs::(fork_name, num_blobs, rng) } - fn rand_block_and_data_columns( - &mut self, - ) -> (SignedBeaconBlock, Vec>>) { - let num_blobs = NumBlobs::Number(1); - generate_rand_block_and_data_columns::( - self.fork_name, - num_blobs, - &mut self.rng, - &self.harness.spec, - ) - } - - pub fn rand_block_and_parent( - &mut self, - ) -> (SignedBeaconBlock, SignedBeaconBlock, Hash256, Hash256) { - let parent = self.rand_block(); - let parent_root = parent.canonical_root(); - let mut block = self.rand_block(); - *block.message_mut().parent_root_mut() = parent_root; - let block_root = block.canonical_root(); - (parent, block, parent_root, block_root) - } - pub fn send_sync_message(&mut self, sync_message: SyncMessage) { self.sync_manager.handle_message(sync_message); } + pub fn push_sync_message(&mut self, sync_message: SyncMessage) { + self.sync_manager.send_sync_message(sync_message); + } + fn active_single_lookups(&self) -> Vec { - self.sync_manager.active_single_lookups() + self.sync_manager.block_lookups().active_single_lookups() } fn active_single_lookups_count(&self) -> usize { - self.sync_manager.active_single_lookups().len() - } - - fn active_parent_lookups(&self) -> Vec> { - self.sync_manager.active_parent_lookups() - } - - fn active_parent_lookups_count(&self) -> usize { - self.sync_manager.active_parent_lookups().len() - } - - fn active_range_sync_chain(&self) -> (RangeSyncType, Slot, Slot) { - self.sync_manager.get_range_sync_chains().unwrap().unwrap() + self.active_single_lookups().len() } fn assert_single_lookups_count(&self, count: usize) { assert_eq!( self.active_single_lookups_count(), count, - "Unexpected count of single lookups. Current lookups: {:?}", + "Unexpected count of single lookups. Current lookups: {:#?}", self.active_single_lookups() ); } - fn assert_parent_lookups_count(&self, count: usize) { - assert_eq!( - self.active_parent_lookups_count(), - count, - "Unexpected count of parent lookups. Parent lookups: {:?}. Current lookups: {:?}", - self.active_parent_lookups(), - self.active_single_lookups() - ); - } - - fn assert_lookup_is_active(&self, block_root: Hash256) { - let lookups = self.sync_manager.active_single_lookups(); - if !lookups.iter().any(|l| l.1 == block_root) { - panic!("Expected lookup {block_root} to be the only active: {lookups:?}"); - } - } - - fn assert_lookup_peers(&self, block_root: Hash256, mut expected_peers: Vec) { - let mut lookup = self - .sync_manager - .active_single_lookups() - .into_iter() - .find(|l| l.1 == block_root) - .unwrap_or_else(|| panic!("no lookup for {block_root}")); - lookup.3.sort(); - expected_peers.sort(); - assert_eq!( - lookup.3, expected_peers, - "unexpected peers on lookup {block_root}" - ); - } - fn insert_ignored_chain(&mut self, block_root: Hash256) { + self.log(&format!("Inserting block in ignored chains {block_root:?}")); self.sync_manager.insert_ignored_chain(block_root); } - fn assert_not_ignored_chain(&mut self, chain_hash: Hash256) { - let chains = self.sync_manager.get_ignored_chains(); - if chains.contains(&chain_hash) { - panic!("ignored chains contain {chain_hash:?}: {chains:?}"); - } - } - fn assert_ignored_chain(&mut self, chain_hash: Hash256) { let chains = self.sync_manager.get_ignored_chains(); if !chains.contains(&chain_hash) { @@ -306,16 +1291,8 @@ impl TestRig { } } - fn find_single_lookup_for(&self, block_root: Hash256) -> Id { - self.active_single_lookups() - .iter() - .find(|l| l.1 == block_root) - .unwrap_or_else(|| panic!("no single block lookup found for {block_root}")) - .0 - } - #[track_caller] - fn expect_no_active_single_lookups(&self) { + fn assert_no_active_single_lookups(&self) { assert!( self.active_single_lookups().is_empty(), "expect no single block lookups: {:?}", @@ -324,13 +1301,8 @@ impl TestRig { } #[track_caller] - fn expect_no_active_lookups(&self) { - self.expect_no_active_single_lookups(); - } - - fn expect_no_active_lookups_empty_network(&mut self) { - self.expect_no_active_lookups(); - self.expect_empty_network(); + fn assert_no_active_lookups(&self) { + self.assert_no_active_single_lookups(); } pub fn new_connected_peer(&mut self) -> PeerId { @@ -340,367 +1312,62 @@ impl TestRig { .peers .write() .__add_connected_peer_testing_only(false, &self.harness.spec, key); - self.log(&format!("Added new peer for testing {peer_id:?}")); + + // Assumes custody subnet count == column count + let custody_subnets = self + .network_globals + .peers + .read() + .peer_info(&peer_id) + .expect("Peer should be known") + .custody_subnets_iter() + .copied() + .collect::>(); + let peer_custody_str = + if custody_subnets.len() == self.harness.spec.number_of_custody_groups as usize { + "all".to_owned() + } else { + format!("{custody_subnets:?}") + }; + + self.log(&format!( + "Added new peer for testing {peer_id:?}, custody: {peer_custody_str}" + )); peer_id } pub fn new_connected_supernode_peer(&mut self) -> PeerId { let key = self.determinstic_key(); - self.network_globals + let peer_id = self + .network_globals .peers .write() - .__add_connected_peer_testing_only(true, &self.harness.spec, key) + .__add_connected_peer_testing_only(true, &self.harness.spec, key); + self.log(&format!( + "Added new peer for testing {peer_id:?}, custody: supernode" + )); + peer_id } fn determinstic_key(&mut self) -> CombinedKey { k256::ecdsa::SigningKey::random(&mut self.rng_08).into() } - pub fn new_connected_peers_for_peerdas(&mut self) { - // Enough sampling peers with few columns - for _ in 0..100 { - self.new_connected_peer(); - } - // One supernode peer to ensure all columns have at least one peer - self.new_connected_supernode_peer(); - } - - fn parent_chain_processed_success( - &mut self, - chain_hash: Hash256, - blocks: &[Arc>], - ) { - // Send import events for all pending parent blocks - for _ in blocks { - self.parent_block_processed_imported(chain_hash); - } - // Send final import event for the block that triggered the lookup - self.single_block_component_processed_imported(chain_hash); - } - - /// Locate a parent lookup chain with tip hash `chain_hash` - fn find_oldest_parent_lookup(&self, chain_hash: Hash256) -> Hash256 { - let parent_chain = self - .active_parent_lookups() - .into_iter() - .find(|chain| chain.first() == Some(&chain_hash)) - .unwrap_or_else(|| { - panic!( - "No parent chain with chain_hash {chain_hash:?}: Parent lookups {:?} Single lookups {:?}", - self.active_parent_lookups(), - self.active_single_lookups(), - ) - }); - *parent_chain.last().unwrap() - } - - fn parent_block_processed(&mut self, chain_hash: Hash256, result: BlockProcessingResult) { - let id = self.find_single_lookup_for(self.find_oldest_parent_lookup(chain_hash)); - self.single_block_component_processed(id, result); - } - - fn parent_blob_processed(&mut self, chain_hash: Hash256, result: BlockProcessingResult) { - let id = self.find_single_lookup_for(self.find_oldest_parent_lookup(chain_hash)); - self.single_blob_component_processed(id, result); - } - - fn parent_block_processed_imported(&mut self, chain_hash: Hash256) { - self.parent_block_processed( - chain_hash, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(chain_hash)), - ); - } - - fn single_block_component_processed(&mut self, id: Id, result: BlockProcessingResult) { - self.send_sync_message(SyncMessage::BlockComponentProcessed { - process_type: BlockProcessType::SingleBlock { id }, - result, - }) - } - - fn single_block_component_processed_imported(&mut self, block_root: Hash256) { - let id = self.find_single_lookup_for(block_root); - self.single_block_component_processed( - id, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(block_root)), - ) - } - - fn single_blob_component_processed(&mut self, id: Id, result: BlockProcessingResult) { - self.send_sync_message(SyncMessage::BlockComponentProcessed { - process_type: BlockProcessType::SingleBlob { id }, - result, - }) - } - - fn parent_lookup_block_response( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - beacon_block: Option>>, - ) { - self.log("parent_lookup_block_response"); - self.send_sync_message(SyncMessage::RpcBlock { - sync_request_id: SyncRequestId::SingleBlock { id }, - peer_id, - beacon_block, - seen_timestamp: D, - }); - } - - fn single_lookup_block_response( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - beacon_block: Option>>, - ) { - self.log("single_lookup_block_response"); - self.send_sync_message(SyncMessage::RpcBlock { - sync_request_id: SyncRequestId::SingleBlock { id }, - peer_id, - beacon_block, - seen_timestamp: D, - }); - } - - fn parent_lookup_blob_response( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - blob_sidecar: Option>>, - ) { - self.log(&format!( - "parent_lookup_blob_response {:?}", - blob_sidecar.as_ref().map(|b| b.index) - )); - self.send_sync_message(SyncMessage::RpcBlob { - sync_request_id: SyncRequestId::SingleBlob { id }, - peer_id, - blob_sidecar, - seen_timestamp: D, - }); - } - - fn single_lookup_blob_response( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - blob_sidecar: Option>>, - ) { - self.send_sync_message(SyncMessage::RpcBlob { - sync_request_id: SyncRequestId::SingleBlob { id }, - peer_id, - blob_sidecar, - seen_timestamp: D, - }); - } - - fn complete_single_lookup_blob_download( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - blobs: Vec>, - ) { - for blob in blobs { - self.single_lookup_blob_response(id, peer_id, Some(blob.into())); - } - self.single_lookup_blob_response(id, peer_id, None); - } - - fn complete_single_lookup_blob_lookup_valid( - &mut self, - id: SingleLookupReqId, - peer_id: PeerId, - blobs: Vec>, - import: bool, - ) { - let block_root = blobs.first().unwrap().block_root(); - let block_slot = blobs.first().unwrap().slot(); - self.complete_single_lookup_blob_download(id, peer_id, blobs); - self.expect_block_process(ResponseType::Blob); - self.single_blob_component_processed( - id.lookup_id, - if import { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(block_root)) - } else { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - block_slot, block_root, - )) - }, - ); - } - - fn complete_lookup_block_download(&mut self, block: SignedBeaconBlock) { - let block_root = block.canonical_root(); - let id = self.expect_block_lookup_request(block_root); - self.expect_empty_network(); - let peer_id = self.new_connected_peer(); - self.single_lookup_block_response(id, peer_id, Some(block.into())); - self.single_lookup_block_response(id, peer_id, None); - } - - fn complete_lookup_block_import_valid(&mut self, block_root: Hash256, import: bool) { - self.expect_block_process(ResponseType::Block); - let id = self.find_single_lookup_for(block_root); - self.single_block_component_processed( - id, - if import { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(block_root)) - } else { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - Slot::new(0), - block_root, - )) - }, - ) - } - - fn complete_single_lookup_block_valid(&mut self, block: SignedBeaconBlock, import: bool) { - let block_root = block.canonical_root(); - self.complete_lookup_block_download(block); - self.complete_lookup_block_import_valid(block_root, import) - } - - fn parent_lookup_failed(&mut self, id: SingleLookupReqId, peer_id: PeerId, error: RPCError) { - self.send_sync_message(SyncMessage::RpcError { - peer_id, - sync_request_id: SyncRequestId::SingleBlock { id }, - error, - }) - } - - fn parent_lookup_failed_unavailable(&mut self, id: SingleLookupReqId, peer_id: PeerId) { - self.parent_lookup_failed( - id, - peer_id, - RPCError::ErrorResponse( - RpcErrorResponse::ResourceUnavailable, - "older than deneb".into(), - ), - ); - } - - fn single_lookup_failed(&mut self, id: SingleLookupReqId, peer_id: PeerId, error: RPCError) { - self.send_sync_message(SyncMessage::RpcError { - peer_id, - sync_request_id: SyncRequestId::SingleBlock { id }, - error, - }) - } - - fn complete_valid_block_request( - &mut self, - id: SingleLookupReqId, - block: Arc>, - missing_components: bool, - ) { - // Complete download - let peer_id = PeerId::random(); - let slot = block.slot(); - let block_root = block.canonical_root(); - self.single_lookup_block_response(id, peer_id, Some(block)); - self.single_lookup_block_response(id, peer_id, None); - // Expect processing and resolve with import - self.expect_block_process(ResponseType::Block); - self.single_block_component_processed( - id.lookup_id, - if missing_components { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - slot, block_root, - )) - } else { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(block_root)) - }, - ) - } - - fn complete_valid_custody_request( - &mut self, - ids: DCByRootIds, - data_columns: Vec>>, - missing_components: bool, - ) { - let lookup_id = if let SyncRequestId::DataColumnsByRoot(DataColumnsByRootRequestId { - requester: DataColumnsByRootRequester::Custody(id), - .. - }) = ids.first().unwrap().0 - { - id.requester.0.lookup_id - } else { - panic!("not a custody requester") - }; - - let first_column = data_columns.first().cloned().unwrap(); - - for id in ids { - self.log(&format!("return valid data column for {id:?}")); - let indices = &id.1; - let columns_to_send = indices - .iter() - .map(|&i| data_columns[i as usize].clone()) - .collect::>(); - self.complete_data_columns_by_root_request(id, &columns_to_send); - } - - // Expect work event - self.expect_rpc_custody_column_work_event(); - - // Respond with valid result - self.send_sync_message(SyncMessage::BlockComponentProcessed { - process_type: BlockProcessType::SingleCustodyColumn(lookup_id), - result: if missing_components { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - first_column.slot(), - first_column.block_root(), - )) - } else { - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported( - first_column.block_root(), - )) - }, - }); - } - - fn complete_data_columns_by_root_request( - &mut self, - (sync_request_id, _): DCByRootId, - data_columns: &[Arc>], - ) { - let peer_id = PeerId::random(); - for data_column in data_columns { - // Send chunks - self.send_sync_message(SyncMessage::RpcDataColumn { - sync_request_id, - peer_id, - data_column: Some(data_column.clone()), - seen_timestamp: timestamp_now(), - }); - } - // Send stream termination - self.send_sync_message(SyncMessage::RpcDataColumn { - sync_request_id, - peer_id, - data_column: None, - seen_timestamp: timestamp_now(), - }); - } - - /// Return RPCErrors for all active requests of peer - fn rpc_error_all_active_requests(&mut self, disconnected_peer_id: PeerId) { - self.drain_network_rx(); - while let Ok(sync_request_id) = self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id, - app_request_id: AppRequestId::Sync(id), - .. - } if *peer_id == disconnected_peer_id => Some(*id), - _ => None, - }) { - self.send_sync_message(SyncMessage::RpcError { - peer_id: disconnected_peer_id, - sync_request_id, - error: RPCError::Disconnected, - }); + pub fn new_connected_peers_for_peerdas(&mut self) -> Vec { + match self.fulu_test_type.them_node_custody_type() { + NodeCustodyType::Fullnode => { + // Enough sampling peers with few columns + let mut peers = (0..100) + .map(|_| self.new_connected_peer()) + .collect::>(); + // One supernode peer to ensure all columns have at least one peer + peers.push(self.new_connected_supernode_peer()); + peers + } + NodeCustodyType::Supernode | NodeCustodyType::SemiSupernode => { + let peer = self.new_connected_supernode_peer(); + vec![peer] + } } } @@ -708,6 +1375,22 @@ impl TestRig { self.send_sync_message(SyncMessage::Disconnect(peer_id)); } + fn get_connected_peers(&self) -> Vec { + self.network_globals + .peers + .read() + .peers() + .map(|(peer, _)| *peer) + .collect::>() + } + + fn disconnect_all_peers(&mut self) { + for peer in self.get_connected_peers() { + self.log(&format!("Disconnecting peer {peer}")); + self.send_sync_message(SyncMessage::Disconnect(peer)); + } + } + fn drain_network_rx(&mut self) { while let Ok(event) = self.network_rx.try_recv() { self.network_rx_queue.push(event); @@ -764,7 +1447,7 @@ impl TestRig { } } - pub fn expect_empty_processor(&mut self) { + pub fn assert_empty_processor(&mut self) { self.drain_processor_rx(); if !self.beacon_processor_rx_queue.is_empty() { panic!( @@ -774,215 +1457,8 @@ impl TestRig { } } - fn find_block_lookup_request( - &mut self, - for_block: Hash256, - ) -> Result { - self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id: _, - request: RequestType::BlocksByRoot(request), - app_request_id: AppRequestId::Sync(SyncRequestId::SingleBlock { id }), - } if request.block_roots().to_vec().contains(&for_block) => Some(*id), - _ => None, - }) - } - #[track_caller] - fn expect_block_lookup_request(&mut self, for_block: Hash256) -> SingleLookupReqId { - self.find_block_lookup_request(for_block) - .unwrap_or_else(|e| panic!("Expected block request for {for_block:?}: {e}")) - } - - fn find_blob_lookup_request( - &mut self, - for_block: Hash256, - ) -> Result { - self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id: _, - request: RequestType::BlobsByRoot(request), - app_request_id: AppRequestId::Sync(SyncRequestId::SingleBlob { id }), - } if request - .blob_ids - .to_vec() - .iter() - .any(|r| r.block_root == for_block) => - { - Some(*id) - } - _ => None, - }) - } - - #[track_caller] - fn expect_blob_lookup_request(&mut self, for_block: Hash256) -> SingleLookupReqId { - self.find_blob_lookup_request(for_block) - .unwrap_or_else(|e| panic!("Expected blob request for {for_block:?}: {e}")) - } - - #[track_caller] - fn expect_block_parent_request(&mut self, for_block: Hash256) -> SingleLookupReqId { - self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id: _, - request: RequestType::BlocksByRoot(request), - app_request_id: AppRequestId::Sync(SyncRequestId::SingleBlock { id }), - } if request.block_roots().to_vec().contains(&for_block) => Some(*id), - _ => None, - }) - .unwrap_or_else(|e| panic!("Expected block parent request for {for_block:?}: {e}")) - } - - fn expect_no_requests_for(&mut self, block_root: Hash256) { - if let Ok(request) = self.find_block_lookup_request(block_root) { - panic!("Expected no block request for {block_root:?} found {request:?}"); - } - if let Ok(request) = self.find_blob_lookup_request(block_root) { - panic!("Expected no blob request for {block_root:?} found {request:?}"); - } - } - - #[track_caller] - fn expect_blob_parent_request(&mut self, for_block: Hash256) -> SingleLookupReqId { - self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id: _, - request: RequestType::BlobsByRoot(request), - app_request_id: AppRequestId::Sync(SyncRequestId::SingleBlob { id }), - } if request - .blob_ids - .to_vec() - .iter() - .all(|r| r.block_root == for_block) => - { - Some(*id) - } - _ => None, - }) - .unwrap_or_else(|e| panic!("Expected blob parent request for {for_block:?}: {e}")) - } - - /// Retrieves an unknown number of requests for data columns of `block_root`. Because peer ENRs - /// are random, and peer selection is random, the total number of batched requests is unknown. - fn expect_data_columns_by_root_requests( - &mut self, - block_root: Hash256, - count: usize, - ) -> DCByRootIds { - let mut requests: DCByRootIds = vec![]; - loop { - let req = self - .pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id: _, - request: RequestType::DataColumnsByRoot(request), - app_request_id: - AppRequestId::Sync(id @ SyncRequestId::DataColumnsByRoot { .. }), - } => { - let matching = request - .data_column_ids - .iter() - .find(|id| id.block_root == block_root)?; - - let indices = matching.columns.iter().copied().collect(); - Some((*id, indices)) - } - _ => None, - }) - .unwrap_or_else(|e| { - panic!("Expected more DataColumnsByRoot requests for {block_root:?}: {e}") - }); - requests.push(req); - - // Should never infinite loop because sync does not send requests for 0 columns - if requests.iter().map(|r| r.1.len()).sum::() >= count { - return requests; - } - } - } - - fn expect_only_data_columns_by_root_requests( - &mut self, - for_block: Hash256, - count: usize, - ) -> DCByRootIds { - let ids = self.expect_data_columns_by_root_requests(for_block, count); - self.expect_empty_network(); - ids - } - - #[track_caller] - fn expect_block_process(&mut self, response_type: ResponseType) { - match response_type { - ResponseType::Block => self - .pop_received_processor_event(|ev| { - (ev.work_type() == beacon_processor::WorkType::RpcBlock).then_some(()) - }) - .unwrap_or_else(|e| panic!("Expected block work event: {e}")), - ResponseType::Blob => self - .pop_received_processor_event(|ev| { - (ev.work_type() == beacon_processor::WorkType::RpcBlobs).then_some(()) - }) - .unwrap_or_else(|e| panic!("Expected blobs work event: {e}")), - ResponseType::CustodyColumn => self - .pop_received_processor_event(|ev| { - (ev.work_type() == beacon_processor::WorkType::RpcCustodyColumn).then_some(()) - }) - .unwrap_or_else(|e| panic!("Expected column work event: {e}")), - } - } - - fn expect_rpc_custody_column_work_event(&mut self) { - self.pop_received_processor_event(|ev| { - if ev.work_type() == beacon_processor::WorkType::RpcCustodyColumn { - Some(()) - } else { - None - } - }) - .unwrap_or_else(|e| panic!("Expected RPC custody column work: {e}")) - } - - #[allow(dead_code)] - fn expect_no_work_event(&mut self) { - self.drain_processor_rx(); - assert!(self.network_rx_queue.is_empty()); - } - - fn expect_no_penalty_for(&mut self, peer_id: PeerId) { - self.drain_network_rx(); - let downscore_events = self - .network_rx_queue - .iter() - .filter_map(|ev| match ev { - NetworkMessage::ReportPeer { - peer_id: p_id, msg, .. - } if p_id == &peer_id => Some(msg), - _ => None, - }) - .collect::>(); - if !downscore_events.is_empty() { - panic!("Some downscore events for {peer_id}: {downscore_events:?}"); - } - } - - #[track_caller] - fn expect_parent_chain_process(&mut self) { - match self.beacon_processor_rx.try_recv() { - Ok(work) => { - // Parent chain sends blocks one by one - assert_eq!(work.work_type(), beacon_processor::WorkType::RpcBlock); - } - other => panic!( - "Expected rpc_block from chain segment process, found {:?}", - other - ), - } - } - - #[track_caller] - pub fn expect_empty_network(&mut self) { + pub fn assert_empty_network(&mut self) { self.drain_network_rx(); if !self.network_rx_queue.is_empty() { let n = self.network_rx_queue.len(); @@ -993,115 +1469,52 @@ impl TestRig { } } - #[track_caller] - fn expect_empty_beacon_processor(&mut self) { - match self.beacon_processor_rx.try_recv() { - Err(mpsc::error::TryRecvError::Empty) => {} // ok - Ok(event) => panic!("expected empty beacon processor: {:?}", event), - other => panic!("unexpected err {:?}", other), - } - } - - #[track_caller] - pub fn expect_penalty(&mut self, peer_id: PeerId, expect_penalty_msg: &'static str) { - let penalty_msg = self - .pop_received_network_event(|ev| match ev { - NetworkMessage::ReportPeer { - peer_id: p_id, msg, .. - } if p_id == &peer_id => Some(msg.to_owned()), - _ => None, - }) - .unwrap_or_else(|_| { - panic!( - "Expected '{expect_penalty_msg}' penalty for peer {peer_id}: {:#?}", - self.network_rx_queue - ) - }); - assert_eq!( - penalty_msg, expect_penalty_msg, - "Unexpected penalty msg for {peer_id}" - ); - self.log(&format!("Found expected penalty {penalty_msg}")); - } - - pub fn block_with_parent_and_blobs( + async fn import_block_to_da_checker( &mut self, - parent_root: Hash256, - num_blobs: NumBlobs, - ) -> (SignedBeaconBlock, Vec>) { - let (mut block, mut blobs) = self.rand_block_and_blobs(num_blobs); - *block.message_mut().parent_root_mut() = parent_root; - blobs.iter_mut().for_each(|blob| { - blob.signed_block_header = block.signed_block_header(); - }); - (block, blobs) - } - - pub fn rand_blockchain(&mut self, depth: usize) -> Vec>> { - let mut blocks = Vec::>>::with_capacity(depth); - for slot in 0..depth { - let parent = blocks - .last() - .map(|b| b.canonical_root()) - .unwrap_or_else(Hash256::random); - let mut block = self.rand_block(); - *block.message_mut().parent_root_mut() = parent; - *block.message_mut().slot_mut() = slot.into(); - blocks.push(block.into()); - } - self.log(&format!( - "Blockchain dump {:#?}", - blocks - .iter() - .map(|b| format!( - "block {} {} parent {}", - b.slot(), - b.canonical_root(), - b.parent_root() - )) - .collect::>() - )); - blocks - } - - fn insert_block_to_da_checker(&mut self, block: Arc>) { - let state = BeaconState::Base(BeaconStateBase::random_for_test(&mut self.rng)); - let parent_block = self.rand_block(); - let import_data = BlockImportData::::__new_for_test( - block.canonical_root(), - state, - parent_block.into(), - ); - let payload_verification_outcome = PayloadVerificationOutcome { - payload_verification_status: PayloadVerificationStatus::Verified, - is_valid_merge_transition_block: false, - }; - let executed_block = - AvailabilityPendingExecutedBlock::new(block, import_data, payload_verification_outcome); - match self - .harness + block: Arc>, + ) -> AvailabilityProcessingStatus { + // Simulate importing block from another source. Don't use GossipVerified as it checks with + // the clock, which does not match the timestamp in the payload. + let block_root = block.canonical_root(); + let rpc_block = RpcBlock::BlockOnly { block_root, block }; + self.harness .chain - .data_availability_checker - .put_executed_block(executed_block) - .unwrap() - { - Availability::Available(_) => panic!("block removed from da_checker, available"), - Availability::MissingComponents(block_root) => { + .process_block( + block_root, + rpc_block, + NotifyExecutionLayer::Yes, + BlockImportSource::Gossip, + || Ok(()), + ) + .await + .expect("Error processing block") + } + + async fn insert_block_to_da_chain_and_assert_missing_componens( + &mut self, + block: Arc>, + ) { + match self.import_block_to_da_checker(block).await { + AvailabilityProcessingStatus::Imported(_) => { + panic!("block removed from da_checker, available") + } + AvailabilityProcessingStatus::MissingComponents(_, block_root) => { self.log(&format!("inserted block to da_checker {block_root:?}")) } - }; + } } - fn insert_blob_to_da_checker(&mut self, blob: BlobSidecar) { + fn insert_blob_to_da_checker(&mut self, blob: Arc>) { match self .harness .chain .data_availability_checker - .put_gossip_verified_blobs( + .put_kzg_verified_blobs( blob.block_root(), - std::iter::once(GossipVerifiedBlob::<_, Observe>::__assumed_valid( - blob.into(), - )), + std::iter::once( + KzgVerifiedBlob::new(blob, &self.harness.chain.kzg, Duration::new(0, 0)) + .expect("Invalid blob"), + ), ) .unwrap() { @@ -1112,7 +1525,11 @@ impl TestRig { }; } - fn insert_block_to_availability_cache(&mut self, block: Arc>) { + fn insert_block_to_da_checker_as_pre_execution(&mut self, block: Arc>) { + self.log(&format!( + "Inserting block to availability_cache as pre_execution_block {:?}", + block.canonical_root() + )); self.harness .chain .data_availability_checker @@ -1121,6 +1538,9 @@ impl TestRig { } fn simulate_block_gossip_processing_becomes_invalid(&mut self, block_root: Hash256) { + self.log(&format!( + "Marking block {block_root:?} in da_checker as execution error" + )); self.harness .chain .data_availability_checker @@ -1132,19 +1552,38 @@ impl TestRig { }); } - fn simulate_block_gossip_processing_becomes_valid_missing_components( + async fn simulate_block_gossip_processing_becomes_valid( &mut self, block: Arc>, ) { let block_root = block.canonical_root(); - self.insert_block_to_da_checker(block); + match self.import_block_to_da_checker(block).await { + AvailabilityProcessingStatus::Imported(block_root) => { + self.log(&format!( + "insert block to da_checker and it imported {block_root:?}" + )); + } + AvailabilityProcessingStatus::MissingComponents(_, _) => { + panic!("block not imported after adding to da_checker"); + } + } self.send_sync_message(SyncMessage::GossipBlockProcessResult { block_root, imported: false, }); } + + fn requests_count(&self) -> HashMap<&'static str, usize> { + let mut requests_count = HashMap::new(); + for (request, _) in &self.requests { + *requests_count + .entry(Into::<&'static str>::into(request)) + .or_default() += 1; + } + requests_count + } } #[test] @@ -1161,1558 +1600,803 @@ fn stable_rng() { ); } -#[test] -fn test_single_block_lookup_happy_path() { - let mut rig = TestRig::test_setup(); - let block = rig.rand_block(); - let peer_id = rig.new_connected_peer(); - let block_root = block.canonical_root(); - // Trigger the request - rig.trigger_unknown_block_from_attestation(block_root, peer_id); - let id = rig.expect_block_lookup_request(block_root); +macro_rules! run_lookups_tests_for_depths { + ($($depth:literal),+ $(,)?) => { + paste::paste! { + $( + #[tokio::test] + async fn []() { + happy_path_unknown_attestation($depth).await; + } - // The peer provides the correct block, should not be penalized. Now the block should be sent - // for processing. - rig.single_lookup_block_response(id, peer_id, Some(block.into())); - rig.expect_empty_network(); - rig.expect_block_process(ResponseType::Block); + #[tokio::test] + async fn []() { + happy_path_unknown_block_parent($depth).await; + } - // The request should still be active. - assert_eq!(rig.active_single_lookups_count(), 1); + #[tokio::test] + async fn []() { + happy_path_unknown_data_parent($depth).await; + } - // Send the stream termination. Peer should have not been penalized, and the request removed - // after processing. - rig.single_lookup_block_response(id, peer_id, None); - rig.single_block_component_processed_imported(block_root); - rig.expect_empty_network(); - rig.expect_no_active_lookups(); + #[tokio::test] + async fn []() { + happy_path_multiple_triggers($depth).await; + } + + #[tokio::test] + async fn []() { + bad_peer_empty_block_response($depth).await; + } + + #[tokio::test] + async fn []() { + bad_peer_empty_data_response($depth).await; + } + + #[tokio::test] + async fn []() { + bad_peer_too_few_data_response($depth).await; + } + + #[tokio::test] + async fn []() { + bad_peer_wrong_block_response($depth).await; + } + + #[tokio::test] + async fn []() { + bad_peer_wrong_data_response($depth).await; + } + + #[tokio::test] + async fn []() { + bad_peer_rpc_failure($depth).await; + } + + #[tokio::test] + async fn []() { + too_many_download_failures($depth).await; + } + + #[tokio::test] + async fn []() { + too_many_processing_failures($depth).await; + } + + #[tokio::test] + async fn []() { + peer_disconnected_then_rpc_error($depth).await; + } + )+ + } + }; } -// Tests that if a peer does not respond with a block, we downscore and retry the block only -#[test] -fn test_single_block_lookup_empty_response() { - let mut r = TestRig::test_setup(); +run_lookups_tests_for_depths!(1, 2); - let block = r.rand_block(); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - - // Trigger the request - r.trigger_unknown_block_from_attestation(block_root, peer_id); - let id = r.expect_block_lookup_request(block_root); - - // The peer does not have the block. It should be penalized. - r.single_lookup_block_response(id, peer_id, None); - r.expect_penalty(peer_id, "NotEnoughResponsesReturned"); - // it should be retried - let id = r.expect_block_lookup_request(block_root); - // Send the right block this time. - r.single_lookup_block_response(id, peer_id, Some(block.into())); - r.expect_block_process(ResponseType::Block); - r.single_block_component_processed_imported(block_root); - r.expect_no_active_lookups(); +/// Assert that lookup sync succeeds with the happy case +async fn happy_path_unknown_attestation(depth: usize) { + let mut r = TestRig::default(); + // We get attestation for a block descendant (depth) blocks of current head + r.build_chain_and_trigger_last_block(depth).await; + // Complete the request with good peer behaviour + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); } -#[test] -fn test_single_block_lookup_wrong_response() { - let mut rig = TestRig::test_setup(); - - let block_hash = Hash256::random(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_block_from_attestation(block_hash, peer_id); - let id = rig.expect_block_lookup_request(block_hash); - - // Peer sends something else. It should be penalized. - let bad_block = rig.rand_block(); - rig.single_lookup_block_response(id, peer_id, Some(bad_block.into())); - rig.expect_penalty(peer_id, "UnrequestedBlockRoot"); - rig.expect_block_lookup_request(block_hash); // should be retried - - // Send the stream termination. This should not produce an additional penalty. - rig.single_lookup_block_response(id, peer_id, None); - rig.expect_empty_network(); +async fn happy_path_unknown_block_parent(depth: usize) { + let mut r = TestRig::default(); + r.build_chain(depth).await; + r.trigger_with_last_unknown_block_parent(); + r.simulate(SimulateConfig::happy_path()).await; + // 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: + // - before deneb the block is cached, so it's sent for processing, and success + // - before fulu the block is cached, but we can't fetch blobs so it's stuck + // - after fulu the block is cached, we start a custody request and since we use the global pool + // of peers we DO have 1 connected synced supernode peer, which gives us the columns and the + // lookup succeeds + if r.is_after_deneb() && !r.is_after_fulu() { + r.assert_successful_lookup_sync_parent_trigger() + } else { + r.assert_successful_lookup_sync(); + } } -#[test] -fn test_single_block_lookup_failure() { - let mut rig = TestRig::test_setup(); - - let block_hash = Hash256::random(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_block_from_attestation(block_hash, peer_id); - let id = rig.expect_block_lookup_request(block_hash); - - // The request fails. RPC failures are handled elsewhere so we should not penalize the peer. - rig.single_lookup_failed(id, peer_id, RPCError::UnsupportedProtocol); - rig.expect_block_lookup_request(block_hash); - rig.expect_empty_network(); +/// Assert that sync completes from a GossipUnknownParentBlob / UnknownDataColumnParent +async fn happy_path_unknown_data_parent(depth: usize) { + let Some(mut r) = TestRig::new_after_deneb() else { + return; + }; + r.build_chain(depth).await; + if r.is_after_fulu() { + r.trigger_with_last_unknown_data_column_parent(); + } else if r.is_after_deneb() { + r.trigger_with_last_unknown_blob_parent(); + } + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync_parent_trigger(); } -#[test] -fn test_single_block_lookup_peer_disconnected_then_rpc_error() { - let mut rig = TestRig::test_setup(); +/// Assert that multiple trigger types don't create extra lookups +async fn happy_path_multiple_triggers(depth: usize) { + let mut r = TestRig::default(); + // + 1, because the unknown parent trigger needs two new blocks + r.build_chain(depth + 1).await; + r.trigger_with_last_block(); + r.trigger_with_last_block(); + r.trigger_with_last_unknown_block_parent(); + r.trigger_with_last_unknown_block_parent(); + if r.is_after_fulu() { + r.trigger_with_last_unknown_data_column_parent(); + } else if r.is_after_deneb() { + r.trigger_with_last_unknown_blob_parent(); + } + r.simulate(SimulateConfig::happy_path()).await; + assert_eq!(r.created_lookups(), depth + 1, "Don't create extra lookups"); + r.assert_successful_lookup_sync(); +} - let block_hash = Hash256::random(); - let peer_id = rig.new_connected_peer(); +// Test bad behaviour of peers - // Trigger the request. - rig.trigger_unknown_block_from_attestation(block_hash, peer_id); - let id = rig.expect_block_lookup_request(block_hash); +/// Assert that if peer responds with no blocks, we downscore, and retry the same lookup +async fn bad_peer_empty_block_response(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + // Simulate that peer returns empty response once, then good behaviour + r.simulate(SimulateConfig::new().return_no_blocks_once()) + .await; + // We register a penalty, retry and complete sync successfully + r.assert_penalties(&["NotEnoughResponsesReturned"]); + r.assert_successful_lookup_sync(); + // TODO(tree-sync) For post-deneb assert that the blobs are not re-fetched + // TODO(tree-sync) Assert that a single lookup is created (no drops) +} + +/// Assert that if peer responds with no blobs / columns, we downscore, and retry the same lookup +async fn bad_peer_empty_data_response(depth: usize) { + let Some(mut r) = TestRig::new_after_deneb() else { + return; + }; + r.build_chain_and_trigger_last_block(depth).await; + r.simulate(SimulateConfig::new().return_no_data_once()) + .await; + // We register a penalty, retry and complete sync successfully + r.assert_penalties(&["NotEnoughResponsesReturned"]); + r.assert_successful_lookup_sync(); + // TODO(tree-sync) Assert that a single lookup is created (no drops) +} + +/// Assert that if peer responds with not enough blobs / columns, we downscore, and retry the same +/// lookup +async fn bad_peer_too_few_data_response(depth: usize) { + let Some(mut r) = TestRig::new_after_deneb() else { + return; + }; + r.build_chain_and_trigger_last_block(depth).await; + r.simulate(SimulateConfig::new().return_too_few_data_once()) + .await; + // We register a penalty, retry and complete sync successfully + r.assert_penalties(&["NotEnoughResponsesReturned"]); + r.assert_successful_lookup_sync(); + // TODO(tree-sync) Assert that a single lookup is created (no drops) +} + +/// Assert that if peer responds with bad blocks, we downscore, and retry the same lookup +async fn bad_peer_wrong_block_response(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + r.simulate(SimulateConfig::new().return_wrong_blocks_once()) + .await; + r.assert_penalties(&["UnrequestedBlockRoot"]); + r.assert_successful_lookup_sync(); + + // TODO(tree-sync) Assert that a single lookup is created (no drops) +} + +/// Assert that if peer responds with bad blobs / columns, we downscore, and retry the same lookup +async fn bad_peer_wrong_data_response(depth: usize) { + let Some(mut r) = TestRig::new_after_deneb() else { + return; + }; + r.build_chain_and_trigger_last_block(depth).await; + r.simulate(SimulateConfig::new().return_wrong_sidecar_for_block_once()) + .await; + // We register a penalty, retry and complete sync successfully + r.assert_penalties(&["UnrequestedBlockRoot"]); + r.assert_successful_lookup_sync(); + // TODO(tree-sync) Assert that a single lookup is created (no drops) +} + +/// Assert that on network error, we DON'T downscore, and retry the same lookup +async fn bad_peer_rpc_failure(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + r.simulate(SimulateConfig::new().return_rpc_error(RPCError::UnsupportedProtocol)) + .await; + r.assert_no_penalties(); + r.assert_successful_lookup_sync(); +} + +// Test retry logic + +/// Assert that on too many download failures the lookup fails, but we can still sync +async fn too_many_download_failures(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + // Simulate that a peer always returns empty + r.simulate(SimulateConfig::new().return_no_blocks_always()) + .await; + // We register multiple penalties, the lookup fails and sync does not progress + r.assert_penalties_of_type("NotEnoughResponsesReturned"); + r.assert_failed_lookup_sync(); + + // Trigger sync again for same block, and complete successfully. + // Asserts that the lookup is not on a blacklist + r.capture_metrics_baseline(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); +} + +/// Assert that on too many processing failures the lookup fails, but we can still sync +async fn too_many_processing_failures(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + // Simulate that a peer always returns empty + r.simulate( + SimulateConfig::new() + .with_process_result(|| BlockProcessingResult::Err(BlockError::BlockSlotLimitReached)), + ) + .await; + // We register multiple penalties, the lookup fails and sync does not progress + r.assert_penalties_of_type("lookup_block_processing_failure"); + r.assert_failed_lookup_sync(); + + // Trigger sync again for same block, and complete successfully. + // Asserts that the lookup is not on a blacklist + r.capture_metrics_baseline(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); +} + +#[tokio::test] +/// Assert that multiple trigger types don't create extra lookups +async fn unknown_parent_does_not_add_peers_to_itself() { + let Some(mut r) = TestRig::new_after_deneb() else { + return; + }; + // 2, because the unknown parent trigger needs two new blocks + r.build_chain(2).await; + r.trigger_with_last_unknown_block_parent(); + r.trigger_with_last_unknown_block_parent(); + if r.is_after_fulu() { + r.trigger_with_last_unknown_data_column_parent(); + } else if r.is_after_deneb() { + r.trigger_with_last_unknown_blob_parent(); + } + r.simulate(SimulateConfig::happy_path()).await; + r.assert_peers_at_lookup_of_slot(2, 0); + r.assert_peers_at_lookup_of_slot(1, 3); + 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: + // - before fulu the block is cached, but we can't fetch blobs so it's stuck + // - after fulu the block is cached, we start a custody request and since we use the global pool + // of peers we DO have >1 connected synced supernode peer, which gives us the columns and the + // lookup succeeds + if r.is_after_fulu() { + r.assert_successful_lookup_sync() + } else { + r.assert_successful_lookup_sync_parent_trigger(); + } +} + +#[tokio::test] +/// Assert that if the beacon processor returns Ignored, the lookup is dropped +async fn test_single_block_lookup_ignored_response() { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(1).await; + // Send an Ignored response, the request should be dropped + r.simulate(SimulateConfig::new().with_process_result(|| BlockProcessingResult::Ignored)) + .await; + // The block was not actually imported + r.assert_head_slot(0); + assert_eq!(r.created_lookups(), 1, "no created lookups"); + assert_eq!(r.dropped_lookups(), 1, "no dropped lookups"); + assert_eq!(r.completed_lookups(), 0, "some completed lookups"); +} + +#[tokio::test] +/// 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(); + r.build_chain_and_trigger_last_block(1).await; + // Send a DuplicateFullyImported response, the lookup should complete successfully + r.simulate(SimulateConfig::new().with_process_result(|| { + BlockProcessingResult::Err(BlockError::DuplicateFullyImported(Hash256::ZERO)) + })) + .await; + // The block was not actually imported + r.assert_head_slot(0); + r.assert_successful_lookup_sync(); +} + +/// Assert that when peers disconnect the lookups are not dropped (kept with zero peers) +async fn peer_disconnected_then_rpc_error(depth: usize) { + let mut r = TestRig::default(); + r.build_chain_and_trigger_last_block(depth).await; + r.assert_single_lookups_count(1); // The peer disconnect event reaches sync before the rpc error. - rig.peer_disconnected(peer_id); + r.disconnect_all_peers(); // The lookup is not removed as it can still potentially make progress. - rig.assert_single_lookups_count(1); - // The request fails. - rig.single_lookup_failed(id, peer_id, RPCError::Disconnected); - rig.expect_block_lookup_request(block_hash); - // The request should be removed from the network context on disconnection. - rig.expect_empty_network(); + r.assert_single_lookups_count(1); + r.simulate(SimulateConfig::new().return_rpc_error(RPCError::Disconnected)) + .await; + + // Regardless of depth, only the initial lookup is created, because the peer disconnects before + // being able to download the block + assert_eq!(r.created_lookups(), 1, "no created lookups"); + assert_eq!(r.completed_lookups(), 0, "some completed lookups"); + assert_eq!(r.dropped_lookups(), 0, "some dropped lookups"); + r.assert_empty_network(); + r.assert_single_lookups_count(1); } -#[test] -fn test_single_block_lookup_becomes_parent_request() { - let mut rig = TestRig::test_setup(); - - let block = Arc::new(rig.rand_block()); - let block_root = block.canonical_root(); - let parent_root = block.parent_root(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_block_from_attestation(block.canonical_root(), peer_id); - let id = rig.expect_block_parent_request(block_root); - - // The peer provides the correct block, should not be penalized. Now the block should be sent - // for processing. - rig.single_lookup_block_response(id, peer_id, Some(block.clone())); - rig.expect_empty_network(); - rig.expect_block_process(ResponseType::Block); - - // The request should still be active. - assert_eq!(rig.active_single_lookups_count(), 1); - - // Send the stream termination. Peer should have not been penalized, and the request moved to a - // parent request after processing. - rig.single_block_component_processed( - id.lookup_id, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: block.parent_root(), - }), - ); - assert_eq!(rig.active_single_lookups_count(), 2); // 2 = current + parent - rig.expect_block_parent_request(parent_root); - rig.expect_empty_network(); - assert_eq!(rig.active_parent_lookups_count(), 1); -} - -#[test] -fn test_parent_lookup_happy_path() { - let mut rig = TestRig::test_setup(); - - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - let id = rig.expect_block_parent_request(parent_root); - - // Peer sends the right block, it should be sent for processing. Peer should not be penalized. - rig.parent_lookup_block_response(id, peer_id, Some(parent.into())); - // No request of blobs because the block has not data - rig.expect_empty_network(); - rig.expect_block_process(ResponseType::Block); - rig.expect_empty_network(); - - // Add peer to child lookup to prevent it being dropped - rig.trigger_unknown_block_from_attestation(block_root, peer_id); - // Processing succeeds, now the rest of the chain should be sent for processing. - rig.parent_block_processed( - block_root, - BlockError::DuplicateFullyImported(block_root).into(), - ); - rig.expect_parent_chain_process(); - rig.parent_chain_processed_success(block_root, &[]); - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn test_parent_lookup_wrong_response() { - let mut rig = TestRig::test_setup(); - - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - let id1 = rig.expect_block_parent_request(parent_root); - - // Peer sends the wrong block, peer should be penalized and the block re-requested. - let bad_block = rig.rand_block(); - rig.parent_lookup_block_response(id1, peer_id, Some(bad_block.into())); - rig.expect_penalty(peer_id, "UnrequestedBlockRoot"); - let id2 = rig.expect_block_parent_request(parent_root); - - // Send the stream termination for the first request. This should not produce extra penalties. - rig.parent_lookup_block_response(id1, peer_id, None); - rig.expect_empty_network(); - - // Send the right block this time. - rig.parent_lookup_block_response(id2, peer_id, Some(parent.into())); - rig.expect_block_process(ResponseType::Block); - - // Add peer to child lookup to prevent it being dropped - rig.trigger_unknown_block_from_attestation(block_root, peer_id); - // Processing succeeds, now the rest of the chain should be sent for processing. - rig.parent_block_processed_imported(block_root); - rig.expect_parent_chain_process(); - rig.parent_chain_processed_success(block_root, &[]); - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn test_parent_lookup_rpc_failure() { - let mut rig = TestRig::test_setup(); - - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - let id = rig.expect_block_parent_request(parent_root); - - // The request fails. It should be tried again. - rig.parent_lookup_failed_unavailable(id, peer_id); - let id = rig.expect_block_parent_request(parent_root); - - // Send the right block this time. - rig.parent_lookup_block_response(id, peer_id, Some(parent.into())); - rig.expect_block_process(ResponseType::Block); - - // Add peer to child lookup to prevent it being dropped - rig.trigger_unknown_block_from_attestation(block_root, peer_id); - // Processing succeeds, now the rest of the chain should be sent for processing. - rig.parent_block_processed_imported(block_root); - rig.expect_parent_chain_process(); - rig.parent_chain_processed_success(block_root, &[]); - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn test_parent_lookup_too_many_attempts() { - let mut rig = TestRig::test_setup(); - - let block = rig.rand_block(); - let parent_root = block.parent_root(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - for i in 1..=PARENT_FAIL_TOLERANCE { - let id = rig.expect_block_parent_request(parent_root); - // Blobs are only requested in the first iteration as this test only retries blocks - - if i % 2 == 0 { - // make sure every error is accounted for - // The request fails. It should be tried again. - rig.parent_lookup_failed_unavailable(id, peer_id); - } else { - // Send a bad block this time. It should be tried again. - let bad_block = rig.rand_block(); - rig.parent_lookup_block_response(id, peer_id, Some(bad_block.into())); - // Send the stream termination - - // Note, previously we would send the same lookup id with a stream terminator, - // we'd ignore it because we'd intrepret it as an unrequested response, since - // we already got one response for the block. I'm not sure what the intent is - // for having this stream terminator line in this test at all. Receiving an invalid - // block and a stream terminator with the same Id now results in two failed attempts, - // I'm unsure if this is how it should behave? - // - rig.parent_lookup_block_response(id, peer_id, None); - rig.expect_penalty(peer_id, "UnrequestedBlockRoot"); - } +#[tokio::test] +/// Assert that when creating multiple lookups their parent-child relation is discovered and we add +/// peers recursively from child to parent. +async fn lookups_form_chain() { + let depth = 5; + let mut r = TestRig::default(); + r.build_chain(depth).await; + for slot in (1..=depth).rev() { + r.trigger_with_block_at_slot(slot as u64); } + // TODO(tree-sync): Assert that there are `depth` disjoint chains + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); - rig.expect_no_active_lookups_empty_network(); + // Assert that the peers are added to ancestor lookups, + // - The lookup with max slot has 1 peer + // - The lookup with min slot has all the peers + for slot in 1..=(depth as u64) { + let lookup = r.lookup_by_root(r.block_root_at_slot(slot)); + assert_eq!( + lookup.seen_peers.len(), + 1 + depth - slot as usize, + "Unexpected peer count for lookup at slot {slot}" + ); + } } -#[test] -fn test_parent_lookup_too_many_download_attempts_no_blacklist() { - let mut rig = TestRig::test_setup(); +#[tokio::test] +/// 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(); + r.build_chain(PARENT_DEPTH_TOLERANCE + 1).await; + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - for i in 1..=PARENT_FAIL_TOLERANCE { - rig.assert_not_ignored_chain(block_root); - let id = rig.expect_block_parent_request(parent_root); - if i % 2 != 0 { - // The request fails. It should be tried again. - rig.parent_lookup_failed_unavailable(id, peer_id); - } else { - // Send a bad block this time. It should be tried again. - let bad_block = rig.rand_block(); - rig.parent_lookup_block_response(id, peer_id, Some(bad_block.into())); - rig.expect_penalty(peer_id, "UnrequestedBlockRoot"); - } - } - - rig.assert_not_ignored_chain(block_root); - rig.assert_not_ignored_chain(parent.canonical_root()); - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn test_parent_lookup_too_many_processing_attempts_must_blacklist() { - const PROCESSING_FAILURES: u8 = PARENT_FAIL_TOLERANCE / 2 + 1; - let mut rig = TestRig::test_setup(); - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.into()); - - rig.log("Fail downloading the block"); - for _ in 0..(PARENT_FAIL_TOLERANCE - PROCESSING_FAILURES) { - let id = rig.expect_block_parent_request(parent_root); - // The request fails. It should be tried again. - rig.parent_lookup_failed_unavailable(id, peer_id); - } - - rig.log("Now fail processing a block in the parent request"); - for _ in 0..PROCESSING_FAILURES { - let id = rig.expect_block_parent_request(parent_root); - // Blobs are only requested in the previous first iteration as this test only retries blocks - rig.assert_not_ignored_chain(block_root); - // send the right parent but fail processing - rig.parent_lookup_block_response(id, peer_id, Some(parent.clone().into())); - rig.parent_block_processed(block_root, BlockError::BlockSlotLimitReached.into()); - rig.parent_lookup_block_response(id, peer_id, None); - rig.expect_penalty(peer_id, "lookup_block_processing_failure"); - } - - rig.assert_not_ignored_chain(block_root); - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn test_parent_lookup_too_deep_grow_ancestor() { - let mut rig = TestRig::test_setup(); - let mut blocks = rig.rand_blockchain(PARENT_DEPTH_TOLERANCE); - - let peer_id = rig.new_connected_peer(); - let trigger_block = blocks.pop().unwrap(); - let chain_hash = trigger_block.canonical_root(); - rig.trigger_unknown_parent_block(peer_id, trigger_block); - - for block in blocks.into_iter().rev() { - let id = rig.expect_block_parent_request(block.canonical_root()); - // the block - rig.parent_lookup_block_response(id, peer_id, Some(block.clone())); - // the stream termination - rig.parent_lookup_block_response(id, peer_id, None); - // the processing request - rig.expect_block_process(ResponseType::Block); - // the processing result - rig.parent_block_processed( - chain_hash, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: block.parent_root(), - }), - ) - } - - // Should create a new syncing chain - rig.drain_sync_rx(); - assert_eq!( - rig.active_range_sync_chain(), - ( - RangeSyncType::Head, - Slot::new(0), - Slot::new(PARENT_DEPTH_TOLERANCE as u64 - 1) - ) - ); + r.assert_head_slot(PARENT_DEPTH_TOLERANCE as u64 + 1); + r.assert_no_penalties(); // Should not penalize peer, but network is not clear because of the blocks_by_range requests - rig.expect_no_penalty_for(peer_id); - rig.assert_ignored_chain(chain_hash); + // r.assert_ignored_chain(chain_hash); + // + // Assert that chain is in failed chains + // Assert that there were 0 lookups completed, 33 dropped + // Assert that there were 1 range sync chains + // Bound resources: + // - Limit amount of requests + // - Limit the types of sync used + assert_eq!(r.completed_lookups(), 0, "no completed lookups"); + assert_eq!( + r.dropped_lookups(), + PARENT_DEPTH_TOLERANCE, + "All lookups dropped" + ); + r.assert_successful_range_sync(); +} + +#[tokio::test] +async fn test_parent_lookup_too_deep_grow_ancestor_zero() { + let mut r = TestRig::default(); + r.build_chain(PARENT_DEPTH_TOLERANCE).await; + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + + r.assert_head_slot(PARENT_DEPTH_TOLERANCE as u64); + r.assert_no_penalties(); + assert_eq!( + r.completed_lookups(), + PARENT_DEPTH_TOLERANCE, + "completed all lookups" + ); + assert_eq!(r.dropped_lookups(), 0, "no dropped lookups"); } // Regression test for https://github.com/sigp/lighthouse/pull/7118 // 8042 UPDATE: block was previously added to the failed_chains cache, now it's inserted into the -// ignored chains cache. The regression test still applies as the chaild lookup is not created -#[test] -fn test_child_lookup_not_created_for_ignored_chain_parent_after_processing() { - // GIVEN: A parent chain longer than PARENT_DEPTH_TOLERANCE. - let mut rig = TestRig::test_setup(); - let mut blocks = rig.rand_blockchain(PARENT_DEPTH_TOLERANCE + 1); - let peer_id = rig.new_connected_peer(); - - // The child of the trigger block to be used to extend the chain. - let trigger_block_child = blocks.pop().unwrap(); - // The trigger block that starts the lookup. - let trigger_block = blocks.pop().unwrap(); - let tip_root = trigger_block.canonical_root(); - - // Trigger the initial unknown parent block for the tip. - rig.trigger_unknown_parent_block(peer_id, trigger_block.clone()); - - // Simulate the lookup chain building up via `ParentUnknown` errors. - for block in blocks.into_iter().rev() { - let id = rig.expect_block_parent_request(block.canonical_root()); - rig.parent_lookup_block_response(id, peer_id, Some(block.clone())); - rig.parent_lookup_block_response(id, peer_id, None); - rig.expect_block_process(ResponseType::Block); - rig.parent_block_processed( - tip_root, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: block.parent_root(), - }), - ); - } +// ignored chains cache. The regression test still applies as the child lookup is not created +#[tokio::test] +async fn test_child_lookup_not_created_for_ignored_chain_parent_after_processing() { + let mut r = TestRig::default(); + let depth = PARENT_DEPTH_TOLERANCE + 1; + r.build_chain(depth + 1).await; + r.trigger_with_block_at_slot(depth as u64); + r.simulate(SimulateConfig::new().no_range_sync()).await; // At this point, the chain should have been deemed too deep and pruned. // The tip root should have been inserted into ignored chains. - rig.assert_ignored_chain(tip_root); - rig.expect_no_penalty_for(peer_id); + // Ensure no blocks have been synced + r.assert_head_slot(0); + r.assert_no_active_lookups(); + r.assert_no_penalties(); + r.assert_ignored_chain(r.block_at_slot(depth as u64).canonical_root()); // WHEN: Trigger the extending block that points to the tip. - let trigger_block_child_root = trigger_block_child.canonical_root(); - rig.trigger_unknown_block_from_attestation(trigger_block_child_root, peer_id); - let id = rig.expect_block_lookup_request(trigger_block_child_root); - rig.single_lookup_block_response(id, peer_id, Some(trigger_block_child.clone())); - rig.single_lookup_block_response(id, peer_id, None); - rig.expect_block_process(ResponseType::Block); - rig.single_block_component_processed( - id.lookup_id, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: tip_root, - }), - ); - + let peer = r.new_connected_peer(); + r.trigger_unknown_parent_block(peer, r.block_at_slot(depth as u64 + 1)); // THEN: The extending block should not create a lookup because the tip was inserted into // ignored chains. - rig.expect_no_active_lookups(); - rig.expect_no_penalty_for(peer_id); - rig.expect_empty_network(); + r.assert_no_active_lookups(); + r.assert_no_penalties(); + r.assert_empty_network(); } -#[test] -fn test_parent_lookup_too_deep_grow_tip() { - let mut rig = TestRig::test_setup(); - let blocks = rig.rand_blockchain(PARENT_DEPTH_TOLERANCE - 1); - let peer_id = rig.new_connected_peer(); - let tip = blocks.last().unwrap().clone(); - - for block in blocks.into_iter() { - let block_root = block.canonical_root(); - rig.trigger_unknown_block_from_attestation(block_root, peer_id); - let id = rig.expect_block_parent_request(block_root); - rig.single_lookup_block_response(id, peer_id, Some(block.clone())); - rig.single_lookup_block_response(id, peer_id, None); - rig.expect_block_process(ResponseType::Block); - rig.single_block_component_processed( - id.lookup_id, - BlockError::ParentUnknown { - parent_root: block.parent_root(), - } - .into(), - ); +#[tokio::test] +/// Assert that if a lookup chain (by appending tips) is too long we drop it +async fn test_parent_lookup_too_deep_grow_tip() { + let depth = PARENT_DEPTH_TOLERANCE + 1; + let mut r = TestRig::default(); + r.build_chain(depth).await; + for slot in (1..=depth).rev() { + r.trigger_with_block_at_slot(slot as u64); } + r.simulate(SimulateConfig::happy_path()).await; - // Should create a new syncing chain - rig.drain_sync_rx(); + // Even if the chain is longer than `PARENT_DEPTH_TOLERANCE` because the lookups are created all + // at once they chain by sections and it's possible that the oldest ancestors start processing + // before the full chain is connected. + assert!(r.created_lookups() > 0, "no created lookups"); assert_eq!( - rig.active_range_sync_chain(), - ( - RangeSyncType::Head, - Slot::new(0), - Slot::new(PARENT_DEPTH_TOLERANCE as u64 - 2) - ) + r.completed_lookups(), + r.created_lookups(), + "not all completed lookups" ); + assert_eq!(r.dropped_lookups(), 0, "some dropped lookups"); + r.assert_successful_lookup_sync(); // Should not penalize peer, but network is not clear because of the blocks_by_range requests - rig.expect_no_penalty_for(peer_id); - rig.assert_ignored_chain(tip.canonical_root()); + r.assert_no_penalties(); } -#[test] -fn test_lookup_peer_disconnected_no_peers_left_while_request() { - let mut rig = TestRig::test_setup(); - let peer_id = rig.new_connected_peer(); - let trigger_block = rig.rand_block(); - rig.trigger_unknown_parent_block(peer_id, trigger_block.into()); - rig.peer_disconnected(peer_id); - rig.rpc_error_all_active_requests(peer_id); - // Erroring all rpc requests and disconnecting the peer shouldn't remove the requests - // from the lookups map as they can still progress. - rig.assert_single_lookups_count(2); -} - -#[test] -fn test_lookup_disconnection_peer_left() { - let mut rig = TestRig::test_setup(); - let peer_ids = (0..2).map(|_| rig.new_connected_peer()).collect::>(); - let disconnecting_peer = *peer_ids.first().unwrap(); - let block_root = Hash256::random(); - // lookup should have two peers associated with the same block - for peer_id in peer_ids.iter() { - rig.trigger_unknown_block_from_attestation(block_root, *peer_id); - } - // Disconnect the first peer only, which is the one handling the request - rig.peer_disconnected(disconnecting_peer); - rig.rpc_error_all_active_requests(disconnecting_peer); - rig.assert_single_lookups_count(1); -} - -#[test] -fn test_lookup_add_peers_to_parent() { - let mut r = TestRig::test_setup(); - let peer_id_1 = r.new_connected_peer(); - let peer_id_2 = r.new_connected_peer(); - let blocks = r.rand_blockchain(5); - let last_block_root = blocks.last().unwrap().canonical_root(); - // Create a chain of lookups - for block in &blocks { - r.trigger_unknown_parent_block(peer_id_1, block.clone()); - } - r.trigger_unknown_block_from_attestation(last_block_root, peer_id_2); - for block in blocks.iter().take(blocks.len() - 1) { - // Parent has the original unknown parent event peer + new peer - r.assert_lookup_peers(block.canonical_root(), vec![peer_id_1, peer_id_2]); - } - // Child lookup only has the unknown attestation peer - r.assert_lookup_peers(last_block_root, vec![peer_id_2]); -} - -#[test] -fn test_skip_creating_ignored_parent_lookup() { - let mut rig = TestRig::test_setup(); - let (_, block, parent_root, _) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - rig.insert_ignored_chain(parent_root); - rig.trigger_unknown_parent_block(peer_id, block.into()); - rig.expect_no_penalty_for(peer_id); +#[tokio::test] +async fn test_skip_creating_ignored_parent_lookup() { + let mut r = TestRig::default(); + r.build_chain(2).await; + r.insert_ignored_chain(r.block_root_at_slot(1)); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_no_penalties(); // Both current and parent lookup should not be created - rig.expect_no_active_lookups(); + r.assert_no_active_lookups(); } -#[test] -fn test_single_block_lookup_ignored_response() { - let mut rig = TestRig::test_setup(); +#[tokio::test] +/// Assert that if the oldest block in a chain is already imported (DuplicateFullyImported), +/// the remaining blocks in the chain are still processed successfully. This tests a race +/// condition where a block gets imported elsewhere while the lookup is processing. +/// +/// The processing sequence is: +/// - Block 3: UnknownParent (needs block 2) +/// - Block 2: UnknownParent (needs block 1) +/// - Block 1: About to be processed, but gets imported via gossip (race condition) +/// - Block 1: DuplicateFullyImported (already in chain from race) +/// - Block 2: Import ok (parent block 1 is available) +/// - Block 3: Import ok (parent block 2 is available) +async fn test_same_chain_race_condition() { + let mut r = TestRig::default(); + r.build_chain(3).await; - let block = rig.rand_block(); - let peer_id = rig.new_connected_peer(); + let block_1_root = r.block_root_at_slot(1); - // Trigger the request - rig.trigger_unknown_block_from_attestation(block.canonical_root(), peer_id); - let id = rig.expect_block_lookup_request(block.canonical_root()); + // Trigger a lookup with block 3. This creates a parent lookup chain that will + // request blocks 3 → 2 → 1. + r.trigger_with_block_at_slot(3); - // The peer provides the correct block, should not be penalized. Now the block should be sent - // for processing. - rig.single_lookup_block_response(id, peer_id, Some(block.into())); - rig.expect_empty_network(); - rig.expect_block_process(ResponseType::Block); + // Configure simulate to import block 1 right before it's processed by the lookup. + // This simulates the race condition where block 1 arrives via gossip at the same + // time the lookup is trying to process it. + r.simulate(SimulateConfig::new().with_import_block_before_process(block_1_root)) + .await; - // The request should still be active. - assert_eq!(rig.active_single_lookups_count(), 1); - - // Send the stream termination. Peer should have not been penalized, and the request removed - // after processing. - rig.single_lookup_block_response(id, peer_id, None); - // Send an Ignored response, the request should be dropped - rig.single_block_component_processed(id.lookup_id, BlockProcessingResult::Ignored); - rig.expect_no_active_lookups_empty_network(); + // The chain should complete successfully with head at slot 3, proving that + // the lookup correctly handled the DuplicateFullyImported for block 1 and + // continued processing blocks 2 and 3. + r.assert_head_slot(3); + r.assert_successful_lookup_sync(); } -#[test] -fn test_parent_lookup_ignored_response() { - let mut rig = TestRig::test_setup(); - - let (parent, block, parent_root, block_root) = rig.rand_block_and_parent(); - let peer_id = rig.new_connected_peer(); - - // Trigger the request - rig.trigger_unknown_parent_block(peer_id, block.clone().into()); - let id = rig.expect_block_parent_request(parent_root); - // Note: single block lookup for current `block` does not trigger any request because it does - // not have blobs, and the block is already cached - - // Peer sends the right block, it should be sent for processing. Peer should not be penalized. - rig.parent_lookup_block_response(id, peer_id, Some(parent.into())); - rig.expect_block_process(ResponseType::Block); - rig.expect_empty_network(); - - // Return an Ignored result. The request should be dropped - rig.parent_block_processed(block_root, BlockProcessingResult::Ignored); - rig.expect_empty_network(); - rig.expect_no_active_lookups(); -} - -/// This is a regression test. -#[test] -fn test_same_chain_race_condition() { - let mut rig = TestRig::test_setup(); - - // if we use one or two blocks it will match on the hash or the parent hash, so make a longer - // chain. - let depth = 4; - let mut blocks = rig.rand_blockchain(depth); - let peer_id = rig.new_connected_peer(); - let trigger_block = blocks.pop().unwrap(); - let chain_hash = trigger_block.canonical_root(); - rig.trigger_unknown_parent_block(peer_id, trigger_block.clone()); - - for (i, block) in blocks.clone().into_iter().rev().enumerate() { - let id = rig.expect_block_parent_request(block.canonical_root()); - // the block - rig.parent_lookup_block_response(id, peer_id, Some(block.clone())); - // the stream termination - rig.parent_lookup_block_response(id, peer_id, None); - // the processing request - rig.expect_block_process(ResponseType::Block); - // the processing result - if i + 2 == depth { - rig.log(&format!("Block {i} was removed and is already known")); - rig.parent_block_processed( - chain_hash, - BlockError::DuplicateFullyImported(block.canonical_root()).into(), - ) - } else { - rig.log(&format!("Block {i} ParentUnknown")); - rig.parent_block_processed( - chain_hash, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: block.parent_root(), - }), - ) - } - } - - // Try to get this block again while the chain is being processed. We should not request it again. - let peer_id = rig.new_connected_peer(); - rig.trigger_unknown_parent_block(peer_id, trigger_block.clone()); - rig.expect_empty_network(); - - // Add a peer to the tip child lookup which has zero peers - rig.trigger_unknown_block_from_attestation(trigger_block.canonical_root(), peer_id); - - rig.log("Processing succeeds, now the rest of the chain should be sent for processing."); - for block in blocks.iter().skip(1).chain(&[trigger_block]) { - rig.expect_parent_chain_process(); - rig.single_block_component_processed_imported(block.canonical_root()); - } - rig.expect_no_active_lookups_empty_network(); -} - -#[test] -fn block_in_da_checker_skips_download() { - let Some(mut r) = TestRig::test_setup_after_deneb_before_fulu() else { +#[tokio::test] +/// Assert that if the lookup's block is in the da_checker we don't download it again +async fn block_in_da_checker_skips_download() { + // Only in Deneb, as the block needs blobs to remain in the da_checker + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { return; }; - let (block, blobs) = r.rand_block_and_blobs(NumBlobs::Number(1)); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - r.insert_block_to_da_checker(block.into()); - r.trigger_unknown_block_from_attestation(block_root, peer_id); - // Should not trigger block request - let id = r.expect_blob_lookup_request(block_root); - r.expect_empty_network(); - // Resolve blob and expect lookup completed - r.complete_single_lookup_blob_lookup_valid(id, peer_id, blobs, true); - r.expect_no_active_lookups(); + // Add block to da_checker + // Complete test with happy path + // Assert that there were no requests for blocks + r.build_chain(1).await; + r.insert_block_to_da_chain_and_assert_missing_componens(r.block_at_slot(1)) + .await; + r.trigger_with_block_at_slot(1); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); + assert_eq!( + r.requests + .iter() + .filter(|(request, _)| matches!(request, RequestType::BlocksByRoot(_))) + .collect::>(), + Vec::<&(RequestType, AppRequestId)>::new(), + "There should be no block requests" + ); } -#[test] -fn block_in_processing_cache_becomes_invalid() { - let Some(mut r) = TestRig::test_setup_after_deneb_before_fulu() else { +#[tokio::test] +async fn block_in_processing_cache_becomes_invalid() { + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { return; }; - let (block, blobs) = r.rand_block_and_blobs(NumBlobs::Number(1)); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - r.insert_block_to_availability_cache(block.clone().into()); - r.trigger_unknown_block_from_attestation(block_root, peer_id); - // Should trigger blob request - let id = r.expect_blob_lookup_request(block_root); - // Should not trigger block request - r.expect_empty_network(); + r.build_chain(1).await; + let block = r.block_at_slot(1); + r.insert_block_to_da_checker_as_pre_execution(block.clone()); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_pending_lookup_sync(); + // Here the only active lookup is waiting for the block to finish processing + // Simulate invalid block, removing it from processing cache - r.simulate_block_gossip_processing_becomes_invalid(block_root); + r.simulate_block_gossip_processing_becomes_invalid(block.canonical_root()); // Should download block, then issue blobs request - r.complete_lookup_block_download(block); - // Should not trigger block or blob request - r.expect_empty_network(); - r.complete_lookup_block_import_valid(block_root, false); - // Resolve blob and expect lookup completed - r.complete_single_lookup_blob_lookup_valid(id, peer_id, blobs, true); - r.expect_no_active_lookups(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_successful_lookup_sync(); } -#[test] -fn block_in_processing_cache_becomes_valid_imported() { - let Some(mut r) = TestRig::test_setup_after_deneb_before_fulu() else { +#[tokio::test] +async fn block_in_processing_cache_becomes_valid_imported() { + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { return; }; - let (block, blobs) = r.rand_block_and_blobs(NumBlobs::Number(1)); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - r.insert_block_to_availability_cache(block.clone().into()); - r.trigger_unknown_block_from_attestation(block_root, peer_id); - // Should trigger blob request - let id = r.expect_blob_lookup_request(block_root); - // Should not trigger block request - r.expect_empty_network(); + r.build_chain(1).await; + let block = r.block_at_slot(1); + r.insert_block_to_da_checker_as_pre_execution(block.clone()); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_pending_lookup_sync(); + // Here the only active lookup is waiting for the block to finish processing + // Resolve the block from processing step - r.simulate_block_gossip_processing_becomes_valid_missing_components(block.into()); + r.simulate_block_gossip_processing_becomes_valid(block) + .await; // Should not trigger block or blob request - r.expect_empty_network(); + r.assert_empty_network(); // Resolve blob and expect lookup completed - r.complete_single_lookup_blob_lookup_valid(id, peer_id, blobs, true); - r.expect_no_active_lookups(); + r.assert_no_active_lookups(); } // IGNORE: wait for change that delays blob fetching to knowing the block -#[ignore] -#[test] -fn blobs_in_da_checker_skip_download() { - let Some(mut r) = TestRig::test_setup_after_deneb_before_fulu() else { +#[tokio::test] +async fn blobs_in_da_checker_skip_download() { + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { return; }; - let (block, blobs) = r.rand_block_and_blobs(NumBlobs::Number(1)); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - for blob in blobs { - r.insert_blob_to_da_checker(blob); + r.build_chain(1).await; + let block = r.get_last_block().clone(); + let blobs = block + .block_data() + .and_then(|d| d.blobs()) + .expect("block with no blobs"); + for blob in &blobs { + r.insert_blob_to_da_checker(blob.clone()); } - r.trigger_unknown_block_from_attestation(block_root, peer_id); - // Should download and process the block - r.complete_single_lookup_block_valid(block, true); - // Should not trigger blob request - r.expect_empty_network(); - r.expect_no_active_lookups(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + + r.assert_successful_lookup_sync(); + assert_eq!( + r.requests + .iter() + .filter(|(request, _)| matches!(request, RequestType::BlobsByRoot(_))) + .collect::>(), + Vec::<&(RequestType, AppRequestId)>::new(), + "There should be no blob requests" + ); } -#[test] -fn custody_lookup_happy_path() { - let Some(mut r) = TestRig::test_setup_after_fulu() else { +macro_rules! fulu_peer_matrix_tests { + ( + [$($name:ident => $variant:expr),+ $(,)?] + ) => { + paste::paste! { + $( + #[tokio::test] + async fn []() { + custody_lookup_happy_path($variant).await; + } + + #[tokio::test] + async fn []() { + custody_lookup_some_custody_failures($variant).await; + } + + #[tokio::test] + async fn []() { + custody_lookup_permanent_custody_failures($variant).await; + } + )+ + } + }; +} + +fulu_peer_matrix_tests!( + [ + we_supernode_them_supernode => FuluTestType::WeSupernodeThemSupernode, + we_supernode_them_fullnodes => FuluTestType::WeSupernodeThemFullnodes, + we_fullnode_them_supernode => FuluTestType::WeFullnodeThemSupernode, + we_fullnode_them_fullnodes => FuluTestType::WeFullnodeThemFullnodes, + ] +); + +async fn custody_lookup_happy_path(test_type: FuluTestType) { + let Some(mut r) = TestRig::new_fulu_peer_test(test_type) else { return; }; - let spec = E::default_spec(); + r.build_chain(1).await; r.new_connected_peers_for_peerdas(); - let (block, data_columns) = r.rand_block_and_data_columns(); - let block_root = block.canonical_root(); - let peer_id = r.new_connected_peer(); - r.trigger_unknown_block_from_attestation(block_root, peer_id); - // Should not request blobs - let id = r.expect_block_lookup_request(block.canonical_root()); - r.complete_valid_block_request(id, block.into(), true); - // for each slot we download `samples_per_slot` columns - let sample_column_count = spec.samples_per_slot * spec.data_columns_per_group::(); - let custody_ids = - r.expect_only_data_columns_by_root_requests(block_root, sample_column_count as usize); - r.complete_valid_custody_request(custody_ids, data_columns, false); - r.expect_no_active_lookups(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_no_penalties(); + r.assert_successful_lookup_sync(); } +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); + } + let custody_columns = r.custody_columns(); + r.simulate(SimulateConfig::new().return_no_columns_on_indices(&custody_columns[..4], 3)) + .await; + r.assert_penalties_of_type("NotEnoughResponsesReturned"); + r.assert_successful_lookup_sync(); +} + +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); + } + + let custody_columns = r.custody_columns(); + r.simulate( + SimulateConfig::new().return_no_columns_on_indices(&custody_columns[..2], usize::MAX), + ) + .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"); + r.assert_failed_lookup_sync(); +} + +// We supernode, diverse peers +// We not supernode, diverse peers + // TODO(das): Test retries of DataColumnByRoot: // - Expect request for column_index // - Respond with bad data // - Respond with stream terminator // ^ The stream terminator should be ignored and not close the next retry -mod deneb_only { - use super::*; - use beacon_chain::{ - block_verification_types::{AsBlock, RpcBlock}, - data_availability_checker::AvailabilityCheckError, - }; - use std::collections::VecDeque; - - struct DenebTester { - rig: TestRig, - block: Arc>, - blobs: Vec>>, - parent_block_roots: Vec, - parent_block: VecDeque>>, - parent_blobs: VecDeque>>>, - unknown_parent_block: Option>>, - unknown_parent_blobs: Option>>>, - peer_id: PeerId, - block_req_id: Option, - parent_block_req_id: Option, - blob_req_id: Option, - parent_blob_req_id: Option, - slot: Slot, - block_root: Hash256, - } - - enum RequestTrigger { - AttestationUnknownBlock, - GossipUnknownParentBlock(usize), - GossipUnknownParentBlob(usize), - } - - impl RequestTrigger { - fn num_parents(&self) -> usize { - match self { - RequestTrigger::AttestationUnknownBlock => 0, - RequestTrigger::GossipUnknownParentBlock(num_parents) => *num_parents, - RequestTrigger::GossipUnknownParentBlob(num_parents) => *num_parents, - } - } - } - - impl DenebTester { - fn new(request_trigger: RequestTrigger) -> Option { - let Some(mut rig) = TestRig::test_setup_after_deneb_before_fulu() else { - return None; - }; - let (block, blobs) = rig.rand_block_and_blobs(NumBlobs::Random); - let mut block = Arc::new(block); - let mut blobs = blobs.into_iter().map(Arc::new).collect::>(); - let slot = block.slot(); - - let num_parents = request_trigger.num_parents(); - let mut parent_block_chain = VecDeque::with_capacity(num_parents); - let mut parent_blobs_chain = VecDeque::with_capacity(num_parents); - let mut parent_block_roots = vec![]; - for _ in 0..num_parents { - // Set the current block as the parent. - let parent_root = block.canonical_root(); - let parent_block = block.clone(); - let parent_blobs = blobs.clone(); - parent_block_chain.push_front(parent_block); - parent_blobs_chain.push_front(parent_blobs); - parent_block_roots.push(parent_root); - - // Create the next block. - let (child_block, child_blobs) = - rig.block_with_parent_and_blobs(parent_root, NumBlobs::Random); - let mut child_block = Arc::new(child_block); - let mut child_blobs = child_blobs.into_iter().map(Arc::new).collect::>(); - - // Update the new block to the current block. - std::mem::swap(&mut child_block, &mut block); - std::mem::swap(&mut child_blobs, &mut blobs); - } - let block_root = block.canonical_root(); - - let peer_id = rig.new_connected_peer(); - - // Trigger the request - let (block_req_id, blob_req_id, parent_block_req_id, parent_blob_req_id) = - match request_trigger { - RequestTrigger::AttestationUnknownBlock => { - rig.send_sync_message(SyncMessage::UnknownBlockHashFromAttestation( - peer_id, block_root, - )); - let block_req_id = rig.expect_block_lookup_request(block_root); - (Some(block_req_id), None, None, None) - } - RequestTrigger::GossipUnknownParentBlock { .. } => { - rig.send_sync_message(SyncMessage::UnknownParentBlock( - peer_id, - block.clone(), - block_root, - )); - - let parent_root = block.parent_root(); - let parent_block_req_id = rig.expect_block_parent_request(parent_root); - rig.expect_empty_network(); // expect no more requests - (None, None, Some(parent_block_req_id), None) - } - RequestTrigger::GossipUnknownParentBlob { .. } => { - let single_blob = blobs.first().cloned().unwrap(); - let parent_root = single_blob.block_parent_root(); - rig.send_sync_message(SyncMessage::UnknownParentBlob(peer_id, single_blob)); - - let parent_block_req_id = rig.expect_block_parent_request(parent_root); - rig.expect_empty_network(); // expect no more requests - (None, None, Some(parent_block_req_id), None) - } - }; - - Some(Self { - rig, - block, - blobs, - parent_block: parent_block_chain, - parent_blobs: parent_blobs_chain, - parent_block_roots, - unknown_parent_block: None, - unknown_parent_blobs: None, - peer_id, - block_req_id, - parent_block_req_id, - blob_req_id, - parent_blob_req_id, - slot, - block_root, - }) - } - - fn trigger_unknown_block_from_attestation(mut self) -> Self { - let block_root = self.block.canonical_root(); - self.rig - .trigger_unknown_block_from_attestation(block_root, self.peer_id); - self - } - - fn parent_block_response(mut self) -> Self { - self.rig.expect_empty_network(); - let block = self.parent_block.pop_front().unwrap().clone(); - let _ = self.unknown_parent_block.insert(block.clone()); - self.rig.parent_lookup_block_response( - self.parent_block_req_id.expect("parent request id"), - self.peer_id, - Some(block), - ); - - self.rig.assert_parent_lookups_count(1); - self - } - - fn parent_block_response_expect_blobs(mut self) -> Self { - self.rig.expect_empty_network(); - let block = self.parent_block.pop_front().unwrap().clone(); - let _ = self.unknown_parent_block.insert(block.clone()); - self.rig.parent_lookup_block_response( - self.parent_block_req_id.expect("parent request id"), - self.peer_id, - Some(block), - ); - - // Expect blobs request after sending block - let s = self.expect_parent_blobs_request(); - - s.rig.assert_parent_lookups_count(1); - s - } - - fn parent_blob_response(mut self) -> Self { - let blobs = self.parent_blobs.pop_front().unwrap(); - let _ = self.unknown_parent_blobs.insert(blobs.clone()); - for blob in &blobs { - self.rig.parent_lookup_blob_response( - self.parent_blob_req_id.expect("parent blob request id"), - self.peer_id, - Some(blob.clone()), - ); - assert_eq!(self.rig.active_parent_lookups_count(), 1); - } - self.rig.parent_lookup_blob_response( - self.parent_blob_req_id.expect("parent blob request id"), - self.peer_id, - None, - ); - - self - } - - fn block_response_triggering_process(self) -> Self { - let mut me = self.block_response_and_expect_blob_request(); - me.rig.expect_block_process(ResponseType::Block); - - // The request should still be active. - assert_eq!(me.rig.active_single_lookups_count(), 1); - me - } - - fn block_response_and_expect_blob_request(mut self) -> Self { - // The peer provides the correct block, should not be penalized. Now the block should be sent - // for processing. - self.rig.single_lookup_block_response( - self.block_req_id.expect("block request id"), - self.peer_id, - Some(self.block.clone()), - ); - // After responding with block the node will issue a blob request - let mut s = self.expect_blobs_request(); - - s.rig.expect_empty_network(); - - // The request should still be active. - s.rig.assert_lookup_is_active(s.block.canonical_root()); - s - } - - fn blobs_response(mut self) -> Self { - self.rig - .log(&format!("blobs response {}", self.blobs.len())); - for blob in &self.blobs { - self.rig.single_lookup_blob_response( - self.blob_req_id.expect("blob request id"), - self.peer_id, - Some(blob.clone()), - ); - self.rig - .assert_lookup_is_active(self.block.canonical_root()); - } - self.rig.single_lookup_blob_response( - self.blob_req_id.expect("blob request id"), - self.peer_id, - None, - ); - self - } - - fn blobs_response_was_valid(mut self) -> Self { - self.rig.expect_empty_network(); - if !self.blobs.is_empty() { - self.rig.expect_block_process(ResponseType::Blob); - } - self - } - - fn expect_empty_beacon_processor(mut self) -> Self { - self.rig.expect_empty_beacon_processor(); - self - } - - fn empty_block_response(mut self) -> Self { - self.rig.single_lookup_block_response( - self.block_req_id.expect("block request id"), - self.peer_id, - None, - ); - self - } - - fn empty_blobs_response(mut self) -> Self { - self.rig.single_lookup_blob_response( - self.blob_req_id.expect("blob request id"), - self.peer_id, - None, - ); - self - } - - fn empty_parent_blobs_response(mut self) -> Self { - self.rig.parent_lookup_blob_response( - self.parent_blob_req_id.expect("blob request id"), - self.peer_id, - None, - ); - self - } - - fn block_missing_components(mut self) -> Self { - self.rig.single_block_component_processed( - self.block_req_id.expect("block request id").lookup_id, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - self.block.slot(), - self.block_root, - )), - ); - self.rig.expect_empty_network(); - self.rig.assert_single_lookups_count(1); - self - } - - fn blob_imported(mut self) -> Self { - self.rig.single_blob_component_processed( - self.blob_req_id.expect("blob request id").lookup_id, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(self.block_root)), - ); - self.rig.expect_empty_network(); - self.rig.assert_single_lookups_count(0); - self - } - - fn block_imported(mut self) -> Self { - // Missing blobs should be the request is not removed, the outstanding blobs request should - // mean we do not send a new request. - self.rig.single_block_component_processed( - self.block_req_id - .or(self.blob_req_id) - .expect("block request id") - .lookup_id, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(self.block_root)), - ); - self.rig.expect_empty_network(); - self.rig.assert_single_lookups_count(0); - self - } - - fn parent_block_imported(mut self) -> Self { - let parent_root = *self.parent_block_roots.first().unwrap(); - self.rig - .log(&format!("parent_block_imported {parent_root:?}")); - self.rig.parent_block_processed( - self.block_root, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(parent_root)), - ); - self.rig.expect_no_requests_for(parent_root); - self.rig.assert_parent_lookups_count(0); - self - } - - fn parent_block_missing_components(mut self) -> Self { - let parent_root = *self.parent_block_roots.first().unwrap(); - self.rig - .log(&format!("parent_block_missing_components {parent_root:?}")); - self.rig.parent_block_processed( - self.block_root, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - Slot::new(0), - parent_root, - )), - ); - self.rig.expect_no_requests_for(parent_root); - self - } - - fn parent_blob_imported(mut self) -> Self { - let parent_root = *self.parent_block_roots.first().unwrap(); - self.rig - .log(&format!("parent_blob_imported {parent_root:?}")); - self.rig.parent_blob_processed( - self.block_root, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::Imported(parent_root)), - ); - - self.rig.expect_no_requests_for(parent_root); - self.rig.assert_parent_lookups_count(0); - self - } - - fn parent_block_unknown_parent(mut self) -> Self { - self.rig.log("parent_block_unknown_parent"); - let block = self.unknown_parent_block.take().unwrap(); - // Now this block is the one we expect requests from - self.block = block.clone(); - let block = RpcBlock::new( - block, - None, - &self.rig.harness.chain.data_availability_checker, - self.rig.harness.chain.spec.clone(), - ) - .unwrap(); - self.rig.parent_block_processed( - self.block_root, - BlockProcessingResult::Err(BlockError::ParentUnknown { - parent_root: block.parent_root(), - }), - ); - assert_eq!(self.rig.active_parent_lookups_count(), 1); - self - } - - fn invalid_parent_processed(mut self) -> Self { - self.rig.parent_block_processed( - self.block_root, - BlockProcessingResult::Err(BlockError::BlockSlotLimitReached), - ); - assert_eq!(self.rig.active_parent_lookups_count(), 1); - self - } - - fn invalid_block_processed(mut self) -> Self { - self.rig.single_block_component_processed( - self.block_req_id.expect("block request id").lookup_id, - BlockProcessingResult::Err(BlockError::BlockSlotLimitReached), - ); - self.rig.assert_single_lookups_count(1); - self - } - - fn invalid_blob_processed(mut self) -> Self { - self.rig.log("invalid_blob_processed"); - self.rig.single_blob_component_processed( - self.blob_req_id.expect("blob request id").lookup_id, - BlockProcessingResult::Err(BlockError::AvailabilityCheck( - AvailabilityCheckError::InvalidBlobs(kzg::Error::KzgVerificationFailed), - )), - ); - self.rig.assert_single_lookups_count(1); - self - } - - fn missing_components_from_block_request(mut self) -> Self { - self.rig.single_block_component_processed( - self.block_req_id.expect("block request id").lookup_id, - BlockProcessingResult::Ok(AvailabilityProcessingStatus::MissingComponents( - self.slot, - self.block_root, - )), - ); - // Add block to da_checker so blobs request can continue - self.rig.insert_block_to_da_checker(self.block.clone()); - - self.rig.assert_single_lookups_count(1); - self - } - - fn complete_current_block_and_blobs_lookup(self) -> Self { - self.expect_block_request() - .block_response_and_expect_blob_request() - .blobs_response() - // TODO: Should send blobs for processing - .expect_block_process() - .block_imported() - } - - fn log(self, msg: &str) -> Self { - self.rig.log(msg); - self - } - - fn parent_block_then_empty_parent_blobs(self) -> Self { - self.log( - " Return empty blobs for parent, block errors with missing components, downscore", - ) - .parent_block_response() - .expect_parent_blobs_request() - .empty_parent_blobs_response() - .expect_penalty("NotEnoughResponsesReturned") - .log("Re-request parent blobs, succeed and import parent") - .expect_parent_blobs_request() - .parent_blob_response() - .expect_block_process() - .parent_block_missing_components() - // Insert new peer into child request before completing parent - .trigger_unknown_block_from_attestation() - .parent_blob_imported() - } - - fn expect_penalty(mut self, expect_penalty_msg: &'static str) -> Self { - self.rig.expect_penalty(self.peer_id, expect_penalty_msg); - self - } - fn expect_no_penalty(mut self) -> Self { - self.rig.expect_empty_network(); - self - } - fn expect_no_penalty_and_no_requests(mut self) -> Self { - self.rig.expect_empty_network(); - self - } - fn expect_block_request(mut self) -> Self { - let id = self - .rig - .expect_block_lookup_request(self.block.canonical_root()); - self.block_req_id = Some(id); - self - } - fn expect_blobs_request(mut self) -> Self { - let id = self - .rig - .expect_blob_lookup_request(self.block.canonical_root()); - self.blob_req_id = Some(id); - self - } - fn expect_parent_block_request(mut self) -> Self { - let id = self - .rig - .expect_block_parent_request(self.block.parent_root()); - self.parent_block_req_id = Some(id); - self - } - fn expect_parent_blobs_request(mut self) -> Self { - let id = self - .rig - .expect_blob_parent_request(self.block.parent_root()); - self.parent_blob_req_id = Some(id); - self - } - fn expect_no_blobs_request(mut self) -> Self { - self.rig.expect_empty_network(); - self - } - fn expect_no_block_request(mut self) -> Self { - self.rig.expect_empty_network(); - self - } - fn invalidate_blobs_too_few(mut self) -> Self { - self.blobs.pop().expect("blobs"); - self - } - fn expect_block_process(mut self) -> Self { - self.rig.expect_block_process(ResponseType::Block); - self - } - fn expect_no_active_lookups(self) -> Self { - self.rig.expect_no_active_lookups(); - self - } - fn search_parent_dup(mut self) -> Self { - self.rig - .trigger_unknown_parent_block(self.peer_id, self.block.clone()); - self - } - } - - #[test] - fn single_block_and_blob_lookup_block_returned_first_attestation() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .block_response_and_expect_blob_request() - .blobs_response() - .block_missing_components() // blobs not yet imported - .blobs_response_was_valid() - .blob_imported(); // now blobs resolve as imported - } - - #[test] - fn single_block_response_then_empty_blob_response_attestation() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .block_response_and_expect_blob_request() - .missing_components_from_block_request() - .empty_blobs_response() - .expect_penalty("NotEnoughResponsesReturned") - .expect_blobs_request() - .expect_no_block_request(); - } - - #[test] - fn single_invalid_block_response_then_blob_response_attestation() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .block_response_triggering_process() - .invalid_block_processed() - .expect_penalty("lookup_block_processing_failure") - .expect_block_request() - .expect_no_blobs_request() - .blobs_response() - // blobs not sent for processing until the block is processed - .expect_no_penalty_and_no_requests(); - } - - #[test] - fn single_block_response_then_invalid_blob_response_attestation() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .block_response_triggering_process() - .missing_components_from_block_request() - .blobs_response() - .invalid_blob_processed() - .expect_penalty("lookup_blobs_processing_failure") - .expect_blobs_request() - .expect_no_block_request(); - } - - #[test] - fn single_block_response_then_too_few_blobs_response_attestation() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .block_response_triggering_process() - .missing_components_from_block_request() - .invalidate_blobs_too_few() - .blobs_response() - .expect_penalty("NotEnoughResponsesReturned") - .expect_blobs_request() - .expect_no_block_request(); - } - - // Test peer returning block that has unknown parent, and a new lookup is created - #[test] - fn parent_block_unknown_parent() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlock(1)) else { - return; - }; - tester - .expect_empty_beacon_processor() - .parent_block_response_expect_blobs() - .parent_blob_response() - .expect_block_process() - .parent_block_unknown_parent() - .expect_parent_block_request() - .expect_empty_beacon_processor(); - } - - // Test peer returning invalid (processing) block, expect retry - #[test] - fn parent_block_invalid_parent() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlock(1)) else { - return; - }; - tester - .parent_block_response_expect_blobs() - .parent_blob_response() - .expect_block_process() - .invalid_parent_processed() - .expect_penalty("lookup_block_processing_failure") - .expect_parent_block_request() - .expect_empty_beacon_processor(); - } - - // Tests that if a peer does not respond with a block, we downscore and retry the block only - #[test] - fn empty_block_is_retried() { - let Some(tester) = DenebTester::new(RequestTrigger::AttestationUnknownBlock) else { - return; - }; - tester - .empty_block_response() - .expect_penalty("NotEnoughResponsesReturned") - .expect_block_request() - .expect_no_blobs_request() - .block_response_and_expect_blob_request() - .blobs_response() - .block_imported() - .expect_no_active_lookups(); - } - - #[test] - fn parent_block_then_empty_parent_blobs() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlock(1)) else { - return; - }; - tester - .parent_block_then_empty_parent_blobs() - .log("resolve original block trigger blobs request and import") - // Should not have block request, it is cached - .expect_blobs_request() - // TODO: Should send blobs for processing - .block_imported() - .expect_no_active_lookups(); - } - - #[test] - fn parent_blob_unknown_parent() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(1)) else { - return; - }; - tester - .expect_empty_beacon_processor() - .parent_block_response_expect_blobs() - .parent_blob_response() - .expect_block_process() - .parent_block_unknown_parent() - .expect_parent_block_request() - .expect_empty_beacon_processor(); - } - - #[test] - fn parent_blob_invalid_parent() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(1)) else { - return; - }; - tester - .expect_empty_beacon_processor() - .parent_block_response_expect_blobs() - .parent_blob_response() - .expect_block_process() - .invalid_parent_processed() - .expect_penalty("lookup_block_processing_failure") - .expect_parent_block_request() - // blobs are not sent until block is processed - .expect_empty_beacon_processor(); - } - - #[test] - fn parent_block_and_blob_lookup_parent_returned_first_blob_trigger() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(1)) else { - return; - }; - tester - .parent_block_response() - .expect_parent_blobs_request() - .parent_blob_response() - .expect_block_process() - .trigger_unknown_block_from_attestation() - .parent_block_imported() - .complete_current_block_and_blobs_lookup() - .expect_no_active_lookups(); - } - - #[test] - fn parent_block_then_empty_parent_blobs_blob_trigger() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(1)) else { - return; - }; - tester - .parent_block_then_empty_parent_blobs() - .log("resolve original block trigger blobs request and import") - .complete_current_block_and_blobs_lookup() - .expect_no_active_lookups(); - } - - #[test] - fn parent_blob_unknown_parent_chain() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(2)) else { - return; - }; - tester - .expect_empty_beacon_processor() - .parent_block_response_expect_blobs() - .parent_blob_response() - .expect_no_penalty() - .expect_block_process() - .parent_block_unknown_parent() - .expect_parent_block_request() - .expect_empty_beacon_processor() - .parent_block_response() - .expect_parent_blobs_request() - .parent_blob_response() - .expect_no_penalty() - .expect_block_process(); - } - - #[test] - fn unknown_parent_block_dup() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlock(1)) else { - return; - }; - tester - .search_parent_dup() - .expect_no_blobs_request() - .expect_no_block_request(); - } - - #[test] - fn unknown_parent_blob_dup() { - let Some(tester) = DenebTester::new(RequestTrigger::GossipUnknownParentBlob(1)) else { - return; - }; - tester - .search_parent_dup() - .expect_no_blobs_request() - .expect_no_block_request(); - } - - // This test no longer applies, we don't issue requests for child lookups - // Keep for after updating rules on fetching blocks only first - #[ignore] - #[test] - fn no_peer_penalty_when_rpc_response_already_known_from_gossip() { - let Some(mut r) = TestRig::test_setup_after_deneb_before_fulu() else { - return; - }; - let (block, blobs) = r.rand_block_and_blobs(NumBlobs::Number(2)); - let block_root = block.canonical_root(); - let blob_0 = blobs[0].clone(); - let blob_1 = blobs[1].clone(); - let peer_a = r.new_connected_peer(); - let peer_b = r.new_connected_peer(); - // Send unknown parent block lookup - r.trigger_unknown_parent_block(peer_a, block.into()); - // Expect network request for blobs - let id = r.expect_blob_lookup_request(block_root); - // Peer responses with blob 0 - r.single_lookup_blob_response(id, peer_a, Some(blob_0.into())); - // Blob 1 is received via gossip unknown parent blob from a different peer - r.trigger_unknown_parent_blob(peer_b, blob_1.clone()); - // Original peer sends blob 1 via RPC - r.single_lookup_blob_response(id, peer_a, Some(blob_1.into())); - // Assert no downscore event for original peer - r.expect_no_penalty_for(peer_a); +// These `crypto_on` tests assert that the fake_crytpo feature works as expected. We run only the +// `crypto_on` tests without the fake_crypto feature and make sure that processing fails, = to +// assert that signatures and kzg proofs are checked +#[tokio::test] +async fn crypto_on_fail_with_invalid_block_signature() { + let mut r = TestRig::default(); + r.build_chain(1).await; + r.corrupt_last_block_signature(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + r.assert_successful_lookup_sync(); + r.assert_no_penalties(); + } else { + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_block_processing_failure"); + } +} + +#[tokio::test] +async fn crypto_on_fail_with_bad_blob_proposer_signature() { + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { + return; + }; + r.build_chain(1).await; + r.corrupt_last_blob_proposer_signature(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + r.assert_successful_lookup_sync(); + r.assert_no_penalties(); + } else { + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_blobs_processing_failure"); + } +} + +#[tokio::test] +async fn crypto_on_fail_with_bad_blob_kzg_proof() { + let Some(mut r) = TestRig::new_after_deneb_before_fulu() else { + return; + }; + r.build_chain(1).await; + r.corrupt_last_blob_kzg_proof(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + r.assert_successful_lookup_sync(); + r.assert_no_penalties(); + } else { + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_blobs_processing_failure"); + } +} + +#[tokio::test] +async fn crypto_on_fail_with_bad_column_proposer_signature() { + let Some(mut r) = TestRig::new_fulu_peer_test(FuluTestType::WeSupernodeThemSupernode) else { + return; + }; + r.build_chain(1).await; + r.corrupt_last_column_proposer_signature(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + r.assert_successful_lookup_sync(); + r.assert_no_penalties(); + } else { + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_custody_column_processing_failure"); + } +} + +#[tokio::test] +async fn crypto_on_fail_with_bad_column_kzg_proof() { + let Some(mut r) = TestRig::new_fulu_peer_test(FuluTestType::WeSupernodeThemSupernode) else { + return; + }; + r.build_chain(1).await; + r.corrupt_last_column_kzg_proof(); + r.trigger_with_last_block(); + r.simulate(SimulateConfig::happy_path()).await; + if cfg!(feature = "fake_crypto") { + r.assert_successful_lookup_sync(); + r.assert_no_penalties(); + } else { + r.assert_failed_lookup_sync(); + r.assert_penalties_of_type("lookup_custody_column_processing_failure"); } } diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index dcc7e3e49d..f00cf5841d 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -1,13 +1,19 @@ use crate::NetworkMessage; use crate::sync::SyncMessage; +use crate::sync::block_lookups::BlockLookupsMetrics; use crate::sync::manager::SyncManager; -use crate::sync::range_sync::RangeSyncType; +use crate::sync::tests::lookups::SimulateConfig; +use beacon_chain::block_verification_types::RpcBlock; use beacon_chain::builder::Witness; +use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::test_utils::{BeaconChainHarness, EphemeralHarnessType}; use beacon_processor::WorkEvent; -use lighthouse_network::NetworkGlobals; +use lighthouse_network::rpc::RequestType; +use lighthouse_network::service::api_types::{AppRequestId, Id}; +use lighthouse_network::{NetworkGlobals, PeerId}; use rand_chacha::ChaCha20Rng; use slot_clock::ManualSlotClock; +use std::collections::{HashMap, HashSet}; use std::fs::OpenOptions; use std::io::Write; use std::sync::{Arc, Once}; @@ -16,7 +22,7 @@ use tokio::sync::mpsc; use tracing_subscriber::fmt::MakeWriter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; -use types::{ForkName, MinimalEthSpec as E}; +use types::{ForkName, Hash256, MinimalEthSpec as E, Slot}; mod lookups; mod range; @@ -58,6 +64,8 @@ struct TestRig { network_rx_queue: Vec>, /// Receiver for `SyncMessage` from the network sync_rx: mpsc::UnboundedReceiver>, + /// Stores all `SyncMessage`s received from `sync_rx` + sync_rx_queue: Vec>, /// To send `SyncMessage`. For sending RPC responses or block processing results to sync. sync_manager: SyncManager, /// To manipulate sync state and peer connection status @@ -68,6 +76,65 @@ struct TestRig { rng_08: rand_chacha_03::ChaCha20Rng, rng: ChaCha20Rng, fork_name: ForkName, + /// 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>, + penalties: Vec, + /// All seen lookups through the test run + seen_lookups: HashMap, + /// Registry of all requests done by the test + requests: Vec<(RequestType, AppRequestId)>, + /// Persistent config on how to complete request + complete_strategy: SimulateConfig, + /// Metrics values to allow a reset + initial_block_lookups_metrics: BlockLookupsMetrics, + /// Fulu test type + fulu_test_type: FuluTestType, +} + +enum FuluTestType { + WeSupernodeThemSupernode, + WeSupernodeThemFullnodes, + WeFullnodeThemSupernode, + WeFullnodeThemFullnodes, +} + +impl FuluTestType { + fn we_node_custody_type(&self) -> NodeCustodyType { + match self { + Self::WeSupernodeThemSupernode | Self::WeSupernodeThemFullnodes => { + NodeCustodyType::Supernode + } + Self::WeFullnodeThemSupernode | Self::WeFullnodeThemFullnodes => { + NodeCustodyType::Fullnode + } + } + } + + fn them_node_custody_type(&self) -> NodeCustodyType { + match self { + Self::WeSupernodeThemSupernode | Self::WeFullnodeThemSupernode => { + NodeCustodyType::Supernode + } + Self::WeSupernodeThemFullnodes | Self::WeFullnodeThemFullnodes => { + NodeCustodyType::Fullnode + } + } + } +} + +#[derive(Debug)] +struct SeenLookup { + /// Lookup's Id + id: Id, + block_root: Hash256, + seen_peers: HashSet, +} + +#[derive(Debug)] +struct ReportedPenalty { + pub peer_id: PeerId, + pub msg: &'static str, } // Environment variable to read if `fork_from_env` feature is enabled. diff --git a/beacon_node/network/src/sync/tests/range.rs b/beacon_node/network/src/sync/tests/range.rs index 6f129bc8f0..67395ccd25 100644 --- a/beacon_node/network/src/sync/tests/range.rs +++ b/beacon_node/network/src/sync/tests/range.rs @@ -185,7 +185,7 @@ impl TestRig { } #[track_caller] - fn expect_chain_segments(&mut self, count: usize) { + fn assert_chain_segments(&mut self, count: usize) { for i in 0..count { self.pop_received_processor_event(|ev| { (ev.work_type() == beacon_processor::WorkType::ChainSegment).then_some(()) @@ -235,7 +235,7 @@ impl TestRig { panic!("Should have a BlocksByRange request, filter {request_filter:?}: {e:?}") }); - let by_range_data_requests = if self.after_fulu() { + let by_range_data_requests = if self.is_after_fulu() { let mut data_columns_requests = vec![]; while let Ok(data_columns_request) = self.pop_received_network_event(|ev| match ev { NetworkMessage::SendRequest { @@ -254,7 +254,7 @@ impl TestRig { panic!("Found zero DataColumnsByRange requests, filter {request_filter:?}"); } ByRangeDataRequestIds::PostPeerDAS(data_columns_requests) - } else if self.after_deneb() { + } else if self.is_after_deneb() { let (id, peer) = self .pop_received_network_event(|ev| match ev { NetworkMessage::SendRequest { @@ -489,7 +489,7 @@ fn build_rpc_block( fn head_chain_removed_while_finalized_syncing() { // NOTE: this is a regression test. // Added in PR https://github.com/sigp/lighthouse/pull/2821 - let mut rig = TestRig::test_setup(); + let mut rig = TestRig::default(); // Get a peer with an advanced head let head_peer = rig.add_head_peer(); @@ -514,11 +514,11 @@ fn head_chain_removed_while_finalized_syncing() { async fn state_update_while_purging() { // NOTE: this is a regression test. // Added in PR https://github.com/sigp/lighthouse/pull/2827 - let mut rig = TestRig::test_setup_with_custody_type(NodeCustodyType::SemiSupernode); + let mut rig = TestRig::with_custody_type(NodeCustodyType::SemiSupernode); // Create blocks on a separate harness // SemiSupernode ensures enough columns are stored for sampling + custody RPC block validation - let mut rig_2 = TestRig::test_setup_with_custody_type(NodeCustodyType::SemiSupernode); + let mut rig_2 = TestRig::with_custody_type(NodeCustodyType::SemiSupernode); // Need to create blocks that can be inserted into the fork-choice and fit the "known // conditions" below. let head_peer_block = rig_2.create_canonical_block().await; @@ -550,7 +550,7 @@ async fn state_update_while_purging() { #[test] fn pause_and_resume_on_ee_offline() { - let mut rig = TestRig::test_setup(); + let mut rig = TestRig::default(); // add some peers let peer1 = rig.add_head_peer(); @@ -559,7 +559,7 @@ fn pause_and_resume_on_ee_offline() { // send the response to the request rig.find_and_complete_blocks_by_range_request(filter().peer(peer1).epoch(0)); // the beacon processor shouldn't have received any work - rig.expect_empty_processor(); + rig.assert_empty_processor(); // while the ee is offline, more peers might arrive. Add a new finalized peer. let _peer2 = rig.add_finalized_peer(); @@ -570,14 +570,14 @@ fn pause_and_resume_on_ee_offline() { // epoch for the other batch. So we can either filter by epoch of by sync type. rig.find_and_complete_blocks_by_range_request(filter().epoch(0)); // the beacon processor shouldn't have received any work - rig.expect_empty_processor(); + rig.assert_empty_processor(); // make the beacon processor available again. // update_execution_engine_state implicitly calls resume // now resume range, we should have two processing requests in the beacon processor. rig.update_execution_engine_state(EngineState::Online); // The head chain and finalized chain (2) should be in the processing queue - rig.expect_chain_segments(2); + rig.assert_chain_segments(2); } /// To attempt to finalize the peer's status finalized checkpoint we synced to its finalized epoch + @@ -587,7 +587,7 @@ const EXTRA_SYNCED_EPOCHS: u64 = 2 + 1; #[test] fn finalized_sync_enough_global_custody_peers_few_chain_peers() { // Run for all forks - let mut r = TestRig::test_setup(); + let mut r = TestRig::default(); let advanced_epochs: u64 = 2; let remote_info = r.finalized_remote_info_advanced_by(advanced_epochs.into()); @@ -604,7 +604,7 @@ fn finalized_sync_enough_global_custody_peers_few_chain_peers() { #[test] fn finalized_sync_not_enough_custody_peers_on_start() { - let mut r = TestRig::test_setup(); + let mut r = TestRig::default(); // Only run post-PeerDAS if !r.fork_name.fulu_enabled() { return; @@ -621,7 +621,7 @@ fn finalized_sync_not_enough_custody_peers_on_start() { // Because we don't have enough peers on all columns we haven't sent any request. // NOTE: There's a small chance that this single peer happens to custody exactly the set we // expect, in that case the test will fail. Find a way to make the test deterministic. - r.expect_empty_network(); + r.assert_empty_network(); // Generate enough peers and supernodes to cover all custody columns let peer_count = 100; diff --git a/crypto/bls/src/impls/fake_crypto.rs b/crypto/bls/src/impls/fake_crypto.rs index e7eee05077..5fe0c3baab 100644 --- a/crypto/bls/src/impls/fake_crypto.rs +++ b/crypto/bls/src/impls/fake_crypto.rs @@ -49,7 +49,9 @@ impl TPublicKey for PublicKey { } fn serialize_uncompressed(&self) -> [u8; PUBLIC_KEY_UNCOMPRESSED_BYTES_LEN] { - panic!("fake_crypto does not support uncompressed keys") + let mut bytes = [0; PUBLIC_KEY_UNCOMPRESSED_BYTES_LEN]; + bytes[0..PUBLIC_KEY_BYTES_LEN].copy_from_slice(&self.0); + bytes } fn deserialize(bytes: &[u8]) -> Result { @@ -58,8 +60,17 @@ impl TPublicKey for PublicKey { Ok(pubkey) } - fn deserialize_uncompressed(_: &[u8]) -> Result { - panic!("fake_crypto does not support uncompressed keys") + fn deserialize_uncompressed(bytes: &[u8]) -> Result { + if bytes.len() == PUBLIC_KEY_UNCOMPRESSED_BYTES_LEN { + let mut pubkey = Self([0; PUBLIC_KEY_BYTES_LEN]); + pubkey.0.copy_from_slice(&bytes[0..PUBLIC_KEY_BYTES_LEN]); + Ok(pubkey) + } else { + Err(Error::InvalidByteLength { + got: bytes.len(), + expected: PUBLIC_KEY_UNCOMPRESSED_BYTES_LEN, + }) + } } } @@ -97,7 +108,7 @@ pub struct Signature([u8; SIGNATURE_BYTES_LEN]); impl Signature { fn infinity() -> Self { - Self([0; SIGNATURE_BYTES_LEN]) + Self(INFINITY_SIGNATURE) } } @@ -213,7 +224,11 @@ impl TSecretKey for SecretKey { } fn public_key(&self) -> PublicKey { - PublicKey::infinity() + let mut bytes = [0; PUBLIC_KEY_BYTES_LEN]; + bytes[0] = 0x01; + let to_copy = std::cmp::min(self.0.len(), bytes.len() - 1); + bytes[1..1 + to_copy].copy_from_slice(&self.0[..to_copy]); + PublicKey(bytes) } fn sign(&self, _msg: Hash256) -> Signature { diff --git a/crypto/kzg/Cargo.toml b/crypto/kzg/Cargo.toml index 5a36eb74f7..d2558663d5 100644 --- a/crypto/kzg/Cargo.toml +++ b/crypto/kzg/Cargo.toml @@ -5,6 +5,10 @@ authors = ["Pawan Dhananjay "] edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +default = [] +fake_crypto = [] + [dependencies] arbitrary = { workspace = true } c-kzg = { workspace = true } diff --git a/crypto/kzg/src/lib.rs b/crypto/kzg/src/lib.rs index 0fe95b7723..66499dad8e 100644 --- a/crypto/kzg/src/lib.rs +++ b/crypto/kzg/src/lib.rs @@ -134,6 +134,9 @@ impl Kzg { kzg_commitment: KzgCommitment, kzg_proof: KzgProof, ) -> Result<(), Error> { + if cfg!(feature = "fake_crypto") { + return Ok(()); + } if !self.trusted_setup.verify_blob_kzg_proof( blob, &kzg_commitment.into(), @@ -155,6 +158,9 @@ impl Kzg { kzg_commitments: &[KzgCommitment], kzg_proofs: &[KzgProof], ) -> Result<(), Error> { + if cfg!(feature = "fake_crypto") { + return Ok(()); + } let commitments_bytes = kzg_commitments .iter() .map(|comm| Bytes48::from(*comm)) @@ -204,6 +210,9 @@ impl Kzg { y: &Bytes32, kzg_proof: KzgProof, ) -> Result { + if cfg!(feature = "fake_crypto") { + return Ok(true); + } self.trusted_setup .verify_kzg_proof(&kzg_commitment.into(), z, y, &kzg_proof.into()) .map_err(Into::into) @@ -240,6 +249,9 @@ impl Kzg { indices: Vec, kzg_commitments: &[Bytes48], ) -> Result<(), (Option, Error)> { + if cfg!(feature = "fake_crypto") { + return Ok(()); + } let mut column_groups: HashMap> = HashMap::new(); let expected_len = cells.len(); From 26db016425aa1495d5ca0a33eeef98f430a3b35c Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Fri, 13 Feb 2026 16:24:26 +1100 Subject: [PATCH 005/189] Gloas consensus: epoch processing, block signature verification, more tests (#8808) - [x] Implement `process_builder_pending_payments` in epoch processing for Gloas. Enable the new EF tests for this sub-component as well. - [x] Update `include_all_signatures_except_proposal` for Gloas to safely include the execution payload bid signature (this was an omission in the previous bid PR). - [x] Enable Gloas for _all_ remaining EF tests by default. They all pass with the exception of the finality tests (see below). Co-Authored-By: Michael Sproul Co-Authored-By: Eitan Seri- Levi --- .../src/per_block_processing.rs | 2 +- .../block_signature_verifier.rs | 22 ++++++ .../src/per_epoch_processing/single_pass.rs | 67 +++++++++++++++- .../src/per_slot_processing.rs | 19 +++++ testing/ef_tests/check_all_files_accessed.py | 8 +- .../ef_tests/src/cases/epoch_processing.rs | 21 +++++ testing/ef_tests/src/handler.rs | 77 ++++++++++++++----- testing/ef_tests/src/lib.rs | 4 +- testing/ef_tests/tests/tests.rs | 6 ++ 9 files changed, 192 insertions(+), 34 deletions(-) diff --git a/consensus/state_processing/src/per_block_processing.rs b/consensus/state_processing/src/per_block_processing.rs index 37639047fb..d9a41418cf 100644 --- a/consensus/state_processing/src/per_block_processing.rs +++ b/consensus/state_processing/src/per_block_processing.rs @@ -181,7 +181,7 @@ pub fn per_block_processing>( let body = block.body(); if state.fork_name_unchecked().gloas_enabled() { withdrawals::gloas::process_withdrawals::(state, spec)?; - // TODO(EIP-7732): process execution payload bid + process_execution_payload_bid(state, block, verify_signatures, spec)?; } else { if state.fork_name_unchecked().capella_enabled() { withdrawals::capella_electra::process_withdrawals::( diff --git a/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs b/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs index 9aa44137d8..e82ce537fd 100644 --- a/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs +++ b/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs @@ -170,6 +170,7 @@ where self.include_exits(block)?; self.include_sync_aggregate(block)?; self.include_bls_to_execution_changes(block)?; + self.include_execution_payload_bid(block)?; Ok(()) } @@ -357,6 +358,27 @@ where Ok(()) } + /// Include the signature of the block's execution payload bid. + pub fn include_execution_payload_bid>( + &mut self, + block: &'a SignedBeaconBlock, + ) -> Result<()> { + if let Ok(signed_execution_payload_bid) = + block.message().body().signed_execution_payload_bid() + { + // TODO(gloas): if we implement a global builder pubkey cache we need to inject it here + if let Some(signature_set) = execution_payload_bid_signature_set( + self.state, + |builder_index| get_builder_pubkey_from_state(self.state, builder_index), + signed_execution_payload_bid, + self.spec, + )? { + self.sets.push(signature_set); + } + } + Ok(()) + } + /// Verify all the signatures that have been included in `self`, returning `true` if and only if /// all the signatures are valid. /// diff --git a/consensus/state_processing/src/per_epoch_processing/single_pass.rs b/consensus/state_processing/src/per_epoch_processing/single_pass.rs index 3e07803aa6..4eb1e36628 100644 --- a/consensus/state_processing/src/per_epoch_processing/single_pass.rs +++ b/consensus/state_processing/src/per_epoch_processing/single_pass.rs @@ -15,9 +15,9 @@ use std::collections::{BTreeSet, HashMap}; use tracing::instrument; use typenum::Unsigned; use types::{ - ActivationQueue, BeaconState, BeaconStateError, ChainSpec, Checkpoint, DepositData, Epoch, - EthSpec, ExitCache, ForkName, ParticipationFlags, PendingDeposit, ProgressiveBalancesCache, - RelativeEpoch, Validator, + ActivationQueue, BeaconState, BeaconStateError, BuilderPendingPayment, ChainSpec, Checkpoint, + DepositData, Epoch, EthSpec, ExitCache, ForkName, ParticipationFlags, PendingDeposit, + ProgressiveBalancesCache, RelativeEpoch, Validator, consts::altair::{ NUM_FLAG_INDICES, PARTICIPATION_FLAG_WEIGHTS, TIMELY_HEAD_FLAG_INDEX, TIMELY_TARGET_FLAG_INDEX, WEIGHT_DENOMINATOR, @@ -33,6 +33,7 @@ pub struct SinglePassConfig { pub pending_consolidations: bool, pub effective_balance_updates: bool, pub proposer_lookahead: bool, + pub builder_pending_payments: bool, } impl Default for SinglePassConfig { @@ -52,6 +53,7 @@ impl SinglePassConfig { pending_consolidations: true, effective_balance_updates: true, proposer_lookahead: true, + builder_pending_payments: true, } } @@ -65,6 +67,7 @@ impl SinglePassConfig { pending_consolidations: false, effective_balance_updates: false, proposer_lookahead: false, + builder_pending_payments: false, } } } @@ -455,6 +458,12 @@ pub fn process_epoch_single_pass( )?; } + // Process builder pending payments outside the single-pass loop, as they depend on balances for + // multiple validators and cannot be computed accurately inside the loop. + if fork_name.gloas_enabled() && conf.builder_pending_payments { + process_builder_pending_payments(state, state_ctxt, spec)?; + } + // Finally, finish updating effective balance caches. We need this to happen *after* processing // of pending consolidations, which recomputes some effective balances. if conf.effective_balance_updates { @@ -503,6 +512,58 @@ pub fn process_proposer_lookahead( Ok(()) } +/// Calculate the quorum threshold for builder payments based on total active balance. +fn get_builder_payment_quorum_threshold( + state_ctxt: &StateContext, + spec: &ChainSpec, +) -> Result { + let per_slot_balance = state_ctxt + .total_active_balance + .safe_div(E::slots_per_epoch())?; + let quorum = per_slot_balance.safe_mul(spec.builder_payment_threshold_numerator)?; + quorum + .safe_div(spec.builder_payment_threshold_denominator) + .map_err(Error::from) +} + +/// Processes the builder pending payments from the previous epoch. +fn process_builder_pending_payments( + state: &mut BeaconState, + state_ctxt: &StateContext, + spec: &ChainSpec, +) -> Result<(), Error> { + let quorum = get_builder_payment_quorum_threshold::(state_ctxt, spec)?; + + // Collect qualifying payments and append to `builder_pending_withdrawals`. + // We use this pattern rather than a loop to avoid multiple borrows of the state's fields. + let new_pending_builder_withdrawals = state + .builder_pending_payments()? + .iter() + .take(E::SlotsPerEpoch::to_usize()) + .filter(|payment| payment.weight >= quorum) + .map(|payment| payment.withdrawal.clone()) + .collect::>(); + for payment_withdrawal in new_pending_builder_withdrawals { + state + .builder_pending_withdrawals_mut()? + .push(payment_withdrawal)?; + } + + // NOTE: this could be a little more memory-efficient with some juggling to reuse parts + // of the persistent tree (could convert to list, use pop_front, convert back). + let updated_payments = state + .builder_pending_payments()? + .iter() + .skip(E::SlotsPerEpoch::to_usize()) + .cloned() + .chain((0..E::SlotsPerEpoch::to_usize()).map(|_| BuilderPendingPayment::default())) + .collect::>(); + + *state.builder_pending_payments_mut()? = Vector::new(updated_payments)?; + + Ok(()) +} + fn process_single_inactivity_update( inactivity_score: &mut Cow, validator_info: &ValidatorInfo, diff --git a/consensus/state_processing/src/per_slot_processing.rs b/consensus/state_processing/src/per_slot_processing.rs index 0f8e5dc52d..f26ea567a2 100644 --- a/consensus/state_processing/src/per_slot_processing.rs +++ b/consensus/state_processing/src/per_slot_processing.rs @@ -14,6 +14,7 @@ pub enum Error { EpochProcessingError(EpochProcessingError), ArithError(ArithError), InconsistentStateFork(InconsistentFork), + BitfieldError(ssz::BitfieldError), } impl From for Error { @@ -22,6 +23,12 @@ impl From for Error { } } +impl From for Error { + fn from(e: ssz::BitfieldError) -> Self { + Self::BitfieldError(e) + } +} + /// Advances a state forward by one slot, performing per-epoch processing if required. /// /// If the root of the supplied `state` is known, then it can be passed as `state_root`. If @@ -48,6 +55,18 @@ pub fn per_slot_processing( None }; + // Unset the next payload availability + if state.fork_name_unchecked().gloas_enabled() { + let next_slot_index = state + .slot() + .as_usize() + .safe_add(1)? + .safe_rem(E::slots_per_historical_root())?; + state + .execution_payload_availability_mut()? + .set(next_slot_index, false)?; + } + state.slot_mut().safe_add_assign(1)?; // Process fork upgrades here. Note that multiple upgrades can potentially run diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index 628ee83936..00638f7b1e 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -49,15 +49,9 @@ excluded_paths = [ "tests/.*/eip7805", # TODO(gloas): remove these ignores as more Gloas operations are implemented "tests/.*/gloas/operations/payload_attestation/.*", - # TODO(EIP-7732): remove these ignores as Gloas consensus is implemented - "tests/.*/gloas/epoch_processing/.*", - "tests/.*/gloas/finality/.*", + # TODO(gloas): remove these ignores as Gloas consensus is implemented "tests/.*/gloas/fork/.*", "tests/.*/gloas/fork_choice/.*", - "tests/.*/gloas/networking/.*", - "tests/.*/gloas/rewards/.*", - "tests/.*/gloas/sanity/.*", - "tests/.*/gloas/transition/.*", # Ignore MatrixEntry SSZ tests for now. "tests/.*/.*/ssz_static/MatrixEntry/.*", # TODO(gloas): Ignore Gloas light client stuff for now diff --git a/testing/ef_tests/src/cases/epoch_processing.rs b/testing/ef_tests/src/cases/epoch_processing.rs index f143643ec3..7a90fc70d0 100644 --- a/testing/ef_tests/src/cases/epoch_processing.rs +++ b/testing/ef_tests/src/cases/epoch_processing.rs @@ -79,6 +79,8 @@ pub struct InactivityUpdates; pub struct ParticipationFlagUpdates; #[derive(Debug)] pub struct ProposerLookahead; +#[derive(Debug)] +pub struct BuilderPendingPayments; type_name!( JustificationAndFinalization, @@ -100,6 +102,7 @@ type_name!(SyncCommitteeUpdates, "sync_committee_updates"); type_name!(InactivityUpdates, "inactivity_updates"); type_name!(ParticipationFlagUpdates, "participation_flag_updates"); type_name!(ProposerLookahead, "proposer_lookahead"); +type_name!(BuilderPendingPayments, "builder_pending_payments"); impl EpochTransition for JustificationAndFinalization { fn run(state: &mut BeaconState, spec: &ChainSpec) -> Result<(), EpochProcessingError> { @@ -293,6 +296,20 @@ impl EpochTransition for ProposerLookahead { } } +impl EpochTransition for BuilderPendingPayments { + fn run(state: &mut BeaconState, spec: &ChainSpec) -> Result<(), EpochProcessingError> { + process_epoch_single_pass( + state, + spec, + SinglePassConfig { + builder_pending_payments: true, + ..SinglePassConfig::disable_all() + }, + ) + .map(|_| ()) + } +} + impl> LoadCase for EpochProcessing { fn load_from_dir(path: &Path, fork_name: ForkName) -> Result { let spec = &testing_spec::(fork_name); @@ -356,6 +373,10 @@ impl> Case for EpochProcessing { return false; } + if !fork_name.gloas_enabled() && T::name() == "builder_pending_payments" { + return false; + } + true } diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index 5a43642c88..45bca21c6f 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -22,7 +22,7 @@ pub trait Handler { // Add forks here to exclude them from EF spec testing. Helpful for adding future or // unspecified forks. fn disabled_forks(&self) -> Vec { - vec![ForkName::Gloas] + vec![] } fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { @@ -395,11 +395,6 @@ where T::name().into() } - fn disabled_forks(&self) -> Vec { - // TODO(gloas): Can be removed once we enable Gloas on all tests - vec![] - } - fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { self.supported_forks.contains(&fork_name) } @@ -422,11 +417,6 @@ where fn handler_name(&self) -> String { BeaconState::::name().into() } - - fn disabled_forks(&self) -> Vec { - // TODO(gloas): Can be removed once we enable Gloas on all tests - vec![] - } } impl Handler for SszStaticWithSpecHandler @@ -449,11 +439,6 @@ where T::name().into() } - fn disabled_forks(&self) -> Vec { - // TODO(gloas): Can be removed once we enable Gloas on all tests - vec![] - } - fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { self.supported_forks.contains(&fork_name) } @@ -552,6 +537,11 @@ impl Handler for RandomHandler { fn handler_name(&self) -> String { "random".into() } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once we have Gloas random tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -622,6 +612,11 @@ impl Handler for ForkHandler { fn handler_name(&self) -> String { "fork".into() } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once onboard_builders_from_pending_deposits is implemented + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -726,6 +721,11 @@ impl Handler for ForkChoiceHandler { // run them with fake crypto. cfg!(not(feature = "fake_crypto")) } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once we have Gloas fork choice tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -755,6 +755,11 @@ impl Handler for OptimisticSyncHandler { fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { fork_name.bellatrix_enabled() && cfg!(not(feature = "fake_crypto")) } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once we have Gloas optimistic sync tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -975,6 +980,11 @@ impl Handler for KZGComputeCellsHandler { fn handler_name(&self) -> String { "compute_cells".into() } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once we have Gloas KZG tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -995,6 +1005,11 @@ impl Handler for KZGComputeCellsAndKZGProofHandler { fn handler_name(&self) -> String { "compute_cells_and_kzg_proofs".into() } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once we have Gloas KZG tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -1015,6 +1030,11 @@ impl Handler for KZGVerifyCellKZGProofBatchHandler { fn handler_name(&self) -> String { "verify_cell_kzg_proof_batch".into() } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once we have Gloas KZG tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -1035,6 +1055,11 @@ impl Handler for KZGRecoverCellsAndKZGProofHandler { fn handler_name(&self) -> String { "recover_cells_and_kzg_proofs".into() } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once we have Gloas KZG tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -1059,6 +1084,11 @@ impl Handler for KzgInclusionMerkleProofValidityHandler bool { fork_name.deneb_enabled() } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once we have Gloas KZG merkle proof tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -1083,6 +1113,11 @@ impl Handler for MerkleProofValidityHandler { fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { fork_name.altair_enabled() } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once we have Gloas light client tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -1108,6 +1143,11 @@ impl Handler for LightClientUpdateHandler { // Enabled in Altair fork_name.altair_enabled() } + + fn disabled_forks(&self) -> Vec { + // TODO(gloas): remove once we have Gloas light client tests + vec![ForkName::Gloas] + } } #[derive(Educe)] @@ -1129,11 +1169,6 @@ impl> Handler for OperationsHandler O::handler_name() } - fn disabled_forks(&self) -> Vec { - // TODO(gloas): Can be removed once we enable Gloas on all tests - vec![] - } - fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { Self::Case::is_enabled_for_fork(fork_name) && (!fork_name.gloas_enabled() diff --git a/testing/ef_tests/src/lib.rs b/testing/ef_tests/src/lib.rs index 49bea7d85f..94b19b6644 100644 --- a/testing/ef_tests/src/lib.rs +++ b/testing/ef_tests/src/lib.rs @@ -1,7 +1,7 @@ pub use case_result::CaseResult; pub use cases::{ - Case, EffectiveBalanceUpdates, Eth1DataReset, ExecutionPayloadBidBlock, FeatureName, - HistoricalRootsUpdate, HistoricalSummariesUpdate, InactivityUpdates, + BuilderPendingPayments, Case, EffectiveBalanceUpdates, Eth1DataReset, ExecutionPayloadBidBlock, + FeatureName, HistoricalRootsUpdate, HistoricalSummariesUpdate, InactivityUpdates, JustificationAndFinalization, ParticipationFlagUpdates, ParticipationRecordUpdates, PendingBalanceDeposits, PendingConsolidations, ProposerLookahead, RandaoMixesReset, RegistryUpdates, RewardsAndPenalties, Slashings, SlashingsReset, SyncCommitteeUpdates, diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 332f077984..c3481a2405 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -954,6 +954,12 @@ fn epoch_processing_proposer_lookahead() { EpochProcessingHandler::::default().run(); } +#[test] +fn epoch_processing_builder_pending_payments() { + EpochProcessingHandler::::default().run(); + EpochProcessingHandler::::default().run(); +} + #[test] fn fork_upgrade() { ForkHandler::::default().run(); From 68ad9758a3e596ff99825bdf2fe4afcbe1894c3a Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Fri, 13 Feb 2026 13:39:56 -0800 Subject: [PATCH 006/189] Gloas attestation verification (#8705) https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/p2p-interface.md#attestation-subnets Implements attestation verification logic for Gloas and adds a few gloas related tests. Note that a few of these tests rely on gloas test harness block production which hasn't been built out yet. So for now those tests are ignored. Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi --- .../src/attestation_verification.rs | 68 ++++- .../tests/attestation_verification.rs | 259 ++++++++++++++++++ .../gossip_methods.rs | 19 ++ 3 files changed, 336 insertions(+), 10 deletions(-) diff --git a/beacon_node/beacon_chain/src/attestation_verification.rs b/beacon_node/beacon_chain/src/attestation_verification.rs index faa396966f..667bafe445 100644 --- a/beacon_node/beacon_chain/src/attestation_verification.rs +++ b/beacon_node/beacon_chain/src/attestation_verification.rs @@ -61,8 +61,9 @@ use tracing::{debug, error}; use tree_hash::TreeHash; use types::{ Attestation, AttestationData, AttestationRef, BeaconCommittee, - BeaconStateError::NoCommitteeFound, ChainSpec, CommitteeIndex, Epoch, EthSpec, Hash256, - IndexedAttestation, SelectionProof, SignedAggregateAndProof, SingleAttestation, Slot, SubnetId, + BeaconStateError::NoCommitteeFound, ChainSpec, CommitteeIndex, Epoch, EthSpec, ForkName, + Hash256, IndexedAttestation, SelectionProof, SignedAggregateAndProof, SingleAttestation, Slot, + SubnetId, }; pub use batch::{batch_verify_aggregated_attestations, batch_verify_unaggregated_attestations}; @@ -160,6 +161,12 @@ pub enum Error { /// /// The peer has sent an invalid message. CommitteeIndexNonZero(usize), + /// The validator index is set to an invalid value after Gloas. + /// + /// ## Peer scoring + /// + /// The peer has sent an invalid message. + CommitteeIndexInvalid, /// The `attestation.data.beacon_block_root` block is unknown. /// /// ## Peer scoring @@ -550,8 +557,12 @@ impl<'a, T: BeaconChainTypes> IndexedAggregatedAttestation<'a, T> { } .tree_hash_root(); + let fork_name = chain + .spec + .fork_name_at_slot::(attestation.data().slot); + // [New in Electra:EIP7549] - verify_committee_index(attestation)?; + verify_committee_index(attestation, fork_name)?; if chain .observed_attestations @@ -595,6 +606,17 @@ impl<'a, T: BeaconChainTypes> IndexedAggregatedAttestation<'a, T> { // attestation and do not delay consideration for later. let head_block = verify_head_block_is_known(chain, attestation.data(), None)?; + // [New in Gloas]: If the attested block is from the same slot as the attestation, + // index must be 0. + if fork_name.gloas_enabled() + && head_block.slot == attestation.data().slot + && attestation.data().index != 0 + { + return Err(Error::CommitteeIndexNonZero( + attestation.data().index as usize, + )); + } + // Check the attestation target root is consistent with the head root. // // This check is not in the specification, however we guard against it since it opens us up @@ -871,7 +893,12 @@ impl<'a, T: BeaconChainTypes> IndexedUnaggregatedAttestation<'a, T> { let fork_name = chain .spec .fork_name_at_slot::(attestation.data.slot); - if fork_name.electra_enabled() { + if fork_name.gloas_enabled() { + // [New in Gloas] + if attestation.data.index >= 2 { + return Err(Error::CommitteeIndexInvalid); + } + } else if fork_name.electra_enabled() { // [New in Electra:EIP7549] if attestation.data.index != 0 { return Err(Error::CommitteeIndexNonZero( @@ -890,6 +917,17 @@ impl<'a, T: BeaconChainTypes> IndexedUnaggregatedAttestation<'a, T> { chain.config.import_max_skip_slots, )?; + // [New in Gloas]: If the attested block is from the same slot as the attestation, + // index must be 0. + if fork_name.gloas_enabled() + && head_block.slot == attestation.data.slot + && attestation.data.index != 0 + { + return Err(Error::CommitteeIndexNonZero( + attestation.data.index as usize, + )); + } + // Check the attestation target root is consistent with the head root. verify_attestation_target_root::(&head_block, &attestation.data)?; @@ -1404,7 +1442,10 @@ pub fn verify_signed_aggregate_signatures( /// Verify that the `attestation` committee index is properly set for the attestation's fork. /// This function will only apply verification post-Electra. -pub fn verify_committee_index(attestation: AttestationRef) -> Result<(), Error> { +pub fn verify_committee_index( + attestation: AttestationRef, + fork_name: ForkName, +) -> Result<(), Error> { if let Ok(committee_bits) = attestation.committee_bits() { // Check to ensure that the attestation is for a single committee. let num_committee_bits = get_committee_indices::(committee_bits); @@ -1414,11 +1455,18 @@ pub fn verify_committee_index(attestation: AttestationRef) -> Res )); } - // Ensure the attestation index is set to zero post Electra. - if attestation.data().index != 0 { - return Err(Error::CommitteeIndexNonZero( - attestation.data().index as usize, - )); + // Ensure the attestation index is valid for the fork. + let index = attestation.data().index; + if fork_name.gloas_enabled() { + // [New in Gloas]: index must be < 2. + if index >= 2 { + return Err(Error::CommitteeIndexInvalid); + } + } else { + // [New in Electra:EIP7549]: index must be 0. + if index != 0 { + return Err(Error::CommitteeIndexNonZero(index as usize)); + } } } Ok(()) diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index 8aeb881aa4..96071be89f 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -368,6 +368,13 @@ impl GossipTester { self.harness.chain.epoch().unwrap() } + pub fn is_gloas(&self) -> bool { + self.harness + .spec + .fork_name_at_slot::(self.valid_attestation.data.slot) + .gloas_enabled() + } + pub fn earliest_valid_attestation_slot(&self) -> Slot { let offset = if self .harness @@ -522,6 +529,44 @@ impl GossipTester { self } + + /// Like `inspect_aggregate_err`, but only runs the check if gloas is enabled. + /// If gloas is not enabled, this is a no-op that returns self. + pub fn inspect_aggregate_err_if_gloas( + self, + desc: &str, + get_attn: G, + inspect_err: I, + ) -> Self + where + G: Fn(&Self, &mut SignedAggregateAndProof), + I: Fn(&Self, AttnError), + { + if self.is_gloas() { + self.inspect_aggregate_err(desc, get_attn, inspect_err) + } else { + self + } + } + + /// Like `inspect_unaggregate_err`, but only runs the check if gloas is enabled. + /// If gloas is not enabled, this is a no-op that returns self. + pub fn inspect_unaggregate_err_if_gloas( + self, + desc: &str, + get_attn: G, + inspect_err: I, + ) -> Self + where + G: Fn(&Self, &mut SingleAttestation, &mut SubnetId, &ChainSpec), + I: Fn(&Self, AttnError), + { + if self.is_gloas() { + self.inspect_unaggregate_err(desc, get_attn, inspect_err) + } else { + self + } + } } /// Tests verification of `SignedAggregateAndProof` from the gossip network. #[tokio::test] @@ -854,6 +899,27 @@ async fn aggregated_gossip_verification() { )) }, ) + /* + * [New in Gloas]: attestation.data.index must be < 2 + */ + .inspect_aggregate_err_if_gloas( + "gloas: aggregate with index >= 2", + |_, a| match a.to_mut() { + SignedAggregateAndProofRefMut::Base(_) => { + panic!("Expected Electra attestation variant"); + } + SignedAggregateAndProofRefMut::Electra(att) => { + att.message.aggregate.data.index = 2; + } + }, + |_, err| { + assert!( + matches!(err, AttnError::CommitteeIndexInvalid), + "expected CommitteeIndexInvalid, got {:?}", + err + ) + }, + ) // NOTE: from here on, the tests are stateful, and rely on the valid attestation having // been seen. .import_valid_aggregate() @@ -1071,6 +1137,22 @@ async fn unaggregated_gossip_verification() { )) }, ) + /* + * [New in Gloas]: attestation.data.index must be < 2 + */ + .inspect_unaggregate_err_if_gloas( + "gloas: attestation with index >= 2", + |_, a, _, _| { + a.data.index = 2; + }, + |_, err| { + assert!( + matches!(err, AttnError::CommitteeIndexInvalid), + "expected CommitteeIndexInvalid, got {:?}", + err + ) + }, + ) // NOTE: from here on, the tests are stateful, and rely on the valid attestation having // been seen. .import_valid_unaggregate() @@ -1700,3 +1782,180 @@ async fn aggregated_attestation_verification_use_head_state_fork() { ); } } + +/// [New in Gloas]: Tests that unaggregated attestations with `data.index == 1` are rejected +/// when `head_block.slot == attestation.data.slot`. +/// +/// This test only runs when `FORK_NAME=gloas` is set with `fork_from_env` feature. +// TODO(EIP-7732): Enable this test once gloas block production works in test harness. +// `state.latest_execution_payload_header()` not available in Gloas. +#[ignore] +#[tokio::test] +async fn gloas_unaggregated_attestation_same_slot_index_must_be_zero() { + let harness = get_harness(VALIDATOR_COUNT); + + // Skip this test if not running with gloas fork + if !harness + .spec + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } + + // Extend the chain out a few epochs so we have some chain depth to play with. + harness + .extend_chain( + MainnetEthSpec::slots_per_epoch() as usize * 3 - 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce a block in the current slot (this creates the same-slot scenario) + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::SomeValidators(vec![]), + ) + .await; + + let current_slot = harness.chain.slot().expect("should get slot"); + let head = harness.chain.head_snapshot(); + + // Verify head block is in the current slot + assert_eq!( + head.beacon_block.slot(), + current_slot, + "head block should be in current slot for same-slot test" + ); + + // Produce an attestation for the current slot + let (mut attestation, _attester_sk, subnet_id) = + get_valid_unaggregated_attestation(&harness.chain); + + // Verify we have a same-slot scenario + let attested_block_slot = harness + .chain + .canonical_head + .fork_choice_read_lock() + .get_block(&attestation.data.beacon_block_root) + .expect("block should exist") + .slot; + assert_eq!( + attested_block_slot, attestation.data.slot, + "attested block slot should equal attestation slot for same-slot test" + ); + + // index == 1 should be rejected when head_block.slot == attestation.data.slot + attestation.data.index = 1; + let result = harness + .chain + .verify_unaggregated_attestation_for_gossip(&attestation, Some(subnet_id)); + assert!( + matches!(result, Err(AttnError::CommitteeIndexNonZero(_))), + "gloas: attestation with index == 1 when head_block.slot == attestation.data.slot should be rejected, got {:?}", + result.err() + ); +} + +/// [New in Gloas]: Tests that aggregated attestations with `data.index == 1` are rejected +/// when `head_block.slot == attestation.data.slot`. +/// +/// This test only runs when `FORK_NAME=gloas` is set with `fork_from_env` feature. +// TODO(EIP-7732): Enable this test once gloas block production works in test harness. +// `state.latest_execution_payload_header()` not available in Gloas. +#[ignore] +#[tokio::test] +async fn gloas_aggregated_attestation_same_slot_index_must_be_zero() { + let harness = get_harness(VALIDATOR_COUNT); + + // Skip this test if not running with gloas fork + if !harness + .spec + .fork_name_at_epoch(Epoch::new(0)) + .gloas_enabled() + { + return; + } + + // Extend the chain out a few epochs so we have some chain depth to play with. + harness + .extend_chain( + MainnetEthSpec::slots_per_epoch() as usize * 3 - 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Produce a block in the current slot (this creates the same-slot scenario) + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::SomeValidators(vec![]), + ) + .await; + + let current_slot = harness.chain.slot().expect("should get slot"); + let head = harness.chain.head_snapshot(); + + // Verify head block is in the current slot + assert_eq!( + head.beacon_block.slot(), + current_slot, + "head block should be in current slot for same-slot test" + ); + + // Produce an attestation for the current slot + let (valid_attestation, _attester_sk, _subnet_id) = + get_valid_unaggregated_attestation(&harness.chain); + + // Verify we have a same-slot scenario + let attested_block_slot = harness + .chain + .canonical_head + .fork_choice_read_lock() + .get_block(&valid_attestation.data.beacon_block_root) + .expect("block should exist") + .slot; + assert_eq!( + attested_block_slot, valid_attestation.data.slot, + "attested block slot should equal attestation slot for same-slot test" + ); + + // Convert to aggregate + let committee = head + .beacon_state + .get_beacon_committee(current_slot, valid_attestation.committee_index) + .expect("should get committee"); + let fork_name = harness + .spec + .fork_name_at_slot::(valid_attestation.data.slot); + let aggregate_attestation = + single_attestation_to_attestation(&valid_attestation, committee.committee, fork_name) + .unwrap(); + + let (mut valid_aggregate, _, _) = + get_valid_aggregated_attestation(&harness.chain, aggregate_attestation); + + // index == 1 should be rejected when head_block.slot == attestation.data.slot + match valid_aggregate.to_mut() { + SignedAggregateAndProofRefMut::Base(att) => { + att.message.aggregate.data.index = 1; + } + SignedAggregateAndProofRefMut::Electra(att) => { + att.message.aggregate.data.index = 1; + } + } + + let result = harness + .chain + .verify_aggregated_attestation_for_gossip(&valid_aggregate); + assert!( + matches!(result, Err(AttnError::CommitteeIndexNonZero(_))), + "gloas: aggregate with index == 1 when head_block.slot == attestation.data.slot should be rejected, got {:?}", + result.err() + ); +} diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index a4125f3df0..a9198f1943 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -2415,6 +2415,25 @@ impl NetworkBeaconProcessor { "attn_comm_index_non_zero", ); } + AttnError::CommitteeIndexInvalid => { + /* + * The committee index is invalid after Gloas. + * + * The peer has published an invalid consensus message. + */ + debug!( + %peer_id, + block = ?beacon_block_root, + ?attestation_type, + "Committee index invalid" + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "attn_comm_index_invalid", + ); + } AttnError::UnknownHeadBlock { beacon_block_root } => { trace!( %peer_id, From a3a74d89881265ebe44299dbe7df7331561e012d Mon Sep 17 00:00:00 2001 From: Mac L Date: Sat, 14 Feb 2026 12:26:25 +0400 Subject: [PATCH 007/189] Correctly compute process times during `ProcessHealth::observe` (#8793) I believe I found a bug where during computation of `pid_process_seconds_total` we add `children_system` twice. I assume that it was originally intended to add `children_system` and `children_user` once each Co-Authored-By: Mac L --- common/health_metrics/src/observe.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/health_metrics/src/observe.rs b/common/health_metrics/src/observe.rs index 81bb8e6f7e..5bc3770301 100644 --- a/common/health_metrics/src/observe.rs +++ b/common/health_metrics/src/observe.rs @@ -121,7 +121,7 @@ impl Observe for ProcessHealth { pid_mem_shared_memory_size: process_mem.shared(), pid_process_seconds_total: process_times.busy().as_secs() + process_times.children_system().as_secs() - + process_times.children_system().as_secs(), + + process_times.children_user().as_secs(), }) } } From 1fe7a8ce77ada05de097352c5acb7646cd107852 Mon Sep 17 00:00:00 2001 From: Romeo Date: Mon, 16 Feb 2026 00:44:15 +0100 Subject: [PATCH 008/189] Implement inactivity scores ef tests unstable (#8807) fixes issue #8750 This PR enables the inactivity_scores reward EF tests from v1.7.0-alpha.2. - Enabled Tests: Added the inactivity_scores handler to the rewards test suite. - Fork Filtering: Updated the runner to execute these tests only on supported forks (Altair onwards), preventing directory-not-found errors on earlier forks. - CI Coverage: Removed exclusions in the file access check script to ensures all new test vectors are fully tracked. Co-Authored-By: romeoscript Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Michael Sproul --- testing/ef_tests/check_all_files_accessed.py | 2 -- testing/ef_tests/src/handler.rs | 9 +++++++++ testing/ef_tests/tests/tests.rs | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index 00638f7b1e..b465a47296 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -70,8 +70,6 @@ excluded_paths = [ # Ignore full epoch tests for now (just test the sub-transitions). "tests/.*/.*/epoch_processing/.*/pre_epoch.ssz_snappy", "tests/.*/.*/epoch_processing/.*/post_epoch.ssz_snappy", - # Ignore inactivity_scores tests for now (should implement soon). - "tests/.*/.*/rewards/inactivity_scores/.*", # Ignore KZG tests that target internal kzg library functions "tests/.*/compute_verify_cell_kzg_proof_batch_challenge/.*", "tests/.*/compute_challenge/.*", diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index 45bca21c6f..625778c2dd 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -592,6 +592,15 @@ impl Handler for RewardsHandler { fn handler_name(&self) -> String { self.handler_name.to_string() } + + fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { + if self.handler_name == "inactivity_scores" { + // These tests were added in v1.7.0-alpha.2 and are available for Altair and later. + fork_name.altair_enabled() + } else { + true + } + } } #[derive(Educe)] diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index c3481a2405..fcf7951c3e 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -1113,7 +1113,7 @@ fn kzg_inclusion_merkle_proof_validity() { #[test] fn rewards() { - for handler in &["basic", "leak", "random"] { + for handler in &["basic", "leak", "random", "inactivity_scores"] { RewardsHandler::::new(handler).run(); RewardsHandler::::new(handler).run(); } From 5563b7a1dd01fc27f80bc596fe7b8121d5057eee Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:58:30 -0700 Subject: [PATCH 009/189] fix second_payload head in execution engine test (#8789) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- testing/execution_engine_integration/src/test_rig.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/execution_engine_integration/src/test_rig.rs b/testing/execution_engine_integration/src/test_rig.rs index 24d75f5a11..5c3061166e 100644 --- a/testing/execution_engine_integration/src/test_rig.rs +++ b/testing/execution_engine_integration/src/test_rig.rs @@ -563,7 +563,7 @@ impl TestRig { * * Indicate that the payload is the head of the chain, providing payload attributes. */ - let head_block_hash = valid_payload.block_hash(); + let head_block_hash = second_payload.block_hash(); let finalized_block_hash = ExecutionBlockHash::zero(); // To save sending proposer preparation data, just set the fee recipient // to the fee recipient configured for EE A. From fcfd061fc2ddf43b74e9b78ac60d7c62694fb715 Mon Sep 17 00:00:00 2001 From: Mac L Date: Mon, 16 Feb 2026 05:45:29 +0400 Subject: [PATCH 010/189] Fix eth2 compilation by feature gating `SseEventSource` (#8819) `eth2` is currently unable to be built without the `events` feature. Feature gates the `SseEventSource` match arm in the `status` function. Co-Authored-By: Mac L --- common/eth2/src/error.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/common/eth2/src/error.rs b/common/eth2/src/error.rs index 671a617c9e..45f2599493 100644 --- a/common/eth2/src/error.rs +++ b/common/eth2/src/error.rs @@ -102,6 +102,7 @@ impl Error { None } } + #[cfg(feature = "events")] Error::SseEventSource(_) => None, Error::ServerMessage(msg) => StatusCode::try_from(msg.code).ok(), Error::ServerIndexedMessage(msg) => StatusCode::try_from(msg.code).ok(), From 48a2b2802da833c3cd626c0697835c076de1fae3 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 16 Feb 2026 13:49:46 +1100 Subject: [PATCH 011/189] Delete `OnDiskConsensusContext` (#8824) Remove the `OnDiskConsensusContext` type which is no longer used at all after the merge of: - https://github.com/sigp/lighthouse/pull/8724 This type was not necessary since the merge of Lion's change which removed the on-disk storage: - https://github.com/sigp/lighthouse/pull/5891 Co-Authored-By: Michael Sproul --- beacon_node/store/src/consensus_context.rs | 65 ---------------------- beacon_node/store/src/lib.rs | 2 - 2 files changed, 67 deletions(-) delete mode 100644 beacon_node/store/src/consensus_context.rs diff --git a/beacon_node/store/src/consensus_context.rs b/beacon_node/store/src/consensus_context.rs deleted file mode 100644 index 281106d9aa..0000000000 --- a/beacon_node/store/src/consensus_context.rs +++ /dev/null @@ -1,65 +0,0 @@ -use ssz_derive::{Decode, Encode}; -use state_processing::ConsensusContext; -use std::collections::HashMap; -use types::{EthSpec, Hash256, IndexedAttestation, Slot}; - -/// The consensus context is stored on disk as part of the data availability overflow cache. -/// -/// We use this separate struct to keep the on-disk format stable in the presence of changes to the -/// in-memory `ConsensusContext`. You MUST NOT change the fields of this struct without -/// superstructing it and implementing a schema migration. -#[derive(Debug, PartialEq, Clone, Encode, Decode)] -pub struct OnDiskConsensusContext { - /// Slot to act as an identifier/safeguard - slot: Slot, - /// Proposer index of the block at `slot`. - proposer_index: Option, - /// Block root of the block at `slot`. - current_block_root: Option, - /// We keep the indexed attestations in the *in-memory* version of this struct so that we don't - /// need to regenerate them if roundtripping via this type *without* going to disk. - /// - /// They are not part of the on-disk format. - #[ssz(skip_serializing, skip_deserializing)] - indexed_attestations: HashMap>, -} - -impl OnDiskConsensusContext { - pub fn from_consensus_context(ctxt: ConsensusContext) -> Self { - // Match exhaustively on fields here so we are forced to *consider* updating the on-disk - // format when the `ConsensusContext` fields change. - let ConsensusContext { - slot, - previous_epoch: _, - current_epoch: _, - proposer_index, - current_block_root, - indexed_attestations, - } = ctxt; - OnDiskConsensusContext { - slot, - proposer_index, - current_block_root, - indexed_attestations, - } - } - - pub fn into_consensus_context(self) -> ConsensusContext { - let OnDiskConsensusContext { - slot, - proposer_index, - current_block_root, - indexed_attestations, - } = self; - - let mut ctxt = ConsensusContext::new(slot); - - if let Some(proposer_index) = proposer_index { - ctxt = ctxt.set_proposer_index(proposer_index); - } - if let Some(block_root) = current_block_root { - ctxt = ctxt.set_current_block_root(block_root); - } - ctxt.set_indexed_attestations(indexed_attestations) - } -} diff --git a/beacon_node/store/src/lib.rs b/beacon_node/store/src/lib.rs index ee9cfce0ec..3363eb800c 100644 --- a/beacon_node/store/src/lib.rs +++ b/beacon_node/store/src/lib.rs @@ -9,7 +9,6 @@ //! tests for implementation examples. pub mod blob_sidecar_list_from_root; pub mod config; -pub mod consensus_context; pub mod errors; mod forwards_iter; pub mod hdiff; @@ -27,7 +26,6 @@ pub mod iter; pub use self::blob_sidecar_list_from_root::BlobSidecarListFromRoot; pub use self::config::StoreConfig; -pub use self::consensus_context::OnDiskConsensusContext; pub use self::hot_cold_store::{HotColdDB, HotStateSummary, Split}; pub use self::memory_store::MemoryStore; pub use crate::metadata::BlobInfo; From 945f6637c51b145558a68777c9b6feaf2c68caa2 Mon Sep 17 00:00:00 2001 From: Mac L Date: Mon, 16 Feb 2026 20:05:54 +0400 Subject: [PATCH 012/189] Remove `reqwest` re-exports from `eth2` (#8829) Remove `reqwest` from being re-exported within `eth2` Co-Authored-By: Mac L --- Cargo.lock | 5 +++++ beacon_node/builder_client/src/lib.rs | 4 ++-- beacon_node/http_api/Cargo.toml | 1 + beacon_node/http_api/src/lib.rs | 2 +- beacon_node/http_api/src/publish_blocks.rs | 10 ++++------ beacon_node/http_api/src/validator/mod.rs | 2 +- .../http_api/tests/broadcast_validation_tests.rs | 2 +- beacon_node/http_api/tests/status_tests.rs | 2 +- beacon_node/http_api/tests/tests.rs | 4 ++-- common/eth2/src/lib.rs | 4 +--- common/eth2/src/types.rs | 2 +- common/warp_utils/Cargo.toml | 1 + common/warp_utils/src/status_code.rs | 2 +- testing/node_test_rig/Cargo.toml | 1 + testing/node_test_rig/src/lib.rs | 3 ++- testing/validator_test_rig/Cargo.toml | 1 + testing/validator_test_rig/src/mock_beacon_node.rs | 3 ++- validator_client/src/lib.rs | 4 ++-- validator_client/validator_services/Cargo.toml | 1 + .../validator_services/src/block_service.rs | 3 ++- 20 files changed, 33 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a63ab1e72..5a8e76a8a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4217,6 +4217,7 @@ dependencies = [ "parking_lot", "proto_array", "rand 0.9.2", + "reqwest", "safe_arith", "sensitive_url", "serde", @@ -6159,6 +6160,7 @@ dependencies = [ "environment", "eth2", "execution_layer", + "reqwest", "sensitive_url", "tempfile", "tokio", @@ -9688,6 +9690,7 @@ dependencies = [ "graffiti_file", "logging", "parking_lot", + "reqwest", "safe_arith", "slot_clock", "task_executor", @@ -9716,6 +9719,7 @@ dependencies = [ "eth2", "mockito", "regex", + "reqwest", "sensitive_url", "serde_json", "tracing", @@ -9810,6 +9814,7 @@ dependencies = [ "bytes", "eth2", "headers", + "reqwest", "safe_arith", "serde", "serde_array_query", diff --git a/beacon_node/builder_client/src/lib.rs b/beacon_node/builder_client/src/lib.rs index b17a824fd7..7dc0cbfc6d 100644 --- a/beacon_node/builder_client/src/lib.rs +++ b/beacon_node/builder_client/src/lib.rs @@ -10,10 +10,10 @@ use eth2::types::{ use eth2::types::{FullPayloadContents, SignedBlindedBeaconBlock}; use eth2::{ CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE_HEADER, - SSZ_CONTENT_TYPE_HEADER, StatusCode, ok_or_error, success_or_error, + SSZ_CONTENT_TYPE_HEADER, ok_or_error, success_or_error, }; use reqwest::header::{ACCEPT, HeaderMap, HeaderValue}; -use reqwest::{IntoUrl, Response}; +use reqwest::{IntoUrl, Response, StatusCode}; use sensitive_url::SensitiveUrl; use serde::Serialize; use serde::de::DeserializeOwned; diff --git a/beacon_node/http_api/Cargo.toml b/beacon_node/http_api/Cargo.toml index 78e7af71f4..dd15a76c7a 100644 --- a/beacon_node/http_api/Cargo.toml +++ b/beacon_node/http_api/Cargo.toml @@ -33,6 +33,7 @@ operation_pool = { workspace = true } parking_lot = { workspace = true } proto_array = { workspace = true } rand = { workspace = true } +reqwest = { workspace = true } safe_arith = { workspace = true } sensitive_url = { workspace = true } serde = { workspace = true } diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index c4b2cded51..6824eab4fd 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -50,7 +50,6 @@ use builder_states::get_next_withdrawals; use bytes::Bytes; use context_deserialize::ContextDeserialize; use directory::DEFAULT_ROOT_DIR; -use eth2::StatusCode; use eth2::lighthouse::sync_state::SyncState; use eth2::types::{ self as api_types, BroadcastValidation, EndpointVersion, ForkChoice, ForkChoiceExtraData, @@ -69,6 +68,7 @@ use parking_lot::RwLock; pub use publish_blocks::{ ProvenancedBlock, publish_blinded_block, publish_block, reconstruct_block, }; +use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use slot_clock::SlotClock; use ssz::Encode; diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index 7826ec55e1..bbf92a4dda 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -9,18 +9,16 @@ use beacon_chain::{ AvailabilityProcessingStatus, BeaconChain, BeaconChainError, BeaconChainTypes, BlockError, IntoGossipVerifiedBlock, NotifyExecutionLayer, build_blob_data_column_sidecars, }; -use eth2::{ - StatusCode, - types::{ - BlobsBundle, BroadcastValidation, ErrorMessage, ExecutionPayloadAndBlobs, - FullPayloadContents, PublishBlockRequest, SignedBlockContents, - }, +use eth2::types::{ + BlobsBundle, BroadcastValidation, ErrorMessage, ExecutionPayloadAndBlobs, FullPayloadContents, + PublishBlockRequest, SignedBlockContents, }; use execution_layer::{ProvenancedPayload, SubmitBlindedBlockResponse}; use futures::TryFutureExt; use lighthouse_network::PubsubMessage; use network::NetworkMessage; use rand::prelude::SliceRandom; +use reqwest::StatusCode; use std::marker::PhantomData; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index b1ab4c648a..0704c52095 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -10,7 +10,6 @@ use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::validator_monitor::timestamp_now; use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; use bls::PublicKeyBytes; -use eth2::StatusCode; use eth2::types::{ Accept, BeaconCommitteeSubscription, EndpointVersion, Failure, GenericResponse, StandardLivenessResponseData, StateId as CoreStateId, ValidatorAggregateAttestationQuery, @@ -18,6 +17,7 @@ use eth2::types::{ }; use lighthouse_network::PubsubMessage; use network::{NetworkMessage, ValidatorSubscriptionMessage}; +use reqwest::StatusCode; use slot_clock::SlotClock; use std::sync::Arc; use tokio::sync::mpsc::{Sender, UnboundedSender}; diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index 357b78cf41..ef5c508595 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -4,11 +4,11 @@ use beacon_chain::{ GossipVerifiedBlock, IntoGossipVerifiedBlock, WhenSlotSkipped, test_utils::{AttestationStrategy, BlockStrategy}, }; -use eth2::reqwest::{Response, StatusCode}; use eth2::types::{BroadcastValidation, PublishBlockRequest}; use fixed_bytes::FixedBytesExtended; use http_api::test_utils::InteractiveTester; use http_api::{Config, ProvenancedBlock, publish_blinded_block, publish_block, reconstruct_block}; +use reqwest::{Response, StatusCode}; use std::collections::HashSet; use std::sync::Arc; use types::{ColumnIndex, Epoch, EthSpec, ForkName, Hash256, MainnetEthSpec, Slot}; diff --git a/beacon_node/http_api/tests/status_tests.rs b/beacon_node/http_api/tests/status_tests.rs index 556b75cb85..6bca9e51f6 100644 --- a/beacon_node/http_api/tests/status_tests.rs +++ b/beacon_node/http_api/tests/status_tests.rs @@ -3,9 +3,9 @@ use beacon_chain::{ BlockError, test_utils::{AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy}, }; -use eth2::StatusCode; use execution_layer::{PayloadStatusV1, PayloadStatusV1Status}; use http_api::test_utils::InteractiveTester; +use reqwest::StatusCode; use types::{EthSpec, ExecPayload, ForkName, MinimalEthSpec, Slot, Uint256}; type E = MinimalEthSpec; diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 367a0e3f05..6787d1ab9e 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -10,9 +10,8 @@ use bls::{AggregateSignature, Keypair, PublicKeyBytes, Signature, SignatureBytes use eth2::{ BeaconNodeHttpClient, Error, Error::ServerMessage, - StatusCode, Timeouts, + Timeouts, mixin::{RequestAccept, ResponseForkName, ResponseOptional}, - reqwest::{RequestBuilder, Response}, types::{ BlockId as CoreBlockId, ForkChoiceNode, ProduceBlockV3Response, StateId as CoreStateId, *, }, @@ -34,6 +33,7 @@ use network::NetworkReceivers; use network_utils::enr_ext::EnrExt; use operation_pool::attestation_storage::CheckpointKey; use proto_array::ExecutionStatus; +use reqwest::{RequestBuilder, Response, StatusCode}; use sensitive_url::SensitiveUrl; use slot_clock::SlotClock; use ssz::BitList; diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 95744a4137..cdf63a3c67 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -22,8 +22,6 @@ pub use beacon_response::{ }; pub use self::error::{Error, ok_or_error, success_or_error}; -pub use reqwest; -pub use reqwest::{StatusCode, Url}; pub use sensitive_url::SensitiveUrl; use self::mixin::{RequestAccept, ResponseOptional}; @@ -38,7 +36,7 @@ use futures_util::StreamExt; #[cfg(feature = "network")] use libp2p_identity::PeerId; use reqwest::{ - Body, IntoUrl, RequestBuilder, Response, + Body, IntoUrl, RequestBuilder, Response, StatusCode, Url, header::{HeaderMap, HeaderValue}, }; #[cfg(feature = "events")] diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 8b33a4dfb9..0c9c0b95f0 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1604,7 +1604,7 @@ pub struct BroadcastValidationQuery { } pub mod serde_status_code { - use crate::StatusCode; + use reqwest::StatusCode; use serde::{Deserialize, Serialize, de::Error}; pub fn serialize(status_code: &StatusCode, ser: S) -> Result diff --git a/common/warp_utils/Cargo.toml b/common/warp_utils/Cargo.toml index 32a540a69d..80bc247cbf 100644 --- a/common/warp_utils/Cargo.toml +++ b/common/warp_utils/Cargo.toml @@ -9,6 +9,7 @@ edition = { workspace = true } bytes = { workspace = true } eth2 = { workspace = true } headers = "0.3.2" +reqwest = { workspace = true } safe_arith = { workspace = true } serde = { workspace = true } serde_array_query = "0.1.0" diff --git a/common/warp_utils/src/status_code.rs b/common/warp_utils/src/status_code.rs index 1b05297359..a654b6d2c5 100644 --- a/common/warp_utils/src/status_code.rs +++ b/common/warp_utils/src/status_code.rs @@ -1,4 +1,4 @@ -use eth2::StatusCode; +use reqwest::StatusCode; use warp::Rejection; /// Convert from a "new" `http::StatusCode` to a `warp` compatible one. diff --git a/testing/node_test_rig/Cargo.toml b/testing/node_test_rig/Cargo.toml index 0d9db528da..21ec6fac12 100644 --- a/testing/node_test_rig/Cargo.toml +++ b/testing/node_test_rig/Cargo.toml @@ -10,6 +10,7 @@ beacon_node_fallback = { workspace = true } environment = { workspace = true } eth2 = { workspace = true } execution_layer = { workspace = true } +reqwest = { workspace = true } sensitive_url = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } diff --git a/testing/node_test_rig/src/lib.rs b/testing/node_test_rig/src/lib.rs index ece6001802..76a5b7ddb2 100644 --- a/testing/node_test_rig/src/lib.rs +++ b/testing/node_test_rig/src/lib.rs @@ -4,7 +4,8 @@ use beacon_node::ProductionBeaconNode; use environment::RuntimeContext; -use eth2::{BeaconNodeHttpClient, Timeouts, reqwest::ClientBuilder}; +use eth2::{BeaconNodeHttpClient, Timeouts}; +use reqwest::ClientBuilder; use sensitive_url::SensitiveUrl; use std::path::PathBuf; use std::time::Duration; diff --git a/testing/validator_test_rig/Cargo.toml b/testing/validator_test_rig/Cargo.toml index f28a423433..2057a9fdc8 100644 --- a/testing/validator_test_rig/Cargo.toml +++ b/testing/validator_test_rig/Cargo.toml @@ -7,6 +7,7 @@ edition = { workspace = true } eth2 = { workspace = true } mockito = { workspace = true } regex = { workspace = true } +reqwest = { workspace = true } sensitive_url = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } diff --git a/testing/validator_test_rig/src/mock_beacon_node.rs b/testing/validator_test_rig/src/mock_beacon_node.rs index ff1e772d54..1ecdd85f3b 100644 --- a/testing/validator_test_rig/src/mock_beacon_node.rs +++ b/testing/validator_test_rig/src/mock_beacon_node.rs @@ -1,7 +1,8 @@ use eth2::types::{GenericResponse, SyncingData}; -use eth2::{BeaconNodeHttpClient, StatusCode, Timeouts}; +use eth2::{BeaconNodeHttpClient, Timeouts}; use mockito::{Matcher, Mock, Server, ServerGuard}; use regex::Regex; +use reqwest::StatusCode; use sensitive_url::SensitiveUrl; use std::marker::PhantomData; use std::str::FromStr; diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index c0d561b175..f70d5830ec 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -19,11 +19,11 @@ use beacon_node_fallback::{ use clap::ArgMatches; use doppelganger_service::DoppelgangerService; use environment::RuntimeContext; -use eth2::{BeaconNodeHttpClient, StatusCode, Timeouts, reqwest::ClientBuilder}; +use eth2::{BeaconNodeHttpClient, Timeouts}; use initialized_validators::Error::UnableToOpenVotingKeystore; use lighthouse_validator_store::LighthouseValidatorStore; use parking_lot::RwLock; -use reqwest::Certificate; +use reqwest::{Certificate, ClientBuilder, StatusCode}; use slot_clock::SlotClock; use slot_clock::SystemTimeSlotClock; use std::fs::File; diff --git a/validator_client/validator_services/Cargo.toml b/validator_client/validator_services/Cargo.toml index c914940914..2582968265 100644 --- a/validator_client/validator_services/Cargo.toml +++ b/validator_client/validator_services/Cargo.toml @@ -13,6 +13,7 @@ futures = { workspace = true } graffiti_file = { workspace = true } logging = { workspace = true } parking_lot = { workspace = true } +reqwest = { workspace = true } safe_arith = { workspace = true } slot_clock = { workspace = true } task_executor = { workspace = true } diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 625f8db7cb..df4c9b223c 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -1,9 +1,10 @@ use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, Error as FallbackError, Errors}; use bls::PublicKeyBytes; +use eth2::BeaconNodeHttpClient; use eth2::types::GraffitiPolicy; -use eth2::{BeaconNodeHttpClient, StatusCode}; use graffiti_file::{GraffitiFile, determine_graffiti}; use logging::crit; +use reqwest::StatusCode; use slot_clock::SlotClock; use std::fmt::Debug; use std::future::Future; From eec0700f94ee551484b6a39d596900ff53a8aba9 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Mon, 16 Feb 2026 18:09:35 -0800 Subject: [PATCH 013/189] Gloas local block building MVP (#8754) The flow for local block building is 1. Create execution payload and bid 2. Construct beacon block 3. Sign beacon block and publish 4. Sign execution payload and publish This PR adds the beacon block v4 flow , GET payload envelope and POST payload envelope (local block building only). The spec for these endpoints can be found here: https://github.com/ethereum/beacon-APIs/pull/552 and is subject to change. We needed a way to store the unsigned execution payload envelope associated to the execution payload bid that was included in the block. I introduced a new cache that stores these unsigned execution payload envelopes. the GET payload envelope queries this cache directly so that a proposer, after publishing a block, can fetch the payload envelope + sign and publish it. I kept payload signing and publishing within the validators block service to keep things simple for now. The idea was to build out a block production MVP for devnet 0, try not to affect any non gloas code paths and build things out in such a way that it will be easy to deprecate pre-gloas code paths later on (for example block production v2 and v3). We will eventually need to track which beacon node was queried for the block so that we can later query it for the payload. But thats not needed for the devnet. Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Michael Sproul Co-Authored-By: Jimmy Chen Co-Authored-By: Eitan Seri-Levi --- beacon_node/beacon_chain/src/beacon_chain.rs | 257 +---- .../src/block_production/gloas.rs | 890 ++++++++++++++++++ .../beacon_chain/src/block_production/mod.rs | 223 +++++ beacon_node/beacon_chain/src/builder.rs | 1 + beacon_node/beacon_chain/src/errors.rs | 2 +- beacon_node/beacon_chain/src/lib.rs | 2 + .../src/pending_payload_envelopes.rs | 151 +++ beacon_node/client/src/notifier.rs | 10 + beacon_node/execution_layer/src/lib.rs | 60 +- .../src/test_utils/mock_builder.rs | 2 +- .../src/beacon/execution_payload_envelope.rs | 116 +++ beacon_node/http_api/src/beacon/mod.rs | 1 + beacon_node/http_api/src/beacon/states.rs | 1 - beacon_node/http_api/src/lib.rs | 63 +- beacon_node/http_api/src/produce_block.rs | 84 +- .../validator/execution_payload_envelope.rs | 110 +++ beacon_node/http_api/src/validator/mod.rs | 41 +- beacon_node/http_api/tests/tests.rs | 247 ++++- common/eth2/src/lib.rs | 274 +++++- common/eth2/src/types.rs | 39 +- .../src/envelope_processing.rs | 38 +- consensus/types/src/builder/builder_bid.rs | 2 +- .../types/src/core/application_domain.rs | 1 + consensus/types/src/core/chain_spec.rs | 4 +- .../validator/validator_registration_data.rs | 2 +- testing/ef_tests/src/cases/operations.rs | 10 +- .../lighthouse_validator_store/src/lib.rs | 45 +- validator_client/signing_method/src/lib.rs | 5 + .../signing_method/src/web3signer.rs | 4 + .../validator_services/src/block_service.rs | 305 ++++-- validator_client/validator_store/src/lib.rs | 12 +- 31 files changed, 2656 insertions(+), 346 deletions(-) create mode 100644 beacon_node/beacon_chain/src/block_production/gloas.rs create mode 100644 beacon_node/beacon_chain/src/block_production/mod.rs create mode 100644 beacon_node/beacon_chain/src/pending_payload_envelopes.rs create mode 100644 beacon_node/http_api/src/beacon/execution_payload_envelope.rs create mode 100644 beacon_node/http_api/src/validator/execution_payload_envelope.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 4ae7871758..d0f5297f1b 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -31,7 +31,7 @@ use crate::errors::{BeaconChainError as Error, BlockProductionError}; use crate::events::ServerSentEventHandler; use crate::execution_payload::{NotifyExecutionLayer, PreparePayloadHandle, get_execution_payload}; use crate::fetch_blobs::EngineGetBlobsOutput; -use crate::fork_choice_signal::{ForkChoiceSignalRx, ForkChoiceSignalTx, ForkChoiceWaitResult}; +use crate::fork_choice_signal::{ForkChoiceSignalRx, ForkChoiceSignalTx}; use crate::graffiti_calculator::{GraffitiCalculator, GraffitiSettings}; use crate::kzg_utils::reconstruct_blobs; use crate::light_client_finality_update_verification::{ @@ -56,6 +56,7 @@ use crate::observed_block_producers::ObservedBlockProducers; use crate::observed_data_sidecars::ObservedDataSidecars; use crate::observed_operations::{ObservationOutcome, ObservedOperations}; use crate::observed_slashable::ObservedSlashable; +use crate::pending_payload_envelopes::PendingPayloadEnvelopes; use crate::persisted_beacon_chain::PersistedBeaconChain; use crate::persisted_custody::persist_custody_context; use crate::persisted_fork_choice::PersistedForkChoice; @@ -235,7 +236,7 @@ pub struct PrePayloadAttributes { /// /// The parent block number is not part of the payload attributes sent to the EL, but *is* /// sent to builders via SSE. - pub parent_block_number: u64, + pub parent_block_number: Option, /// The block root of the block being built upon (same block as fcU `headBlockHash`). pub parent_beacon_block_root: Hash256, } @@ -419,6 +420,9 @@ pub struct BeaconChain { RwLock, T::EthSpec>>, /// Maintains a record of slashable message seen over the gossip network or RPC. pub observed_slashable: RwLock>, + /// Cache of pending execution payload envelopes for local block building. + /// Envelopes are stored here during block production and eventually published. + pub pending_payload_envelopes: RwLock>, /// Maintains a record of which validators have submitted voluntary exits. pub observed_voluntary_exits: Mutex>, /// Maintains a record of which validators we've seen proposer slashings for. @@ -4504,55 +4508,6 @@ impl BeaconChain { Ok(()) } - /// If configured, wait for the fork choice run at the start of the slot to complete. - #[instrument(level = "debug", skip_all)] - fn wait_for_fork_choice_before_block_production( - self: &Arc, - slot: Slot, - ) -> Result<(), BlockProductionError> { - if let Some(rx) = &self.fork_choice_signal_rx { - let current_slot = self - .slot() - .map_err(|_| BlockProductionError::UnableToReadSlot)?; - - let timeout = Duration::from_millis(self.config.fork_choice_before_proposal_timeout_ms); - - if slot == current_slot || slot == current_slot + 1 { - match rx.wait_for_fork_choice(slot, timeout) { - ForkChoiceWaitResult::Success(fc_slot) => { - debug!( - %slot, - fork_choice_slot = %fc_slot, - "Fork choice successfully updated before block production" - ); - } - ForkChoiceWaitResult::Behind(fc_slot) => { - warn!( - fork_choice_slot = %fc_slot, - %slot, - message = "this block may be orphaned", - "Fork choice notifier out of sync with block production" - ); - } - ForkChoiceWaitResult::TimeOut => { - warn!( - message = "this block may be orphaned", - "Timed out waiting for fork choice before proposal" - ); - } - } - } else { - error!( - %slot, - %current_slot, - message = "check clock sync, this block may be orphaned", - "Producing block at incorrect slot" - ); - } - } - Ok(()) - } - pub async fn produce_block_with_verification( self: &Arc, randao_reveal: Signature, @@ -4599,165 +4554,6 @@ impl BeaconChain { .await } - /// Load a beacon state from the database for block production. This is a long-running process - /// that should not be performed in an `async` context. - fn load_state_for_block_production( - self: &Arc, - slot: Slot, - ) -> Result<(BeaconState, Option), BlockProductionError> { - let fork_choice_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_FORK_CHOICE_TIMES); - self.wait_for_fork_choice_before_block_production(slot)?; - drop(fork_choice_timer); - - let state_load_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_STATE_LOAD_TIMES); - - // Atomically read some values from the head whilst avoiding holding cached head `Arc` any - // longer than necessary. - let (head_slot, head_block_root, head_state_root) = { - let head = self.canonical_head.cached_head(); - ( - head.head_slot(), - head.head_block_root(), - head.head_state_root(), - ) - }; - let (state, state_root_opt) = if head_slot < slot { - // Attempt an aggressive re-org if configured and the conditions are right. - if let Some((re_org_state, re_org_state_root)) = - self.get_state_for_re_org(slot, head_slot, head_block_root) - { - info!( - %slot, - head_to_reorg = %head_block_root, - "Proposing block to re-org current head" - ); - (re_org_state, Some(re_org_state_root)) - } else { - // Fetch the head state advanced through to `slot`, which should be present in the - // state cache thanks to the state advance timer. - let (state_root, state) = self - .store - .get_advanced_hot_state(head_block_root, slot, head_state_root) - .map_err(BlockProductionError::FailedToLoadState)? - .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; - (state, Some(state_root)) - } - } else { - warn!( - message = "this block is more likely to be orphaned", - %slot, - "Producing block that conflicts with head" - ); - let state = self - .state_at_slot(slot - 1, StateSkipConfig::WithStateRoots) - .map_err(|_| BlockProductionError::UnableToProduceAtSlot(slot))?; - - (state, None) - }; - - drop(state_load_timer); - - Ok((state, state_root_opt)) - } - - /// Fetch the beacon state to use for producing a block if a 1-slot proposer re-org is viable. - /// - /// This function will return `None` if proposer re-orgs are disabled. - #[instrument(skip_all, level = "debug")] - fn get_state_for_re_org( - &self, - slot: Slot, - head_slot: Slot, - canonical_head: Hash256, - ) -> Option<(BeaconState, Hash256)> { - let re_org_head_threshold = self.config.re_org_head_threshold?; - let re_org_parent_threshold = self.config.re_org_parent_threshold?; - - if self.spec.proposer_score_boost.is_none() { - warn!( - reason = "this network does not have proposer boost enabled", - "Ignoring proposer re-org configuration" - ); - return None; - } - - let slot_delay = self - .slot_clock - .seconds_from_current_slot_start() - .or_else(|| { - warn!(error = "unable to read slot clock", "Not attempting re-org"); - None - })?; - - // Attempt a proposer re-org if: - // - // 1. It seems we have time to propagate and still receive the proposer boost. - // 2. The current head block was seen late. - // 3. The `get_proposer_head` conditions from fork choice pass. - let proposing_on_time = - slot_delay < self.config.re_org_cutoff(self.spec.get_slot_duration()); - if !proposing_on_time { - debug!(reason = "not proposing on time", "Not attempting re-org"); - return None; - } - - let head_late = self.block_observed_after_attestation_deadline(canonical_head, head_slot); - if !head_late { - debug!(reason = "head not late", "Not attempting re-org"); - return None; - } - - // Is the current head weak and appropriate for re-orging? - let proposer_head_timer = - metrics::start_timer(&metrics::BLOCK_PRODUCTION_GET_PROPOSER_HEAD_TIMES); - let proposer_head = self - .canonical_head - .fork_choice_read_lock() - .get_proposer_head( - slot, - canonical_head, - re_org_head_threshold, - re_org_parent_threshold, - &self.config.re_org_disallowed_offsets, - self.config.re_org_max_epochs_since_finalization, - ) - .map_err(|e| match e { - ProposerHeadError::DoNotReOrg(reason) => { - debug!( - %reason, - "Not attempting re-org" - ); - } - ProposerHeadError::Error(e) => { - warn!( - error = ?e, - "Not attempting re-org" - ); - } - }) - .ok()?; - drop(proposer_head_timer); - let re_org_parent_block = proposer_head.parent_node.root; - - let (state_root, state) = self - .store - .get_advanced_hot_state_from_cache(re_org_parent_block, slot) - .or_else(|| { - warn!(reason = "no state in cache", "Not attempting re-org"); - None - })?; - - info!( - weak_head = ?canonical_head, - parent = ?re_org_parent_block, - head_weight = proposer_head.head_node.weight, - threshold_weight = proposer_head.re_org_head_weight_threshold, - "Attempting re-org due to weak head" - ); - - Some((state, state_root)) - } - /// Get the proposer index and `prev_randao` value for a proposal at slot `proposal_slot`. /// /// The `proposer_head` may be the head block of `cached_head` or its parent. An error will @@ -4840,15 +4636,25 @@ impl BeaconChain { return Ok(None); }; - // Get the `prev_randao` and parent block number. - let head_block_number = cached_head.head_block_number()?; - let (prev_randao, parent_block_number) = if proposer_head == head_parent_block_root { - ( - cached_head.parent_random()?, - head_block_number.saturating_sub(1), - ) + // 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) } else { - (cached_head.head_random()?, head_block_number) + // Get the `prev_randao` and parent block number. + let head_block_number = cached_head.head_block_number()?; + if proposer_head == head_parent_block_root { + ( + cached_head.parent_random()?, + Some(head_block_number.saturating_sub(1)), + ) + } else { + (cached_head.head_random()?, Some(head_block_number)) + } }; Ok(Some(PrePayloadAttributes { @@ -5093,7 +4899,11 @@ impl BeaconChain { } /// Check if the block with `block_root` was observed after the attestation deadline of `slot`. - fn block_observed_after_attestation_deadline(&self, block_root: Hash256, slot: Slot) -> bool { + pub(crate) fn block_observed_after_attestation_deadline( + &self, + block_root: Hash256, + slot: Slot, + ) -> bool { let block_delays = self.block_times_cache.read().get_block_delays( block_root, self.slot_clock @@ -5860,7 +5670,11 @@ impl BeaconChain { execution_payload_value, ) } - BeaconState::Gloas(_) => return Err(BlockProductionError::GloasNotImplemented), + BeaconState::Gloas(_) => { + return Err(BlockProductionError::GloasNotImplemented( + "Attempting to produce gloas beacn block via non gloas code path".to_owned(), + )); + } }; let block = SignedBeaconBlock::from_block( @@ -6198,13 +6012,14 @@ impl BeaconChain { // Push a server-sent event (probably to a block builder or relay). if let Some(event_handler) = &self.event_handler && event_handler.has_payload_attributes_subscribers() + && let Some(parent_block_number) = pre_payload_attributes.parent_block_number { event_handler.register(EventKind::PayloadAttributes(ForkVersionedResponse { data: SseExtendedPayloadAttributes { proposal_slot: prepare_slot, proposer_index: proposer, parent_block_root: head_root, - parent_block_number: pre_payload_attributes.parent_block_number, + parent_block_number, parent_block_hash: forkchoice_update_params.head_hash.unwrap_or_default(), payload_attributes: payload_attributes.into(), }, diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs new file mode 100644 index 0000000000..025cf21a73 --- /dev/null +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -0,0 +1,890 @@ +use std::collections::HashMap; +use std::marker::PhantomData; +use std::sync::Arc; + +use bls::Signature; +use execution_layer::{ + BlockProposalContentsGloas, BuilderParams, PayloadAttributes, PayloadParameters, +}; +use operation_pool::CompactAttestationRef; +use ssz::Encode; +use state_processing::common::get_attesting_indices_from_state; +use state_processing::envelope_processing::{VerifyStateRoot, process_execution_payload_envelope}; +use state_processing::epoch_cache::initialize_epoch_cache; +use state_processing::per_block_processing::{ + compute_timestamp_at_slot, get_expected_withdrawals, verify_attestation_for_block_inclusion, +}; +use state_processing::{ + BlockSignatureStrategy, ConsensusContext, VerifyBlockRoot, VerifySignatures, +}; +use state_processing::{VerifyOperation, state_advance::complete_state_advance}; +use task_executor::JoinHandle; +use tracing::{Instrument, Span, debug, debug_span, error, instrument, trace, warn}; +use tree_hash::TreeHash; +use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; +use types::{ + Address, Attestation, AttestationElectra, AttesterSlashing, AttesterSlashingElectra, + BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BeaconStateError, + BuilderIndex, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, + ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, FullPayload, Graffiti, + Hash256, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, + SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedVoluntaryExit, Slot, SyncAggregate, Withdrawal, Withdrawals, +}; + +use crate::{ + BeaconChain, BeaconChainError, BeaconChainTypes, BlockProductionError, + ProduceBlockVerification, graffiti_calculator::GraffitiSettings, metrics, +}; + +pub const BID_VALUE_SELF_BUILD: u64 = 0; +pub const EXECUTION_PAYMENT_TRUSTLESS_BUILD: u64 = 0; + +type ConsensusBlockValue = u64; +type BlockProductionResult = (BeaconBlock>, ConsensusBlockValue); + +pub type PreparePayloadResult = Result, BlockProductionError>; +pub type PreparePayloadHandle = JoinHandle>>; + +pub struct PartialBeaconBlock { + slot: Slot, + proposer_index: u64, + parent_root: Hash256, + randao_reveal: Signature, + eth1_data: Eth1Data, + graffiti: Graffiti, + proposer_slashings: Vec, + attester_slashings: Vec>, + attestations: Vec>, + payload_attestations: Vec>, + deposits: Vec, + voluntary_exits: Vec, + sync_aggregate: Option>, + bls_to_execution_changes: Vec, +} + +/// Data needed to construct an ExecutionPayloadEnvelope. +/// The envelope requires the beacon_block_root which can only be computed after the block exists. +pub struct ExecutionPayloadData { + pub payload: ExecutionPayloadGloas, + pub execution_requests: ExecutionRequests, + pub builder_index: BuilderIndex, + pub slot: Slot, +} + +impl BeaconChain { + pub async fn produce_block_with_verification_gloas( + self: &Arc, + randao_reveal: Signature, + slot: Slot, + graffiti_settings: GraffitiSettings, + verification: ProduceBlockVerification, + _builder_boost_factor: Option, + ) -> Result, BlockProductionError> { + metrics::inc_counter(&metrics::BLOCK_PRODUCTION_REQUESTS); + let _complete_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_TIMES); + // Part 1/2 (blocking) + // + // Load the parent state from disk. + let chain = self.clone(); + let span = Span::current(); + let (state, state_root_opt) = self + .task_executor + .spawn_blocking_handle( + move || { + let _guard = + debug_span!(parent: span, "load_state_for_block_production").entered(); + chain.load_state_for_block_production(slot) + }, + "load_state_for_block_production", + ) + .ok_or(BlockProductionError::ShuttingDown)? + .await + .map_err(BlockProductionError::TokioJoin)??; + + // Part 2/2 (async, with some blocking components) + // + // Produce the block upon the state + self.produce_block_on_state_gloas( + state, + state_root_opt, + slot, + randao_reveal, + graffiti_settings, + verification, + ) + .await + } + + // TODO(gloas) need to implement builder boost factor logic + #[instrument(level = "debug", skip_all)] + pub async fn produce_block_on_state_gloas( + self: &Arc, + state: BeaconState, + state_root_opt: Option, + produce_at_slot: Slot, + randao_reveal: Signature, + graffiti_settings: GraffitiSettings, + verification: ProduceBlockVerification, + ) -> Result, BlockProductionError> { + // Part 1/3 (blocking) + // + // Perform the state advance and block-packing functions. + let chain = self.clone(); + let graffiti = self + .graffiti_calculator + .get_graffiti(graffiti_settings) + .await; + let span = Span::current(); + let (partial_beacon_block, state) = self + .task_executor + .spawn_blocking_handle( + move || { + let _guard = + debug_span!(parent: span, "produce_partial_beacon_block_gloas").entered(); + chain.produce_partial_beacon_block_gloas( + state, + state_root_opt, + produce_at_slot, + randao_reveal, + graffiti, + ) + }, + "produce_partial_beacon_block_gloas", + ) + .ok_or(BlockProductionError::ShuttingDown)? + .await + .map_err(BlockProductionError::TokioJoin)??; + + // Part 2/3 (async) + // + // Produce the execution payload bid. + // TODO(gloas) this is strictly for building local bids + // We'll need to build out trustless/trusted bid paths. + let (execution_payload_bid, state, payload_data) = self + .clone() + .produce_execution_payload_bid( + state, + produce_at_slot, + BID_VALUE_SELF_BUILD, + BUILDER_INDEX_SELF_BUILD, + ) + .await?; + + // Part 3/3 (blocking) + // + // Complete the block with the execution payload bid. + let chain = self.clone(); + let span = Span::current(); + self.task_executor + .spawn_blocking_handle( + move || { + let _guard = + debug_span!(parent: span, "complete_partial_beacon_block_gloas").entered(); + chain.complete_partial_beacon_block_gloas( + partial_beacon_block, + execution_payload_bid, + payload_data, + state, + verification, + ) + }, + "complete_partial_beacon_block_gloas", + ) + .ok_or(BlockProductionError::ShuttingDown)? + .await + .map_err(BlockProductionError::TokioJoin)? + } + + #[allow(clippy::too_many_arguments)] + #[allow(clippy::type_complexity)] + fn produce_partial_beacon_block_gloas( + self: &Arc, + mut state: BeaconState, + state_root_opt: Option, + produce_at_slot: Slot, + randao_reveal: Signature, + graffiti: Graffiti, + ) -> Result<(PartialBeaconBlock, BeaconState), BlockProductionError> + { + // It is invalid to try to produce a block using a state from a future slot. + if state.slot() > produce_at_slot { + return Err(BlockProductionError::StateSlotTooHigh { + produce_at_slot, + state_slot: state.slot(), + }); + } + + let slot_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_SLOT_PROCESS_TIMES); + + // Ensure the state has performed a complete transition into the required slot. + complete_state_advance(&mut state, state_root_opt, produce_at_slot, &self.spec)?; + + drop(slot_timer); + + state.build_committee_cache(RelativeEpoch::Current, &self.spec)?; + state.apply_pending_mutations()?; + + let parent_root = if state.slot() > 0 { + *state + .get_block_root(state.slot() - 1) + .map_err(|_| BlockProductionError::UnableToGetBlockRootFromState)? + } else { + state.latest_block_header().canonical_root() + }; + + let proposer_index = state.get_beacon_proposer_index(state.slot(), &self.spec)? as u64; + + let slashings_and_exits_span = debug_span!("get_slashings_and_exits").entered(); + let (mut proposer_slashings, mut attester_slashings, mut voluntary_exits) = + self.op_pool.get_slashings_and_exits(&state, &self.spec); + + drop(slashings_and_exits_span); + + let eth1_data = state.eth1_data().clone(); + + let deposits = vec![]; + + let bls_changes_span = debug_span!("get_bls_to_execution_changes").entered(); + let bls_to_execution_changes = self + .op_pool + .get_bls_to_execution_changes(&state, &self.spec); + drop(bls_changes_span); + + // Iterate through the naive aggregation pool and ensure all the attestations from there + // are included in the operation pool. + { + let _guard = debug_span!("import_naive_aggregation_pool").entered(); + let _unagg_import_timer = + metrics::start_timer(&metrics::BLOCK_PRODUCTION_UNAGGREGATED_TIMES); + for attestation in self.naive_aggregation_pool.read().iter() { + let import = |attestation: &Attestation| { + let attesting_indices = + get_attesting_indices_from_state(&state, attestation.to_ref())?; + self.op_pool + .insert_attestation(attestation.clone(), attesting_indices) + }; + if let Err(e) = import(attestation) { + // Don't stop block production if there's an error, just create a log. + error!( + reason = ?e, + "Attestation did not transfer to op pool" + ); + } + } + }; + + let mut attestations = { + let _guard = debug_span!("pack_attestations").entered(); + let _attestation_packing_timer = + metrics::start_timer(&metrics::BLOCK_PRODUCTION_ATTESTATION_TIMES); + + // Epoch cache and total balance cache are required for op pool packing. + state.build_total_active_balance_cache(&self.spec)?; + initialize_epoch_cache(&mut state, &self.spec)?; + + let mut prev_filter_cache = HashMap::new(); + let prev_attestation_filter = |att: &CompactAttestationRef| { + self.filter_op_pool_attestation(&mut prev_filter_cache, att, &state) + }; + let mut curr_filter_cache = HashMap::new(); + let curr_attestation_filter = |att: &CompactAttestationRef| { + self.filter_op_pool_attestation(&mut curr_filter_cache, att, &state) + }; + + self.op_pool + .get_attestations( + &state, + prev_attestation_filter, + curr_attestation_filter, + &self.spec, + ) + .map_err(BlockProductionError::OpPoolError)? + }; + + // If paranoid mode is enabled re-check the signatures of every included message. + // This will be a lot slower but guards against bugs in block production and can be + // quickly rolled out without a release. + if self.config.paranoid_block_proposal { + let mut tmp_ctxt = ConsensusContext::new(state.slot()); + attestations.retain(|att| { + verify_attestation_for_block_inclusion( + &state, + att.to_ref(), + &mut tmp_ctxt, + VerifySignatures::True, + &self.spec, + ) + .map_err(|e| { + warn!( + err = ?e, + block_slot = %state.slot(), + attestation = ?att, + "Attempted to include an invalid attestation" + ); + }) + .is_ok() + }); + + proposer_slashings.retain(|slashing| { + slashing + .clone() + .validate(&state, &self.spec) + .map_err(|e| { + warn!( + err = ?e, + block_slot = %state.slot(), + ?slashing, + "Attempted to include an invalid proposer slashing" + ); + }) + .is_ok() + }); + + attester_slashings.retain(|slashing| { + slashing + .clone() + .validate(&state, &self.spec) + .map_err(|e| { + warn!( + err = ?e, + block_slot = %state.slot(), + ?slashing, + "Attempted to include an invalid attester slashing" + ); + }) + .is_ok() + }); + + voluntary_exits.retain(|exit| { + exit.clone() + .validate(&state, &self.spec) + .map_err(|e| { + warn!( + err = ?e, + block_slot = %state.slot(), + ?exit, + "Attempted to include an invalid proposer slashing" + ); + }) + .is_ok() + }); + + // TODO(gloas) verifiy payload attestation signature here as well + } + + let attester_slashings = attester_slashings + .into_iter() + .filter_map(|a| match a { + AttesterSlashing::Base(_) => None, + AttesterSlashing::Electra(a) => Some(a), + }) + .collect::>(); + + let attestations = attestations + .into_iter() + .filter_map(|a| match a { + Attestation::Base(_) => None, + Attestation::Electra(a) => Some(a), + }) + .collect::>(); + + let slot = state.slot(); + + let sync_aggregate = if matches!(&state, BeaconState::Base(_)) { + None + } else { + let sync_aggregate = self + .op_pool + .get_sync_aggregate(&state) + .map_err(BlockProductionError::OpPoolError)? + .unwrap_or_else(|| { + warn!( + slot = %state.slot(), + "Producing block with no sync contributions" + ); + SyncAggregate::new() + }); + Some(sync_aggregate) + }; + + Ok(( + PartialBeaconBlock { + slot, + proposer_index, + parent_root, + randao_reveal, + eth1_data, + graffiti, + proposer_slashings, + attester_slashings, + attestations, + deposits, + voluntary_exits, + sync_aggregate, + // TODO(gloas) need to implement payload attestations + payload_attestations: vec![], + bls_to_execution_changes, + }, + state, + )) + } + + #[allow(clippy::type_complexity)] + fn complete_partial_beacon_block_gloas( + &self, + partial_beacon_block: PartialBeaconBlock, + signed_execution_payload_bid: SignedExecutionPayloadBid, + payload_data: Option>, + mut state: BeaconState, + verification: ProduceBlockVerification, + ) -> Result<(BeaconBlock>, u64), BlockProductionError> { + let PartialBeaconBlock { + slot, + proposer_index, + parent_root, + randao_reveal, + eth1_data, + graffiti, + proposer_slashings, + attester_slashings, + attestations, + deposits, + voluntary_exits, + sync_aggregate, + payload_attestations, + bls_to_execution_changes, + } = partial_beacon_block; + + let beacon_block = match &state { + BeaconState::Base(_) + | BeaconState::Altair(_) + | BeaconState::Bellatrix(_) + | BeaconState::Capella(_) + | BeaconState::Deneb(_) + | BeaconState::Electra(_) + | BeaconState::Fulu(_) => { + return Err(BlockProductionError::InvalidBlockVariant( + "Cannot construct a block pre-Gloas".to_owned(), + )); + } + BeaconState::Gloas(_) => BeaconBlock::Gloas(BeaconBlockGloas { + slot, + proposer_index, + parent_root, + state_root: Hash256::ZERO, + body: BeaconBlockBodyGloas { + randao_reveal, + eth1_data, + graffiti, + proposer_slashings: proposer_slashings + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + attester_slashings: attester_slashings + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + attestations: attestations + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + deposits: deposits + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + voluntary_exits: voluntary_exits + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + sync_aggregate: sync_aggregate + .ok_or(BlockProductionError::MissingSyncAggregate)?, + bls_to_execution_changes: bls_to_execution_changes + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + signed_execution_payload_bid, + payload_attestations: payload_attestations + .try_into() + .map_err(BlockProductionError::SszTypesError)?, + _phantom: PhantomData::>, + }, + }), + }; + + let signed_beacon_block = SignedBeaconBlock::from_block( + beacon_block, + // The block is not signed here, that is the task of a validator client. + Signature::empty(), + ); + + let block_size = signed_beacon_block.ssz_bytes_len(); + debug!(%block_size, "Produced block on state"); + + metrics::observe(&metrics::BLOCK_SIZE, block_size as f64); + + if block_size > self.config.max_network_size { + return Err(BlockProductionError::BlockTooLarge(block_size)); + } + + let process_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_PROCESS_TIMES); + let signature_strategy = match verification { + ProduceBlockVerification::VerifyRandao => BlockSignatureStrategy::VerifyRandao, + ProduceBlockVerification::NoVerification => BlockSignatureStrategy::NoVerification, + }; + + // Use a context without block root or proposer index so that both are checked. + let mut ctxt = ConsensusContext::new(signed_beacon_block.slot()); + + let consensus_block_value = self + .compute_beacon_block_reward(signed_beacon_block.message(), &mut state) + .map(|reward| reward.total) + .unwrap_or(0); + + state_processing::per_block_processing( + &mut state, + &signed_beacon_block, + signature_strategy, + VerifyBlockRoot::True, + &mut ctxt, + &self.spec, + )?; + drop(process_timer); + + let state_root_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_STATE_ROOT_TIMES); + + let state_root = state.update_tree_hash_cache()?; + + drop(state_root_timer); + + let (mut block, _) = signed_beacon_block.deconstruct(); + *block.state_root_mut() = state_root; + + // Construct and cache the ExecutionPayloadEnvelope if we have payload data. + // For local building, we always have payload data. + // For trustless building, the builder will provide the envelope separately. + if let Some(payload_data) = payload_data { + let beacon_block_root = block.tree_hash_root(); + let execution_payload_envelope = ExecutionPayloadEnvelope { + payload: payload_data.payload, + execution_requests: payload_data.execution_requests, + builder_index: payload_data.builder_index, + beacon_block_root, + slot: payload_data.slot, + state_root: Hash256::ZERO, + }; + + let mut signed_envelope = SignedExecutionPayloadEnvelope { + message: execution_payload_envelope, + signature: Signature::empty(), + }; + + // TODO(gloas) add better error variant + // We skip state root verification here because the relevant state root + // cant be calculated until after the new block has been constructed. + process_execution_payload_envelope( + &mut state, + None, + &signed_envelope, + VerifySignatures::False, + VerifyStateRoot::False, + &self.spec, + ) + .map_err(|_| { + BlockProductionError::GloasNotImplemented( + "process_execution_payload_envelope failed".to_owned(), + ) + })?; + + signed_envelope.message.state_root = state.update_tree_hash_cache()?; + + // Cache the envelope for later retrieval by the validator for signing and publishing. + let envelope_slot = payload_data.slot; + // TODO(gloas) might be safer to cache by root instead of by slot. + // We should revisit this once this code path + beacon api spec matures + self.pending_payload_envelopes + .write() + .insert(envelope_slot, signed_envelope.message); + + debug!( + %beacon_block_root, + slot = %envelope_slot, + "Cached pending execution payload envelope" + ); + } + + metrics::inc_counter(&metrics::BLOCK_PRODUCTION_SUCCESSES); + + trace!( + parent = ?block.parent_root(), + attestations = block.body().attestations_len(), + slot = %block.slot(), + "Produced beacon block" + ); + + Ok((block, consensus_block_value)) + } + + // TODO(gloas) introduce `ProposerPreferences` so we can build out trustless + // bid building. Right now this only works for local building. + /// Produce an `ExecutionPayloadBid` for some `slot` upon the given `state`. + /// This function assumes we've already advanced `state`. + /// + /// Returns the signed bid, the state, and optionally the payload data needed to construct + /// the `ExecutionPayloadEnvelope` after the beacon block is created. + /// + /// For local building, payload data is always returned (`Some`). + /// For trustless building, the builder provides the envelope separately, so `None` is returned. + #[allow(clippy::type_complexity)] + #[instrument(level = "debug", skip_all)] + pub async fn produce_execution_payload_bid( + self: Arc, + mut state: BeaconState, + produce_at_slot: Slot, + bid_value: u64, + builder_index: BuilderIndex, + ) -> Result< + ( + SignedExecutionPayloadBid, + BeaconState, + Option>, + ), + BlockProductionError, + > { + // TODO(gloas) For non local building, add sanity check on value + // The builder MUST have enough excess balance to fulfill this bid (i.e. `value`) and all pending payments. + + // TODO(gloas) add metrics for execution payload bid production + + let parent_root = if state.slot() > 0 { + *state + .get_block_root(state.slot() - 1) + .map_err(|_| BlockProductionError::UnableToGetBlockRootFromState)? + } else { + state.latest_block_header().canonical_root() + }; + + let proposer_index = state.get_beacon_proposer_index(state.slot(), &self.spec)? as u64; + + let pubkey = state + .validators() + .get(proposer_index as usize) + .map(|v| v.pubkey) + .ok_or(BlockProductionError::BeaconChain(Box::new( + BeaconChainError::ValidatorIndexUnknown(proposer_index as usize), + )))?; + + let builder_params = BuilderParams { + pubkey, + slot: state.slot(), + chain_health: self + .is_healthy(&parent_root) + .map_err(|e| BlockProductionError::BeaconChain(Box::new(e)))?, + }; + + // TODO(gloas) this should be BlockProductionVersion::V4 + // V3 is okay for now as long as we're not connected to a builder + // TODO(gloas) add builder boost factor + let prepare_payload_handle = get_execution_payload_gloas( + self.clone(), + &state, + parent_root, + proposer_index, + builder_params, + )?; + + let block_proposal_contents = prepare_payload_handle + .await + .map_err(BlockProductionError::TokioJoin)? + .ok_or(BlockProductionError::ShuttingDown)??; + + let BlockProposalContentsGloas { + payload, + payload_value: _, + execution_requests, + blob_kzg_commitments, + blobs_and_proofs: _, + } = block_proposal_contents; + + let state_root = state.update_tree_hash_cache()?; + + // TODO(gloas) since we are defaulting to local building, execution payment is 0 + // execution payment should only be set to > 0 for trusted building. + let bid = ExecutionPayloadBid:: { + parent_block_hash: state.latest_block_hash()?.to_owned(), + parent_block_root: state.get_latest_block_root(state_root), + block_hash: payload.block_hash, + prev_randao: payload.prev_randao, + fee_recipient: Address::ZERO, + gas_limit: payload.gas_limit, + builder_index, + slot: produce_at_slot, + value: bid_value, + execution_payment: EXECUTION_PAYMENT_TRUSTLESS_BUILD, + blob_kzg_commitments, + }; + + // Store payload data for envelope construction after block is created + let payload_data = ExecutionPayloadData { + payload, + execution_requests, + builder_index, + slot: produce_at_slot, + }; + + // TODO(gloas) this is only local building + // we'll need to implement builder signature for the trustless path + Ok(( + SignedExecutionPayloadBid { + message: bid, + // TODO(gloas) return better error variant here + signature: Signature::infinity().map_err(|_| { + BlockProductionError::GloasNotImplemented( + "Failed to generate infinity signature".to_owned(), + ) + })?, + }, + state, + // Local building always returns payload data. + // Trustless building would return None here. + Some(payload_data), + )) + } +} + +/// Gets an execution payload for inclusion in a block. +/// +/// ## Errors +/// +/// Will return an error when using a pre-Gloas `state`. Ensure to only run this function +/// after the Gloas fork. +/// +/// ## Specification +/// +/// Equivalent to the `get_execution_payload` function in the Validator Guide: +/// +/// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md#block-proposal +fn get_execution_payload_gloas( + chain: Arc>, + state: &BeaconState, + parent_beacon_block_root: Hash256, + proposer_index: u64, + builder_params: BuilderParams, +) -> Result, BlockProductionError> { + // Compute all required values from the `state` now to avoid needing to pass it into a spawned + // task. + let spec = &chain.spec; + let current_epoch = state.current_epoch(); + let timestamp = + compute_timestamp_at_slot(state, state.slot(), spec).map_err(BeaconStateError::from)?; + let random = *state.get_randao_mix(current_epoch)?; + + let latest_execution_block_hash = *state.latest_block_hash()?; + let latest_gas_limit = state.latest_execution_payload_bid()?.gas_limit; + + let withdrawals = + Withdrawals::::from(get_expected_withdrawals(state, spec)?).into(); + + // Spawn a task to obtain the execution payload from the EL via a series of async calls. The + // `join_handle` can be used to await the result of the function. + let join_handle = chain + .task_executor + .clone() + .spawn_handle( + async move { + prepare_execution_payload::( + &chain, + timestamp, + random, + proposer_index, + latest_execution_block_hash, + latest_gas_limit, + builder_params, + withdrawals, + parent_beacon_block_root, + ) + .await + } + .instrument(debug_span!("prepare_execution_payload")), + "prepare_execution_payload", + ) + .ok_or(BlockProductionError::ShuttingDown)?; + + Ok(join_handle) +} + +/// Prepares an execution payload for inclusion in a block. +/// +/// ## Errors +/// +/// Will return an error when using a pre-Gloas fork `state`. Ensure to only run this function +/// after the Gloas fork. +/// +/// ## Specification +/// +/// Equivalent to the `prepare_execution_payload` function in the Validator Guide: +/// +/// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md#block-proposal +#[allow(clippy::too_many_arguments)] +async fn prepare_execution_payload( + chain: &Arc>, + timestamp: u64, + random: Hash256, + proposer_index: u64, + parent_block_hash: ExecutionBlockHash, + parent_gas_limit: u64, + builder_params: BuilderParams, + withdrawals: Vec, + parent_beacon_block_root: Hash256, +) -> Result, BlockProductionError> +where + T: BeaconChainTypes, +{ + let spec = &chain.spec; + let fork = spec.fork_name_at_slot::(builder_params.slot); + let execution_layer = chain + .execution_layer + .as_ref() + .ok_or(BlockProductionError::ExecutionLayerMissing)?; + + // Try to obtain the fork choice update parameters from the cached head. + // + // Use a blocking task to interact with the `canonical_head` lock otherwise we risk blocking the + // core `tokio` executor. + let inner_chain = chain.clone(); + let forkchoice_update_params = chain + .spawn_blocking_handle( + move || { + inner_chain + .canonical_head + .cached_head() + .forkchoice_update_parameters() + }, + "prepare_execution_payload_forkchoice_update_params", + ) + .instrument(debug_span!("forkchoice_update_params")) + .await + .map_err(|e| BlockProductionError::BeaconChain(Box::new(e)))?; + + let suggested_fee_recipient = execution_layer + .get_suggested_fee_recipient(proposer_index) + .await; + let payload_attributes = PayloadAttributes::new( + timestamp, + random, + suggested_fee_recipient, + Some(withdrawals), + Some(parent_beacon_block_root), + ); + + let target_gas_limit = execution_layer.get_proposer_gas_limit(proposer_index).await; + let payload_parameters = PayloadParameters { + parent_hash: parent_block_hash, + parent_gas_limit, + proposer_gas_limit: target_gas_limit, + payload_attributes: &payload_attributes, + forkchoice_update_params: &forkchoice_update_params, + current_fork: fork, + }; + + let block_contents = execution_layer + .get_payload_gloas(payload_parameters) + .await + .map_err(BlockProductionError::GetPayloadFailed)?; + + Ok(block_contents) +} diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs new file mode 100644 index 0000000000..76c8b77e93 --- /dev/null +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -0,0 +1,223 @@ +use std::{sync::Arc, time::Duration}; + +use proto_array::ProposerHeadError; +use slot_clock::SlotClock; +use tracing::{debug, error, info, instrument, warn}; +use types::{BeaconState, Hash256, Slot}; + +use crate::{ + BeaconChain, BeaconChainTypes, BlockProductionError, StateSkipConfig, + fork_choice_signal::ForkChoiceWaitResult, metrics, +}; + +mod gloas; + +impl BeaconChain { + /// Load a beacon state from the database for block production. This is a long-running process + /// that should not be performed in an `async` context. + pub(crate) fn load_state_for_block_production( + self: &Arc, + slot: Slot, + ) -> Result<(BeaconState, Option), BlockProductionError> { + let fork_choice_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_FORK_CHOICE_TIMES); + self.wait_for_fork_choice_before_block_production(slot)?; + drop(fork_choice_timer); + + let state_load_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_STATE_LOAD_TIMES); + + // Atomically read some values from the head whilst avoiding holding cached head `Arc` any + // longer than necessary. + let (head_slot, head_block_root, head_state_root) = { + let head = self.canonical_head.cached_head(); + ( + head.head_slot(), + head.head_block_root(), + head.head_state_root(), + ) + }; + let (state, state_root_opt) = if head_slot < slot { + // Attempt an aggressive re-org if configured and the conditions are right. + if let Some((re_org_state, re_org_state_root)) = + self.get_state_for_re_org(slot, head_slot, head_block_root) + { + info!( + %slot, + head_to_reorg = %head_block_root, + "Proposing block to re-org current head" + ); + (re_org_state, Some(re_org_state_root)) + } else { + // Fetch the head state advanced through to `slot`, which should be present in the + // state cache thanks to the state advance timer. + let (state_root, state) = self + .store + .get_advanced_hot_state(head_block_root, slot, head_state_root) + .map_err(BlockProductionError::FailedToLoadState)? + .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; + (state, Some(state_root)) + } + } else { + warn!( + message = "this block is more likely to be orphaned", + %slot, + "Producing block that conflicts with head" + ); + let state = self + .state_at_slot(slot - 1, StateSkipConfig::WithStateRoots) + .map_err(|_| BlockProductionError::UnableToProduceAtSlot(slot))?; + + (state, None) + }; + + drop(state_load_timer); + + Ok((state, state_root_opt)) + } + + /// If configured, wait for the fork choice run at the start of the slot to complete. + #[instrument(level = "debug", skip_all)] + fn wait_for_fork_choice_before_block_production( + self: &Arc, + slot: Slot, + ) -> Result<(), BlockProductionError> { + if let Some(rx) = &self.fork_choice_signal_rx { + let current_slot = self + .slot() + .map_err(|_| BlockProductionError::UnableToReadSlot)?; + + let timeout = Duration::from_millis(self.config.fork_choice_before_proposal_timeout_ms); + + if slot == current_slot || slot == current_slot + 1 { + match rx.wait_for_fork_choice(slot, timeout) { + ForkChoiceWaitResult::Success(fc_slot) => { + debug!( + %slot, + fork_choice_slot = %fc_slot, + "Fork choice successfully updated before block production" + ); + } + ForkChoiceWaitResult::Behind(fc_slot) => { + warn!( + fork_choice_slot = %fc_slot, + %slot, + message = "this block may be orphaned", + "Fork choice notifier out of sync with block production" + ); + } + ForkChoiceWaitResult::TimeOut => { + warn!( + message = "this block may be orphaned", + "Timed out waiting for fork choice before proposal" + ); + } + } + } else { + error!( + %slot, + %current_slot, + message = "check clock sync, this block may be orphaned", + "Producing block at incorrect slot" + ); + } + } + Ok(()) + } + + /// Fetch the beacon state to use for producing a block if a 1-slot proposer re-org is viable. + /// + /// This function will return `None` if proposer re-orgs are disabled. + #[instrument(skip_all, level = "debug")] + fn get_state_for_re_org( + &self, + slot: Slot, + head_slot: Slot, + canonical_head: Hash256, + ) -> Option<(BeaconState, Hash256)> { + let re_org_head_threshold = self.config.re_org_head_threshold?; + let re_org_parent_threshold = self.config.re_org_parent_threshold?; + + if self.spec.proposer_score_boost.is_none() { + warn!( + reason = "this network does not have proposer boost enabled", + "Ignoring proposer re-org configuration" + ); + return None; + } + + let slot_delay = self + .slot_clock + .seconds_from_current_slot_start() + .or_else(|| { + warn!(error = "unable to read slot clock", "Not attempting re-org"); + None + })?; + + // Attempt a proposer re-org if: + // + // 1. It seems we have time to propagate and still receive the proposer boost. + // 2. The current head block was seen late. + // 3. The `get_proposer_head` conditions from fork choice pass. + let proposing_on_time = + slot_delay < self.config.re_org_cutoff(self.spec.get_slot_duration()); + if !proposing_on_time { + debug!(reason = "not proposing on time", "Not attempting re-org"); + return None; + } + + let head_late = self.block_observed_after_attestation_deadline(canonical_head, head_slot); + if !head_late { + debug!(reason = "head not late", "Not attempting re-org"); + return None; + } + + // Is the current head weak and appropriate for re-orging? + let proposer_head_timer = + metrics::start_timer(&metrics::BLOCK_PRODUCTION_GET_PROPOSER_HEAD_TIMES); + let proposer_head = self + .canonical_head + .fork_choice_read_lock() + .get_proposer_head( + slot, + canonical_head, + re_org_head_threshold, + re_org_parent_threshold, + &self.config.re_org_disallowed_offsets, + self.config.re_org_max_epochs_since_finalization, + ) + .map_err(|e| match e { + ProposerHeadError::DoNotReOrg(reason) => { + debug!( + %reason, + "Not attempting re-org" + ); + } + ProposerHeadError::Error(e) => { + warn!( + error = ?e, + "Not attempting re-org" + ); + } + }) + .ok()?; + drop(proposer_head_timer); + let re_org_parent_block = proposer_head.parent_node.root; + + let (state_root, state) = self + .store + .get_advanced_hot_state_from_cache(re_org_parent_block, slot) + .or_else(|| { + warn!(reason = "no state in cache", "Not attempting re-org"); + None + })?; + + info!( + weak_head = ?canonical_head, + parent = ?re_org_parent_block, + head_weight = proposer_head.head_node.weight, + threshold_weight = proposer_head.re_org_head_weight_threshold, + "Attempting re-org due to weak head" + ); + + Some((state, state_root)) + } +} diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index f673519f5f..4c82c93ba3 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -1036,6 +1036,7 @@ where observed_column_sidecars: RwLock::new(ObservedDataSidecars::new(self.spec.clone())), observed_blob_sidecars: RwLock::new(ObservedDataSidecars::new(self.spec.clone())), observed_slashable: <_>::default(), + pending_payload_envelopes: <_>::default(), observed_voluntary_exits: <_>::default(), observed_proposer_slashings: <_>::default(), observed_attester_slashings: <_>::default(), diff --git a/beacon_node/beacon_chain/src/errors.rs b/beacon_node/beacon_chain/src/errors.rs index 816e75fd24..bcccc0ec12 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -319,7 +319,7 @@ pub enum BlockProductionError { MissingExecutionRequests, SszTypesError(ssz_types::Error), // TODO(gloas): Remove this once Gloas is implemented - GloasNotImplemented, + GloasNotImplemented(String), } easy_from_to!(BlockProcessingError, BlockProductionError); diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index e77739e2d5..3b03395a66 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -9,6 +9,7 @@ pub mod beacon_proposer_cache; mod beacon_snapshot; pub mod bellatrix_readiness; pub mod blob_verification; +mod block_production; pub mod block_reward; mod block_times_cache; mod block_verification; @@ -42,6 +43,7 @@ pub mod observed_block_producers; pub mod observed_data_sidecars; pub mod observed_operations; mod observed_slashable; +pub mod pending_payload_envelopes; pub mod persisted_beacon_chain; pub mod persisted_custody; mod persisted_fork_choice; diff --git a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs new file mode 100644 index 0000000000..336ab5323f --- /dev/null +++ b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs @@ -0,0 +1,151 @@ +//! Provides the `PendingPayloadEnvelopes` cache for storing execution payload envelopes +//! that have been produced during local block production. +//! +//! For local building, the envelope is created during block production. +//! This cache holds the envelopes temporarily until the validator fetches, signs, +//! and publishes the payload. + +use std::collections::HashMap; +use types::{EthSpec, ExecutionPayloadEnvelope, Slot}; + +/// Cache for pending execution payload envelopes awaiting publishing. +/// +/// Envelopes are keyed by slot and pruned based on slot age. +/// This cache is only used for local building. +pub struct PendingPayloadEnvelopes { + /// Maximum number of slots to keep envelopes before pruning. + max_slot_age: u64, + /// The envelopes, keyed by slot. + envelopes: HashMap>, +} + +impl Default for PendingPayloadEnvelopes { + fn default() -> Self { + Self::new(Self::DEFAULT_MAX_SLOT_AGE) + } +} + +impl PendingPayloadEnvelopes { + /// Default maximum slot age before pruning (2 slots). + pub const DEFAULT_MAX_SLOT_AGE: u64 = 2; + + /// Create a new cache with the specified maximum slot age. + pub fn new(max_slot_age: u64) -> Self { + Self { + max_slot_age, + envelopes: HashMap::new(), + } + } + + /// Insert a pending envelope into the cache. + pub fn insert(&mut self, slot: Slot, envelope: ExecutionPayloadEnvelope) { + // TODO(gloas): we may want to check for duplicates here, which shouldn't be allowed + self.envelopes.insert(slot, envelope); + } + + /// Get a pending envelope by slot. + pub fn get(&self, slot: Slot) -> Option<&ExecutionPayloadEnvelope> { + self.envelopes.get(&slot) + } + + /// Remove and return a pending envelope by slot. + pub fn remove(&mut self, slot: Slot) -> Option> { + self.envelopes.remove(&slot) + } + + /// Check if an envelope exists for the given slot. + pub fn contains(&self, slot: Slot) -> bool { + self.envelopes.contains_key(&slot) + } + + /// Prune envelopes older than `current_slot - max_slot_age`. + /// + /// This removes stale envelopes from blocks that were never published. + // TODO(gloas) implement pruning + pub fn prune(&mut self, current_slot: Slot) { + let min_slot = current_slot.saturating_sub(self.max_slot_age); + self.envelopes.retain(|slot, _| *slot >= min_slot); + } + + /// Returns the number of pending envelopes in the cache. + pub fn len(&self) -> usize { + self.envelopes.len() + } + + /// Returns true if the cache is empty. + pub fn is_empty(&self) -> bool { + self.envelopes.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use types::{ExecutionPayloadGloas, ExecutionRequests, Hash256, MainnetEthSpec}; + + type E = MainnetEthSpec; + + fn make_envelope(slot: Slot) -> ExecutionPayloadEnvelope { + ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequests::default(), + builder_index: 0, + beacon_block_root: Hash256::ZERO, + slot, + state_root: Hash256::ZERO, + } + } + + #[test] + fn insert_and_get() { + let mut cache = PendingPayloadEnvelopes::::default(); + let slot = Slot::new(1); + let envelope = make_envelope(slot); + + assert!(!cache.contains(slot)); + assert_eq!(cache.len(), 0); + + cache.insert(slot, envelope.clone()); + + assert!(cache.contains(slot)); + assert_eq!(cache.len(), 1); + assert_eq!(cache.get(slot), Some(&envelope)); + } + + #[test] + fn remove() { + let mut cache = PendingPayloadEnvelopes::::default(); + let slot = Slot::new(1); + let envelope = make_envelope(slot); + + cache.insert(slot, envelope.clone()); + assert!(cache.contains(slot)); + + let removed = cache.remove(slot); + assert_eq!(removed, Some(envelope)); + assert!(!cache.contains(slot)); + assert_eq!(cache.len(), 0); + } + + #[test] + fn prune_old_envelopes() { + let mut cache = PendingPayloadEnvelopes::::new(2); + + // Insert envelope at slot 5 + let slot_1 = Slot::new(5); + cache.insert(slot_1, make_envelope(slot_1)); + + // Insert envelope at slot 10 + let slot_2 = Slot::new(10); + cache.insert(slot_2, make_envelope(slot_2)); + + assert_eq!(cache.len(), 2); + + // Prune at slot 10 with max_slot_age=2, should keep slots >= 8 + cache.prune(Slot::new(10)); + + assert_eq!(cache.len(), 1); + assert!(!cache.contains(slot_1)); // slot 5 < 8, pruned + assert!(cache.contains(slot_2)); // slot 10 >= 8, kept + } +} diff --git a/beacon_node/client/src/notifier.rs b/beacon_node/client/src/notifier.rs index 3f01622c35..21a5abeb6c 100644 --- a/beacon_node/client/src/notifier.rs +++ b/beacon_node/client/src/notifier.rs @@ -431,6 +431,16 @@ async fn bellatrix_readiness_logging( current_slot: Slot, beacon_chain: &BeaconChain, ) { + // There is no execution payload in gloas blocks, so this will trigger + // bellatrix readiness logging in gloas if we dont skip the check below + if beacon_chain + .spec + .fork_name_at_slot::(current_slot) + .gloas_enabled() + { + return; + } + let merge_completed = beacon_chain .canonical_head .cached_head() diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 33b83aab09..ad2486a4ad 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -48,7 +48,6 @@ use tree_hash::TreeHash; use types::builder::BuilderBid; use types::execution::BlockProductionVersion; use types::kzg_ext::KzgCommitments; -use types::new_non_zero_usize; use types::{ AbstractExecPayload, BlobsList, ExecutionPayloadDeneb, ExecutionRequests, KzgProofs, SignedBlindedBeaconBlock, @@ -58,6 +57,7 @@ use types::{ ExecutionPayloadCapella, ExecutionPayloadElectra, ExecutionPayloadFulu, FullPayload, ProposerPreparationData, Slot, }; +use types::{ExecutionPayloadGloas, new_non_zero_usize}; mod block_hash; mod engine_api; @@ -168,6 +168,7 @@ pub enum Error { BeaconStateError(BeaconStateError), PayloadTypeMismatch, VerifyingVersionedHashes(versioned_hashes::Error), + Unexpected(String), } impl From for Error { @@ -204,6 +205,26 @@ pub enum BlockProposalContentsType { Blinded(BlockProposalContents>), } +pub struct BlockProposalContentsGloas { + pub payload: ExecutionPayloadGloas, + pub payload_value: Uint256, + pub blob_kzg_commitments: KzgCommitments, + pub blobs_and_proofs: (BlobsList, KzgProofs), + pub execution_requests: ExecutionRequests, +} + +impl From> for BlockProposalContentsGloas { + fn from(response: GetPayloadResponseGloas) -> Self { + Self { + payload: response.execution_payload, + payload_value: response.block_value, + blob_kzg_commitments: response.blobs_bundle.commitments, + blobs_and_proofs: (response.blobs_bundle.blobs, response.blobs_bundle.proofs), + execution_requests: response.requests, + } + } +} + pub enum BlockProposalContents> { Payload { payload: Payload, @@ -884,6 +905,43 @@ impl ExecutionLayer { .and_then(|entry| entry.gas_limit) } + /// Maps to the `engine_getPayload` JSON-RPC call for post-Gloas payload construction. + /// + /// However, it will attempt to call `self.prepare_payload` if it cannot find an existing + /// payload id for the given parameters. + /// + /// ## Fallback Behavior + /// + /// The result will be returned from the first node that returns successfully. No more nodes + /// will be contacted. + pub async fn get_payload_gloas( + &self, + payload_parameters: PayloadParameters<'_>, + ) -> Result, Error> { + let payload_response_type = self.get_full_payload_caching(payload_parameters).await?; + let GetPayloadResponseType::Full(payload_response) = payload_response_type else { + return Err(Error::Unexpected( + "get_payload_gloas should never return a blinded payload".to_owned(), + )); + }; + let GetPayloadResponse::Gloas(payload_response) = payload_response else { + return Err(Error::Unexpected( + "get_payload_gloas should always return a gloas `GetPayloadResponse` variant" + .to_owned(), + )); + }; + metrics::inc_counter_vec( + &metrics::EXECUTION_LAYER_GET_PAYLOAD_OUTCOME, + &[metrics::SUCCESS], + ); + metrics::inc_counter_vec( + &metrics::EXECUTION_LAYER_GET_PAYLOAD_SOURCE, + &[metrics::LOCAL], + ); + + Ok(payload_response.into()) + } + /// Maps to the `engine_getPayload` JSON-RPC call. /// /// However, it will attempt to call `self.prepare_payload` if it cannot find an existing diff --git a/beacon_node/execution_layer/src/test_utils/mock_builder.rs b/beacon_node/execution_layer/src/test_utils/mock_builder.rs index 464879288b..7b6c4e8310 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_builder.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_builder.rs @@ -245,7 +245,7 @@ impl BidStuff for BuilderBid { } fn sign_builder_message(&mut self, sk: &SecretKey, spec: &ChainSpec) -> Signature { - let domain = spec.get_builder_domain(); + let domain = spec.get_builder_application_domain(); let message = self.signing_root(domain); sk.sign(message) } diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs new file mode 100644 index 0000000000..81f2ea41ea --- /dev/null +++ b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs @@ -0,0 +1,116 @@ +use crate::task_spawner::{Priority, TaskSpawner}; +use crate::utils::{ChainFilter, EthV1Filter, NetworkTxFilter, ResponseFilter, TaskSpawnerFilter}; +use beacon_chain::{BeaconChain, BeaconChainTypes}; +use bytes::Bytes; +use eth2::{CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; +use lighthouse_network::PubsubMessage; +use network::NetworkMessage; +use ssz::Decode; +use std::sync::Arc; +use tokio::sync::mpsc::UnboundedSender; +use tracing::{info, warn}; +use types::SignedExecutionPayloadEnvelope; +use warp::{Filter, Rejection, Reply, reply::Response}; + +// POST beacon/execution_payload_envelope (SSZ) +pub(crate) fn post_beacon_execution_payload_envelope_ssz( + eth_v1: EthV1Filter, + task_spawner_filter: TaskSpawnerFilter, + chain_filter: ChainFilter, + network_tx_filter: NetworkTxFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("beacon")) + .and(warp::path("execution_payload_envelope")) + .and(warp::path::end()) + .and(warp::header::exact( + CONTENT_TYPE_HEADER, + SSZ_CONTENT_TYPE_HEADER, + )) + .and(warp::body::bytes()) + .and(task_spawner_filter) + .and(chain_filter) + .and(network_tx_filter) + .then( + |body_bytes: Bytes, + task_spawner: TaskSpawner, + chain: Arc>, + network_tx: UnboundedSender>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + let envelope = + SignedExecutionPayloadEnvelope::::from_ssz_bytes(&body_bytes) + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })?; + publish_execution_payload_envelope(envelope, chain, &network_tx).await + }) + }, + ) + .boxed() +} + +// POST beacon/execution_payload_envelope +pub(crate) fn post_beacon_execution_payload_envelope( + eth_v1: EthV1Filter, + task_spawner_filter: TaskSpawnerFilter, + chain_filter: ChainFilter, + network_tx_filter: NetworkTxFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("beacon")) + .and(warp::path("execution_payload_envelope")) + .and(warp::path::end()) + .and(warp::body::json()) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .and(network_tx_filter.clone()) + .then( + |envelope: SignedExecutionPayloadEnvelope, + task_spawner: TaskSpawner, + chain: Arc>, + network_tx: UnboundedSender>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + publish_execution_payload_envelope(envelope, chain, &network_tx).await + }) + }, + ) + .boxed() +} +/// Publishes a signed execution payload envelope to the network. +pub async fn publish_execution_payload_envelope( + envelope: SignedExecutionPayloadEnvelope, + chain: Arc>, + network_tx: &UnboundedSender>, +) -> Result { + let slot = envelope.message.slot; + let beacon_block_root = envelope.message.beacon_block_root; + + // TODO(gloas): Replace this check once we have gossip validation. + if !chain.spec.is_gloas_scheduled() { + return Err(warp_utils::reject::custom_bad_request( + "Execution payload envelopes are not supported before the Gloas fork".into(), + )); + } + + // TODO(gloas): We should probably add validation here i.e. BroadcastValidation::Gossip + info!( + %slot, + %beacon_block_root, + builder_index = envelope.message.builder_index, + "Publishing signed execution payload envelope to network" + ); + + // Publish to the network + crate::utils::publish_pubsub_message( + network_tx, + PubsubMessage::ExecutionPayload(Box::new(envelope)), + ) + .map_err(|_| { + warn!(%slot, "Failed to publish execution payload envelope to network"); + warp_utils::reject::custom_server_error( + "Unable to publish execution payload envelope to network".into(), + ) + })?; + + Ok(warp::reply().into_response()) +} diff --git a/beacon_node/http_api/src/beacon/mod.rs b/beacon_node/http_api/src/beacon/mod.rs index df5e6eee5c..9ec1c476f6 100644 --- a/beacon_node/http_api/src/beacon/mod.rs +++ b/beacon_node/http_api/src/beacon/mod.rs @@ -1,2 +1,3 @@ +pub mod execution_payload_envelope; pub mod pool; pub mod states; diff --git a/beacon_node/http_api/src/beacon/states.rs b/beacon_node/http_api/src/beacon/states.rs index 828efb86a7..50be7211d8 100644 --- a/beacon_node/http_api/src/beacon/states.rs +++ b/beacon_node/http_api/src/beacon/states.rs @@ -28,7 +28,6 @@ pub fn get_beacon_state_pending_consolidations( beacon_states_path: BeaconStatesPath, ) -> ResponseFilter { beacon_states_path - .clone() .and(warp::path("pending_consolidations")) .and(warp::path::end()) .then( diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 6824eab4fd..92a1ad934d 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -36,6 +36,9 @@ mod validator_inclusion; mod validators; mod version; +use crate::beacon::execution_payload_envelope::{ + post_beacon_execution_payload_envelope, post_beacon_execution_payload_envelope_ssz, +}; use crate::beacon::pool::*; use crate::light_client::{get_light_client_bootstrap, get_light_client_updates}; use crate::utils::{AnyVersionFilter, EthV1Filter}; @@ -92,6 +95,7 @@ use types::{ BeaconStateError, Checkpoint, ConfigAndPreset, Epoch, EthSpec, ForkName, Hash256, SignedBlindedBeaconBlock, Slot, }; +use validator::execution_payload_envelope::get_validator_execution_payload_envelope; use version::{ ResponseIncludesVersion, V1, V2, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, inconsistent_fork_rejection, @@ -1486,6 +1490,22 @@ pub fn serve( let post_beacon_pool_bls_to_execution_changes = post_beacon_pool_bls_to_execution_changes(&network_tx_filter, &beacon_pool_path); + // POST beacon/execution_payload_envelope + let post_beacon_execution_payload_envelope = post_beacon_execution_payload_envelope( + eth_v1.clone(), + task_spawner_filter.clone(), + chain_filter.clone(), + network_tx_filter.clone(), + ); + + // POST beacon/execution_payload_envelope (SSZ) + let post_beacon_execution_payload_envelope_ssz = post_beacon_execution_payload_envelope_ssz( + eth_v1.clone(), + task_spawner_filter.clone(), + chain_filter.clone(), + network_tx_filter.clone(), + ); + let beacon_rewards_path = eth_v1 .clone() .and(warp::path("beacon")) @@ -2444,7 +2464,7 @@ pub fn serve( // GET validator/duties/proposer/{epoch} let get_validator_duties_proposer = get_validator_duties_proposer( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2452,7 +2472,7 @@ pub fn serve( // GET validator/blocks/{slot} let get_validator_blocks = get_validator_blocks( - any_version.clone().clone(), + any_version.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2460,7 +2480,15 @@ pub fn serve( // GET validator/blinded_blocks/{slot} let get_validator_blinded_blocks = get_validator_blinded_blocks( - eth_v1.clone().clone(), + eth_v1.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + + // GET validator/execution_payload_envelope/{slot}/{builder_index} + let get_validator_execution_payload_envelope = get_validator_execution_payload_envelope( + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2468,7 +2496,7 @@ pub fn serve( // GET validator/attestation_data?slot,committee_index let get_validator_attestation_data = get_validator_attestation_data( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2476,7 +2504,7 @@ pub fn serve( // GET validator/aggregate_attestation?attestation_data_root,slot let get_validator_aggregate_attestation = get_validator_aggregate_attestation( - any_version.clone().clone(), + any_version.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2484,7 +2512,7 @@ pub fn serve( // POST validator/duties/attester/{epoch} let post_validator_duties_attester = post_validator_duties_attester( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2492,7 +2520,7 @@ pub fn serve( // POST validator/duties/sync/{epoch} let post_validator_duties_sync = post_validator_duties_sync( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2500,7 +2528,7 @@ pub fn serve( // GET validator/sync_committee_contribution let get_validator_sync_committee_contribution = get_validator_sync_committee_contribution( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), @@ -2508,7 +2536,7 @@ pub fn serve( // POST validator/aggregate_and_proofs let post_validator_aggregate_and_proofs = post_validator_aggregate_and_proofs( - any_version.clone().clone(), + any_version.clone(), chain_filter.clone(), network_tx_filter.clone(), not_while_syncing_filter.clone(), @@ -2516,7 +2544,7 @@ pub fn serve( ); let post_validator_contribution_and_proofs = post_validator_contribution_and_proofs( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), network_tx_filter.clone(), not_while_syncing_filter.clone(), @@ -2526,7 +2554,7 @@ pub fn serve( // POST validator/beacon_committee_subscriptions let post_validator_beacon_committee_subscriptions = post_validator_beacon_committee_subscriptions( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), validator_subscription_tx_filter.clone(), task_spawner_filter.clone(), @@ -2534,7 +2562,7 @@ pub fn serve( // POST validator/prepare_beacon_proposer let post_validator_prepare_beacon_proposer = post_validator_prepare_beacon_proposer( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), network_tx_filter.clone(), not_while_syncing_filter.clone(), @@ -2543,13 +2571,13 @@ pub fn serve( // POST validator/register_validator let post_validator_register_validator = post_validator_register_validator( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), task_spawner_filter.clone(), ); // POST validator/sync_committee_subscriptions let post_validator_sync_committee_subscriptions = post_validator_sync_committee_subscriptions( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), validator_subscription_tx_filter.clone(), task_spawner_filter.clone(), @@ -2557,7 +2585,7 @@ pub fn serve( // POST validator/liveness/{epoch} let post_validator_liveness_epoch = post_validator_liveness_epoch( - eth_v1.clone().clone(), + eth_v1.clone(), chain_filter.clone(), task_spawner_filter.clone(), ); @@ -3336,6 +3364,7 @@ pub fn serve( .uor(get_validator_duties_proposer) .uor(get_validator_blocks) .uor(get_validator_blinded_blocks) + .uor(get_validator_execution_payload_envelope) .uor(get_validator_attestation_data) .uor(get_validator_aggregate_attestation) .uor(get_validator_sync_committee_contribution) @@ -3374,7 +3403,8 @@ pub fn serve( post_beacon_blocks_ssz .uor(post_beacon_blocks_v2_ssz) .uor(post_beacon_blinded_blocks_ssz) - .uor(post_beacon_blinded_blocks_v2_ssz), + .uor(post_beacon_blinded_blocks_v2_ssz) + .uor(post_beacon_execution_payload_envelope_ssz), ) .uor(post_beacon_blocks) .uor(post_beacon_blinded_blocks) @@ -3386,6 +3416,7 @@ pub fn serve( .uor(post_beacon_pool_voluntary_exits) .uor(post_beacon_pool_sync_committees) .uor(post_beacon_pool_bls_to_execution_changes) + .uor(post_beacon_execution_payload_envelope) .uor(post_beacon_state_validators) .uor(post_beacon_state_validator_balances) .uor(post_beacon_state_validator_identities) diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 6a549c91ef..607221686f 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -10,8 +10,8 @@ use beacon_chain::graffiti_calculator::GraffitiSettings; use beacon_chain::{ BeaconBlockResponseWrapper, BeaconChain, BeaconChainTypes, ProduceBlockVerification, }; -use eth2::beacon_response::ForkVersionedResponse; use eth2::types::{self as api_types, ProduceBlockV3Metadata, SkipRandaoVerification}; +use eth2::{beacon_response::ForkVersionedResponse, types::ProduceBlockV4Metadata}; use ssz::Encode; use std::sync::Arc; use tracing::instrument; @@ -43,6 +43,49 @@ pub fn get_randao_verification( Ok(randao_verification) } +#[instrument( + name = "lh_produce_block_v4", + skip_all, + fields(%slot) +)] +pub async fn produce_block_v4( + accept_header: Option, + chain: Arc>, + slot: Slot, + query: api_types::ValidatorBlocksQuery, +) -> Result, warp::Rejection> { + let randao_reveal = query.randao_reveal.decompress().map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "randao reveal is not a valid BLS signature: {:?}", + e + )) + })?; + + let randao_verification = get_randao_verification(&query, randao_reveal.is_infinity())?; + let builder_boost_factor = if query.builder_boost_factor == Some(DEFAULT_BOOST_FACTOR) { + None + } else { + query.builder_boost_factor + }; + + let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); + + let (block, consensus_block_value) = chain + .produce_block_with_verification_gloas( + randao_reveal, + slot, + graffiti_settings, + randao_verification, + builder_boost_factor, + ) + .await + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!("failed to fetch a block: {:?}", e)) + })?; + + build_response_v4::(block, consensus_block_value, accept_header, &chain.spec) +} + #[instrument( name = "lh_produce_block_v3", skip_all, @@ -87,6 +130,45 @@ pub async fn produce_block_v3( build_response_v3(chain, block_response_type, accept_header) } +pub fn build_response_v4( + block: BeaconBlock>, + consensus_block_value: u64, + accept_header: Option, + spec: &ChainSpec, +) -> Result, warp::Rejection> { + let fork_name = block + .to_ref() + .fork_name(spec) + .map_err(inconsistent_fork_rejection)?; + let consensus_block_value_wei = + Uint256::from(consensus_block_value) * Uint256::from(1_000_000_000u64); + + let metadata = ProduceBlockV4Metadata { + consensus_version: fork_name, + consensus_block_value: consensus_block_value_wei, + }; + + match accept_header { + Some(api_types::Accept::Ssz) => Response::builder() + .status(200) + .body(block.as_ssz_bytes().into()) + .map(|res: Response| add_ssz_content_type_header(res)) + .map(|res: Response| add_consensus_version_header(res, fork_name)) + .map(|res| add_consensus_block_value_header(res, consensus_block_value_wei)) + .map_err(|e| -> warp::Rejection { + warp_utils::reject::custom_server_error(format!("failed to create response: {}", e)) + }), + _ => Ok(warp::reply::json(&ForkVersionedResponse { + version: fork_name, + metadata, + data: block, + }) + .into_response()) + .map(|res| add_consensus_version_header(res, fork_name)) + .map(|res| add_consensus_block_value_header(res, consensus_block_value_wei)), + } +} + pub fn build_response_v3( chain: Arc>, block_response: BeaconBlockResponseWrapper, diff --git a/beacon_node/http_api/src/validator/execution_payload_envelope.rs b/beacon_node/http_api/src/validator/execution_payload_envelope.rs new file mode 100644 index 0000000000..c40b375e49 --- /dev/null +++ b/beacon_node/http_api/src/validator/execution_payload_envelope.rs @@ -0,0 +1,110 @@ +use crate::task_spawner::{Priority, TaskSpawner}; +use crate::utils::{ + ChainFilter, EthV1Filter, NotWhileSyncingFilter, ResponseFilter, TaskSpawnerFilter, +}; +use beacon_chain::{BeaconChain, BeaconChainTypes}; +use eth2::beacon_response::{EmptyMetadata, ForkVersionedResponse}; +use eth2::types::Accept; +use ssz::Encode; +use std::sync::Arc; +use tracing::debug; +use types::Slot; +use warp::http::Response; +use warp::{Filter, Rejection}; + +// GET validator/execution_payload_envelope/{slot}/{builder_index} +pub fn get_validator_execution_payload_envelope( + eth_v1: EthV1Filter, + chain_filter: ChainFilter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("execution_payload_envelope")) + .and(warp::path::param::().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid slot".to_string(), + )) + })) + .and(warp::path::param::().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid builder_index".to_string(), + )) + })) + .and(warp::path::end()) + .and(warp::header::optional::("accept")) + .and(not_while_syncing_filter) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |slot: Slot, + // TODO(gloas) we're only doing local building + // we'll need to implement builder index logic + // eventually. + _builder_index: u64, + accept_header: Option, + not_synced_filter: Result<(), Rejection>, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.spawn_async_with_rejection(Priority::P0, async move { + debug!(?slot, "Execution payload envelope request from HTTP API"); + + not_synced_filter?; + + // Get the envelope from the pending cache (local building only) + let envelope = chain + .pending_payload_envelopes + .read() + .get(slot) + .cloned() + .ok_or_else(|| { + warp_utils::reject::custom_not_found(format!( + "Execution payload envelope not available for slot {slot}" + )) + })?; + + let fork_name = chain.spec.fork_name_at_slot::(slot); + + match accept_header { + Some(Accept::Ssz) => Response::builder() + .status(200) + .header("Content-Type", "application/octet-stream") + .header("Eth-Consensus-Version", fork_name.to_string()) + .body(envelope.as_ssz_bytes().into()) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to build SSZ response: {e}" + )) + }), + _ => { + let json_response = ForkVersionedResponse { + version: fork_name, + metadata: EmptyMetadata {}, + data: envelope, + }; + Response::builder() + .status(200) + .header("Content-Type", "application/json") + .header("Eth-Consensus-Version", fork_name.to_string()) + .body( + serde_json::to_string(&json_response) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to serialize response: {e}" + )) + })? + .into(), + ) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to build JSON response: {e}" + )) + }) + } + } + }) + }, + ) + .boxed() +} diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 0704c52095..df237d9f9b 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -1,4 +1,6 @@ -use crate::produce_block::{produce_blinded_block_v2, produce_block_v2, produce_block_v3}; +use crate::produce_block::{ + produce_blinded_block_v2, produce_block_v2, produce_block_v3, produce_block_v4, +}; use crate::task_spawner::{Priority, TaskSpawner}; use crate::utils::{ AnyVersionFilter, ChainFilter, EthV1Filter, NetworkTxFilter, NotWhileSyncingFilter, @@ -31,6 +33,8 @@ use types::{ use warp::{Filter, Rejection, Reply}; use warp_utils::reject::convert_rejection; +pub mod execution_payload_envelope; + /// Uses the `chain.validator_pubkey_cache` to resolve a pubkey to a validator /// index and then ensures that the validator exists in the given `state`. pub fn pubkey_to_validator_index( @@ -316,7 +320,11 @@ pub fn get_validator_blocks( not_synced_filter?; - if endpoint_version == V3 { + // Use V4 block production for Gloas fork + let fork_name = chain.spec.fork_name_at_slot::(slot); + if fork_name.gloas_enabled() { + produce_block_v4(accept_header, chain, slot, query).await + } else if endpoint_version == V3 { produce_block_v3(accept_header, chain, slot, query).await } else { produce_block_v2(accept_header, chain, slot, query).await @@ -662,15 +670,26 @@ pub fn post_validator_prepare_beacon_proposer( ) .await; - chain - .prepare_beacon_proposer(current_slot) - .await - .map_err(|e| { - warp_utils::reject::custom_bad_request(format!( - "error updating proposer preparations: {:?}", - e - )) - })?; + // TODO(gloas): verify this is correct. We skip proposer preparation for + // GLOAS because the execution payload is no longer embedded in the beacon + // block (it's in the payload envelope), so the head block's + // execution_payload() is unavailable. + let next_slot = current_slot + 1; + if !chain + .spec + .fork_name_at_slot::(next_slot) + .gloas_enabled() + { + chain + .prepare_beacon_proposer(current_slot) + .await + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "error updating proposer preparations: {:?}", + e + )) + })?; + } if chain.spec.is_peer_das_scheduled() { let (finalized_beacon_state, _, _) = diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 6787d1ab9e..7e3eb8b980 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -6,14 +6,15 @@ use beacon_chain::{ AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, test_spec, }, }; -use bls::{AggregateSignature, Keypair, PublicKeyBytes, Signature, SignatureBytes}; +use bls::{AggregateSignature, Keypair, PublicKeyBytes, SecretKey, Signature, SignatureBytes}; use eth2::{ BeaconNodeHttpClient, Error, Error::ServerMessage, Timeouts, mixin::{RequestAccept, ResponseForkName, ResponseOptional}, types::{ - BlockId as CoreBlockId, ForkChoiceNode, ProduceBlockV3Response, StateId as CoreStateId, *, + BlockId as CoreBlockId, ForkChoiceNode, ProduceBlockV3Response, ProduceBlockV4Metadata, + StateId as CoreStateId, *, }, }; use execution_layer::expected_gas_limit; @@ -47,7 +48,8 @@ use tree_hash::TreeHash; use types::ApplicationDomain; use types::{ Domain, EthSpec, ExecutionBlockHash, Hash256, MainnetEthSpec, RelativeEpoch, SelectionProof, - SignedRoot, SingleAttestation, Slot, attestation::AttestationBase, + SignedExecutionPayloadEnvelope, SignedRoot, SingleAttestation, Slot, + attestation::AttestationBase, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; type E = MainnetEthSpec; @@ -3726,6 +3728,229 @@ impl ApiTester { self } + /// Get the proposer secret key and randao reveal for the given slot. + async fn proposer_setup( + &self, + slot: Slot, + epoch: Epoch, + fork: &Fork, + genesis_validators_root: Hash256, + ) -> (SecretKey, SignatureBytes) { + let proposer_pubkey_bytes = self + .client + .get_validator_duties_proposer(epoch) + .await + .unwrap() + .data + .into_iter() + .find(|duty| duty.slot == slot) + .map(|duty| duty.pubkey) + .unwrap(); + let proposer_pubkey = (&proposer_pubkey_bytes).try_into().unwrap(); + + let sk = self + .validator_keypairs() + .iter() + .find(|kp| kp.pk == proposer_pubkey) + .map(|kp| kp.sk.clone()) + .unwrap(); + + let randao_reveal = { + let domain = + self.chain + .spec + .get_domain(epoch, Domain::Randao, fork, genesis_validators_root); + let message = epoch.signing_root(domain); + sk.sign(message).into() + }; + + (sk, randao_reveal) + } + + /// Assert block metadata and verify the envelope cache. + fn assert_v4_block_metadata( + &self, + block: &BeaconBlock, + metadata: &ProduceBlockV4Metadata, + slot: Slot, + ) { + assert_eq!( + metadata.consensus_version, + block.to_ref().fork_name(&self.chain.spec).unwrap() + ); + assert!(!metadata.consensus_block_value.is_zero()); + + let block_root = block.tree_hash_root(); + let envelope = self + .chain + .pending_payload_envelopes + .read() + .get(slot) + .cloned() + .expect("envelope should exist in pending cache for local building"); + assert_eq!(envelope.beacon_block_root, block_root); + assert_eq!(envelope.slot, slot); + } + + /// Assert envelope fields match the expected block root and slot. + fn assert_envelope_fields( + &self, + envelope: &ExecutionPayloadEnvelope, + block_root: Hash256, + slot: Slot, + ) { + assert_eq!(envelope.beacon_block_root, block_root); + assert_eq!(envelope.slot, slot); + assert_eq!(envelope.builder_index, BUILDER_INDEX_SELF_BUILD); + assert_ne!(envelope.state_root, Hash256::ZERO); + } + + /// Sign an execution payload envelope. + fn sign_envelope( + &self, + envelope: ExecutionPayloadEnvelope, + sk: &SecretKey, + epoch: Epoch, + fork: &Fork, + genesis_validators_root: Hash256, + ) -> SignedExecutionPayloadEnvelope { + let domain = + self.chain + .spec + .get_domain(epoch, Domain::BeaconBuilder, fork, genesis_validators_root); + let signing_root = envelope.signing_root(domain); + let signature = sk.sign(signing_root); + + SignedExecutionPayloadEnvelope { + message: envelope, + signature, + } + } + + /// Test V4 block production (JSON). Only runs if Gloas is scheduled. + pub async fn test_block_production_v4(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + + for _ in 0..E::slots_per_epoch() * 3 { + let slot = self.chain.slot().unwrap(); + let epoch = self.chain.epoch().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::(slot); + + if !fork_name.gloas_enabled() { + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + continue; + } + + let (sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let (response, metadata) = self + .client + .get_validator_blocks_v4::(slot, &randao_reveal, None, None, None) + .await + .unwrap(); + let block = response.data; + + self.assert_v4_block_metadata(&block, &metadata, slot); + + let envelope = self + .client + .get_validator_execution_payload_envelope::(slot, BUILDER_INDEX_SELF_BUILD) + .await + .unwrap() + .data; + + self.assert_envelope_fields(&envelope, block.tree_hash_root(), slot); + + let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); + let signed_block_request = + PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); + self.client + .post_beacon_blocks_v2(&signed_block_request, None) + .await + .unwrap(); + assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); + + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + self.client + .post_beacon_execution_payload_envelope(&signed_envelope, fork_name) + .await + .unwrap(); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + } + + self + } + + /// Test V4 block production (SSZ). Only runs if Gloas is scheduled. + pub async fn test_block_production_v4_ssz(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + + for _ in 0..E::slots_per_epoch() * 3 { + let slot = self.chain.slot().unwrap(); + let epoch = self.chain.epoch().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::(slot); + + if !fork_name.gloas_enabled() { + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + continue; + } + + let (sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + let (block, metadata) = self + .client + .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, None, None) + .await + .unwrap(); + + self.assert_v4_block_metadata(&block, &metadata, slot); + + let envelope = self + .client + .get_validator_execution_payload_envelope_ssz::(slot, BUILDER_INDEX_SELF_BUILD) + .await + .unwrap(); + + self.assert_envelope_fields(&envelope, block.tree_hash_root(), slot); + + let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); + let signed_block_request = + PublishBlockRequest::try_from(Arc::new(signed_block.clone())).unwrap(); + self.client + .post_beacon_blocks_v2_ssz(&signed_block_request, None) + .await + .unwrap(); + assert_eq!(self.chain.head_beacon_block(), Arc::new(signed_block)); + + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + self.client + .post_beacon_execution_payload_envelope_ssz(&signed_envelope, fork_name) + .await + .unwrap(); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + } + + self + } + pub async fn test_block_production_no_verify_randao(self) -> Self { for _ in 0..E::slots_per_epoch() { let slot = self.chain.slot().unwrap(); @@ -7459,6 +7684,22 @@ async fn block_production_v3_ssz_with_skip_slots() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn block_production_v4() { + ApiTester::new_with_hard_forks() + .await + .test_block_production_v4() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn block_production_v4_ssz() { + ApiTester::new_with_hard_forks() + .await + .test_block_production_v4_ssz() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn blinded_block_production_full_payload_premerge() { ApiTester::new().await.test_blinded_block_production().await; diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index cdf63a3c67..a5b4f9afdd 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -42,7 +42,7 @@ use reqwest::{ #[cfg(feature = "events")] use reqwest_eventsource::{Event, RequestBuilderExt}; use serde::{Serialize, de::DeserializeOwned}; -use ssz::Encode; +use ssz::{Decode, Encode}; use std::fmt; use std::future::Future; use std::time::Duration; @@ -50,6 +50,7 @@ use std::time::Duration; pub const V1: EndpointVersion = EndpointVersion(1); pub const V2: EndpointVersion = EndpointVersion(2); pub const V3: EndpointVersion = EndpointVersion(3); +pub const V4: EndpointVersion = EndpointVersion(4); pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; pub const EXECUTION_PAYLOAD_BLINDED_HEADER: &str = "Eth-Execution-Payload-Blinded"; @@ -2406,6 +2407,277 @@ impl BeaconNodeHttpClient { opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) } + /// returns `GET v4/validator/blocks/{slot}` URL path + pub async fn get_validator_blocks_v4_path( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + builder_booster_factor: Option, + graffiti_policy: Option, + ) -> Result { + let mut path = self.eth_path(V4)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("blocks") + .push(&slot.to_string()); + + path.query_pairs_mut() + .append_pair("randao_reveal", &randao_reveal.to_string()); + + if let Some(graffiti) = graffiti { + path.query_pairs_mut() + .append_pair("graffiti", &graffiti.to_string()); + } + + if skip_randao_verification == SkipRandaoVerification::Yes { + path.query_pairs_mut() + .append_pair("skip_randao_verification", ""); + } + + if let Some(builder_booster_factor) = builder_booster_factor { + path.query_pairs_mut() + .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); + } + + if let Some(GraffitiPolicy::AppendClientVersions) = graffiti_policy { + path.query_pairs_mut() + .append_pair("graffiti_policy", "AppendClientVersions"); + } + + Ok(path) + } + + /// `GET v4/validator/blocks/{slot}` + pub async fn get_validator_blocks_v4( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + builder_booster_factor: Option, + graffiti_policy: Option, + ) -> Result< + ( + ForkVersionedResponse, ProduceBlockV4Metadata>, + ProduceBlockV4Metadata, + ), + Error, + > { + self.get_validator_blocks_v4_modular( + slot, + randao_reveal, + graffiti, + SkipRandaoVerification::No, + builder_booster_factor, + graffiti_policy, + ) + .await + } + + /// `GET v4/validator/blocks/{slot}` + pub async fn get_validator_blocks_v4_modular( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + builder_booster_factor: Option, + graffiti_policy: Option, + ) -> Result< + ( + ForkVersionedResponse, ProduceBlockV4Metadata>, + ProduceBlockV4Metadata, + ), + Error, + > { + let path = self + .get_validator_blocks_v4_path( + slot, + randao_reveal, + graffiti, + skip_randao_verification, + builder_booster_factor, + graffiti_policy, + ) + .await?; + + let opt_result = self + .get_response_with_response_headers( + path, + Accept::Json, + self.timeouts.get_validator_block, + |response, headers| async move { + let header_metadata = ProduceBlockV4Metadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + let block_response = response + .json::, ProduceBlockV4Metadata>>() + .await?; + Ok((block_response, header_metadata)) + }, + ) + .await?; + + opt_result.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + + /// `GET v4/validator/blocks/{slot}` in ssz format + pub async fn get_validator_blocks_v4_ssz( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + builder_booster_factor: Option, + graffiti_policy: Option, + ) -> Result<(BeaconBlock, ProduceBlockV4Metadata), Error> { + self.get_validator_blocks_v4_modular_ssz::( + slot, + randao_reveal, + graffiti, + SkipRandaoVerification::No, + builder_booster_factor, + graffiti_policy, + ) + .await + } + + /// `GET v4/validator/blocks/{slot}` in ssz format + pub async fn get_validator_blocks_v4_modular_ssz( + &self, + slot: Slot, + randao_reveal: &SignatureBytes, + graffiti: Option<&Graffiti>, + skip_randao_verification: SkipRandaoVerification, + builder_booster_factor: Option, + graffiti_policy: Option, + ) -> Result<(BeaconBlock, ProduceBlockV4Metadata), Error> { + let path = self + .get_validator_blocks_v4_path( + slot, + randao_reveal, + graffiti, + skip_randao_verification, + builder_booster_factor, + graffiti_policy, + ) + .await?; + + let opt_response = self + .get_response_with_response_headers( + path, + Accept::Ssz, + self.timeouts.get_validator_block, + |response, headers| async move { + let metadata = ProduceBlockV4Metadata::try_from(&headers) + .map_err(Error::InvalidHeaders)?; + let response_bytes = response.bytes().await?; + + let block = BeaconBlock::from_ssz_bytes_for_fork( + &response_bytes, + metadata.consensus_version, + ) + .map_err(Error::InvalidSsz)?; + + Ok((block, metadata)) + }, + ) + .await?; + + opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) + } + + /// `GET v1/validator/execution_payload_envelope/{slot}/{builder_index}` + pub async fn get_validator_execution_payload_envelope( + &self, + slot: Slot, + builder_index: u64, + ) -> Result>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("execution_payload_envelope") + .push(&slot.to_string()) + .push(&builder_index.to_string()); + + self.get(path).await + } + + /// `GET v1/validator/execution_payload_envelope/{slot}/{builder_index}` in SSZ format + pub async fn get_validator_execution_payload_envelope_ssz( + &self, + slot: Slot, + builder_index: u64, + ) -> Result, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("execution_payload_envelope") + .push(&slot.to_string()) + .push(&builder_index.to_string()); + + let opt_response = self + .get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.get_validator_block) + .await?; + + let response_bytes = opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND))?; + + ExecutionPayloadEnvelope::from_ssz_bytes(&response_bytes).map_err(Error::InvalidSsz) + } + + /// `POST v1/beacon/execution_payload_envelope` + pub async fn post_beacon_execution_payload_envelope( + &self, + envelope: &SignedExecutionPayloadEnvelope, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("execution_payload_envelope"); + + self.post_generic_with_consensus_version( + path, + envelope, + Some(self.timeouts.proposal), + fork_name, + ) + .await?; + + Ok(()) + } + + /// `POST v1/beacon/execution_payload_envelope` in SSZ format + pub async fn post_beacon_execution_payload_envelope_ssz( + &self, + envelope: &SignedExecutionPayloadEnvelope, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("execution_payload_envelope"); + + self.post_generic_with_consensus_version_and_ssz_body( + path, + envelope.as_ssz_bytes(), + Some(self.timeouts.proposal), + fork_name, + ) + .await?; + + Ok(()) + } + /// `GET v2/validator/blocks/{slot}` in ssz format pub async fn get_validator_blocks_ssz( &self, diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 0c9c0b95f0..f8376d430c 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1723,7 +1723,7 @@ pub type JsonProduceBlockV3Response = pub enum FullBlockContents { /// This is a full deneb variant with block and blobs. BlockContents(BlockContents), - /// This variant is for all pre-deneb full blocks. + /// This variant is for all pre-deneb full blocks or post-gloas beacon block. Block(BeaconBlock), } @@ -1751,6 +1751,20 @@ pub struct ProduceBlockV3Metadata { pub consensus_block_value: Uint256, } +/// Metadata about a `produce_block_v4` response which is returned in the body & headers. +#[derive(Debug, Deserialize, Serialize)] +pub struct ProduceBlockV4Metadata { + // The consensus version is serialized & deserialized by `ForkVersionedResponse`. + #[serde( + skip_serializing, + skip_deserializing, + default = "dummy_consensus_version" + )] + pub consensus_version: ForkName, + #[serde(with = "serde_utils::u256_dec")] + pub consensus_block_value: Uint256, +} + impl FullBlockContents { pub fn new(block: BeaconBlock, blob_data: Option<(KzgProofs, BlobsList)>) -> Self { match blob_data { @@ -1907,6 +1921,27 @@ impl TryFrom<&HeaderMap> for ProduceBlockV3Metadata { } } +impl TryFrom<&HeaderMap> for ProduceBlockV4Metadata { + type Error = String; + + fn try_from(headers: &HeaderMap) -> Result { + let consensus_version = parse_required_header(headers, CONSENSUS_VERSION_HEADER, |s| { + s.parse::() + .map_err(|e| format!("invalid {CONSENSUS_VERSION_HEADER}: {e:?}")) + })?; + let consensus_block_value = + parse_required_header(headers, CONSENSUS_BLOCK_VALUE_HEADER, |s| { + Uint256::from_str_radix(s, 10) + .map_err(|e| format!("invalid {CONSENSUS_BLOCK_VALUE_HEADER}: {e:?}")) + })?; + + Ok(ProduceBlockV4Metadata { + consensus_version, + consensus_block_value, + }) + } +} + /// A wrapper over a [`SignedBeaconBlock`] or a [`SignedBlockContents`]. #[derive(Clone, Debug, PartialEq, Encode, Serialize)] #[serde(untagged)] @@ -1954,7 +1989,7 @@ impl PublishBlockRequest { /// SSZ decode with fork variant determined by `fork_name`. pub fn from_ssz_bytes(bytes: &[u8], fork_name: ForkName) -> Result { - if fork_name.deneb_enabled() { + if fork_name.deneb_enabled() && !fork_name.gloas_enabled() { let mut builder = ssz::SszDecoderBuilder::new(bytes); builder.register_anonymous_variable_length_item()?; builder.register_type::>()?; diff --git a/consensus/state_processing/src/envelope_processing.rs b/consensus/state_processing/src/envelope_processing.rs index d46728dbbc..c2cfeae5d3 100644 --- a/consensus/state_processing/src/envelope_processing.rs +++ b/consensus/state_processing/src/envelope_processing.rs @@ -20,6 +20,23 @@ macro_rules! envelope_verify { }; } +/// The strategy to be used when validating the payloads state root. +#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[derive(PartialEq, Clone, Copy)] +pub enum VerifyStateRoot { + /// Validate state root. + True, + /// Do not validate state root. Use with caution. + /// This should only be used when first constructing the payload envelope. + False, +} + +impl VerifyStateRoot { + pub fn is_true(self) -> bool { + self == VerifyStateRoot::True + } +} + #[derive(Debug, Clone)] pub enum EnvelopeProcessingError { /// Bad Signature @@ -111,6 +128,7 @@ pub fn process_execution_payload_envelope( parent_state_root: Option, signed_envelope: &SignedExecutionPayloadEnvelope, verify_signatures: VerifySignatures, + verify_state_root: VerifyStateRoot, spec: &ChainSpec, ) -> Result<(), EnvelopeProcessingError> { if verify_signatures.is_true() { @@ -264,15 +282,17 @@ pub fn process_execution_payload_envelope( .map_err(EnvelopeProcessingError::BitFieldError)?; *state.latest_block_hash_mut()? = payload.block_hash; - // Verify the state root - let state_root = state.canonical_root()?; - envelope_verify!( - envelope.state_root == state_root, - EnvelopeProcessingError::InvalidStateRoot { - state: state_root, - envelope: envelope.state_root, - } - ); + if verify_state_root.is_true() { + // Verify the state root + let state_root = state.canonical_root()?; + envelope_verify!( + envelope.state_root == state_root, + EnvelopeProcessingError::InvalidStateRoot { + state: state_root, + envelope: envelope.state_root, + } + ); + } Ok(()) } diff --git a/consensus/types/src/builder/builder_bid.rs b/consensus/types/src/builder/builder_bid.rs index 1018fadb64..e706b01283 100644 --- a/consensus/types/src/builder/builder_bid.rs +++ b/consensus/types/src/builder/builder_bid.rs @@ -196,7 +196,7 @@ impl SignedBuilderBid { .pubkey() .decompress() .map(|pubkey| { - let domain = spec.get_builder_domain(); + let domain = spec.get_builder_application_domain(); let message = self.message.signing_root(domain); self.signature.verify(&pubkey, message) }) diff --git a/consensus/types/src/core/application_domain.rs b/consensus/types/src/core/application_domain.rs index 5e33f2dfd5..ff55a91034 100644 --- a/consensus/types/src/core/application_domain.rs +++ b/consensus/types/src/core/application_domain.rs @@ -4,6 +4,7 @@ pub const APPLICATION_DOMAIN_BUILDER: u32 = 16777216; #[derive(Debug, PartialEq, Clone, Copy)] pub enum ApplicationDomain { + /// NOTE: This domain is only used for out-of-protocol block building, DO NOT use it for Gloas/ePBS. Builder, } diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index 6d25e3baf4..adf87dee94 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -549,7 +549,9 @@ impl ChainSpec { // This should be updated to include the current fork and the genesis validators root, but discussion is ongoing: // // https://github.com/ethereum/builder-specs/issues/14 - pub fn get_builder_domain(&self) -> Hash256 { + // + // NOTE: This domain is only used for out-of-protocol block building, DO NOT use it for Gloas/ePBS. + pub fn get_builder_application_domain(&self) -> Hash256 { self.compute_domain( Domain::ApplicationMask(ApplicationDomain::Builder), self.genesis_fork_version, diff --git a/consensus/types/src/validator/validator_registration_data.rs b/consensus/types/src/validator/validator_registration_data.rs index a0a1df7dc5..df2293cbae 100644 --- a/consensus/types/src/validator/validator_registration_data.rs +++ b/consensus/types/src/validator/validator_registration_data.rs @@ -31,7 +31,7 @@ impl SignedValidatorRegistrationData { .pubkey .decompress() .map(|pubkey| { - let domain = spec.get_builder_domain(); + let domain = spec.get_builder_application_domain(); let message = self.message.signing_root(domain); self.signature.verify(&pubkey, message) }) diff --git a/testing/ef_tests/src/cases/operations.rs b/testing/ef_tests/src/cases/operations.rs index 2f08727045..59d2bef24e 100644 --- a/testing/ef_tests/src/cases/operations.rs +++ b/testing/ef_tests/src/cases/operations.rs @@ -5,6 +5,7 @@ use crate::decode::{ssz_decode_file, ssz_decode_file_with, ssz_decode_state, yam use serde::Deserialize; use ssz::Decode; use state_processing::common::update_progressive_balances_cache::initialize_progressive_balances_cache; +use state_processing::envelope_processing::VerifyStateRoot; use state_processing::epoch_cache::initialize_epoch_cache; use state_processing::per_block_processing::process_operations::{ process_consolidation_requests, process_deposit_requests_post_gloas, @@ -458,7 +459,14 @@ impl Operation for SignedExecutionPayloadEnvelope { .as_ref() .is_some_and(|e| e.execution_valid); if valid { - process_execution_payload_envelope(state, None, self, VerifySignatures::True, spec) + process_execution_payload_envelope( + state, + None, + self, + VerifySignatures::True, + VerifyStateRoot::True, + spec, + ) } else { Err(EnvelopeProcessingError::ExecutionInvalid) } diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index 7b6a582363..7806482ffb 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -20,12 +20,12 @@ use task_executor::TaskExecutor; use tracing::{error, info, instrument, warn}; use types::{ AbstractExecPayload, Address, AggregateAndProof, Attestation, BeaconBlock, BlindedPayload, - ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, Fork, Graffiti, Hash256, - SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, SignedContributionAndProof, - SignedRoot, SignedValidatorRegistrationData, SignedVoluntaryExit, Slot, - SyncAggregatorSelectionData, SyncCommitteeContribution, SyncCommitteeMessage, - SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, VoluntaryExit, - graffiti::GraffitiString, + ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, ExecutionPayloadEnvelope, Fork, + FullPayload, Graffiti, Hash256, SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, + SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedRoot, + SignedValidatorRegistrationData, SignedVoluntaryExit, Slot, SyncAggregatorSelectionData, + SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, + ValidatorRegistrationData, VoluntaryExit, graffiti::GraffitiString, }; use validator_store::{ DoppelgangerStatus, Error as ValidatorStoreError, ProposalData, SignedBlock, UnsignedBlock, @@ -954,7 +954,7 @@ impl ValidatorStore for LighthouseValidatorS &self, validator_registration_data: ValidatorRegistrationData, ) -> Result { - let domain_hash = self.spec.get_builder_domain(); + let domain_hash = self.spec.get_builder_application_domain(); let signing_root = validator_registration_data.signing_root(domain_hash); let signing_method = @@ -1242,4 +1242,35 @@ impl ValidatorStore for LighthouseValidatorS .get_builder_proposals_defaulting(validator.get_builder_proposals()), }) } + + /// Sign an `ExecutionPayloadEnvelope` for Gloas (local building). + /// The proposer acts as the builder and signs with the BeaconBuilder domain. + async fn sign_execution_payload_envelope( + &self, + validator_pubkey: PublicKeyBytes, + envelope: ExecutionPayloadEnvelope, + ) -> Result, Error> { + let signing_context = self.signing_context( + Domain::BeaconBuilder, + envelope.slot.epoch(E::slots_per_epoch()), + ); + + // Execution payload envelope signing is not slashable, bypass doppelganger protection. + let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?; + + let signature = signing_method + .get_signature::>( + SignableMessage::ExecutionPayloadEnvelope(&envelope), + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + Ok(SignedExecutionPayloadEnvelope { + message: envelope, + signature, + }) + } } diff --git a/validator_client/signing_method/src/lib.rs b/validator_client/signing_method/src/lib.rs index bf3cc6a17d..c132d86c17 100644 --- a/validator_client/signing_method/src/lib.rs +++ b/validator_client/signing_method/src/lib.rs @@ -49,6 +49,7 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload = FullP SignedContributionAndProof(&'a ContributionAndProof), ValidatorRegistration(&'a ValidatorRegistrationData), VoluntaryExit(&'a VoluntaryExit), + ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), } impl> SignableMessage<'_, E, Payload> { @@ -70,6 +71,7 @@ impl> SignableMessage<'_, E, Payload SignableMessage::SignedContributionAndProof(c) => c.signing_root(domain), SignableMessage::ValidatorRegistration(v) => v.signing_root(domain), SignableMessage::VoluntaryExit(exit) => exit.signing_root(domain), + SignableMessage::ExecutionPayloadEnvelope(e) => e.signing_root(domain), } } } @@ -233,6 +235,9 @@ impl SigningMethod { Web3SignerObject::ValidatorRegistration(v) } SignableMessage::VoluntaryExit(e) => Web3SignerObject::VoluntaryExit(e), + SignableMessage::ExecutionPayloadEnvelope(e) => { + Web3SignerObject::ExecutionPayloadEnvelope(e) + } }; // Determine the Web3Signer message type. diff --git a/validator_client/signing_method/src/web3signer.rs b/validator_client/signing_method/src/web3signer.rs index 246d9e9e09..e6fc8f3ba2 100644 --- a/validator_client/signing_method/src/web3signer.rs +++ b/validator_client/signing_method/src/web3signer.rs @@ -19,6 +19,8 @@ pub enum MessageType { SyncCommitteeSelectionProof, SyncCommitteeContributionAndProof, ValidatorRegistration, + // TODO(gloas) verify w/ web3signer specs + ExecutionPayloadEnvelope, } #[derive(Debug, PartialEq, Copy, Clone, Serialize)] @@ -75,6 +77,7 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload> { SyncAggregatorSelectionData(&'a SyncAggregatorSelectionData), ContributionAndProof(&'a ContributionAndProof), ValidatorRegistration(&'a ValidatorRegistrationData), + ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), } impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Payload> { @@ -140,6 +143,7 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Pa MessageType::SyncCommitteeContributionAndProof } Web3SignerObject::ValidatorRegistration(_) => MessageType::ValidatorRegistration, + Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope, } } } diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index df4c9b223c..1535f50663 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -14,6 +14,7 @@ use std::time::Duration; use task_executor::TaskExecutor; use tokio::sync::mpsc; use tracing::{Instrument, debug, error, info, info_span, instrument, trace, warn}; +use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{BlockType, ChainSpec, EthSpec, Graffiti, Slot}; use validator_store::{Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore}; @@ -334,7 +335,7 @@ impl BlockService { #[instrument(skip_all, fields(%slot, ?validator_pubkey))] async fn sign_and_publish_block( &self, - proposer_fallback: ProposerFallback, + proposer_fallback: &ProposerFallback, slot: Slot, graffiti: Option, validator_pubkey: &PublicKeyBytes, @@ -460,73 +461,145 @@ impl BlockService { info!(slot = slot.as_u64(), "Requesting unsigned block"); - // Request an SSZ block from all beacon nodes in order, returning on the first successful response. - // If all nodes fail, run a second pass falling back to JSON. - // - // Proposer nodes will always be tried last during each pass since it's likely that they don't have a - // great view of attestations on the network. - let ssz_block_response = proposer_fallback - .request_proposers_last(|beacon_node| async move { - let _get_timer = validator_metrics::start_timer_vec( - &validator_metrics::BLOCK_SERVICE_TIMES, - &[validator_metrics::BEACON_BLOCK_HTTP_GET], - ); - beacon_node - .get_validator_blocks_v3_ssz::( - slot, - randao_reveal_ref, - graffiti.as_ref(), - builder_boost_factor, - self_ref.graffiti_policy, - ) - .await - }) - .await; + // Check if Gloas fork is active at this slot + let fork_name = self_ref.chain_spec.fork_name_at_slot::(slot); - let block_response = match ssz_block_response { - Ok((ssz_block_response, _metadata)) => ssz_block_response, - Err(e) => { - warn!( - slot = slot.as_u64(), - error = %e, - "SSZ block production failed, falling back to JSON" - ); + let (block_proposer, unsigned_block) = if fork_name.gloas_enabled() { + // Use V4 block production for Gloas + // Request an SSZ block from all beacon nodes in order, returning on the first successful response. + // If all nodes fail, run a second pass falling back to JSON. + let ssz_block_response = proposer_fallback + .request_proposers_last(|beacon_node| async move { + let _get_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_GET], + ); + beacon_node + .get_validator_blocks_v4_ssz::( + slot, + randao_reveal_ref, + graffiti.as_ref(), + builder_boost_factor, + self_ref.graffiti_policy, + ) + .await + }) + .await; - proposer_fallback - .request_proposers_last(|beacon_node| async move { - let _get_timer = validator_metrics::start_timer_vec( - &validator_metrics::BLOCK_SERVICE_TIMES, - &[validator_metrics::BEACON_BLOCK_HTTP_GET], - ); - let (json_block_response, _metadata) = beacon_node - .get_validator_blocks_v3::( - slot, - randao_reveal_ref, - graffiti.as_ref(), - builder_boost_factor, - self_ref.graffiti_policy, - ) - .await - .map_err(|e| { - BlockError::Recoverable(format!( - "Error from beacon node when producing block: {:?}", - e - )) - })?; + let block_response = match ssz_block_response { + Ok((ssz_block_response, _metadata)) => ssz_block_response, + Err(e) => { + warn!( + slot = slot.as_u64(), + error = %e, + "SSZ V4 block production failed, falling back to JSON" + ); - Ok(json_block_response.data) - }) - .await - .map_err(BlockError::from)? - } - }; + proposer_fallback + .request_proposers_last(|beacon_node| async move { + let _get_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_GET], + ); + let (json_block_response, _metadata) = beacon_node + .get_validator_blocks_v4::( + slot, + randao_reveal_ref, + graffiti.as_ref(), + builder_boost_factor, + self_ref.graffiti_policy, + ) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error from beacon node when producing block: {:?}", + e + )) + })?; - let (block_proposer, unsigned_block) = match block_response { - eth2::types::ProduceBlockV3Response::Full(block) => { - (block.block().proposer_index(), UnsignedBlock::Full(block)) - } - eth2::types::ProduceBlockV3Response::Blinded(block) => { - (block.proposer_index(), UnsignedBlock::Blinded(block)) + Ok(json_block_response.data) + }) + .await + .map_err(BlockError::from)? + } + }; + + // Gloas blocks don't have blobs (they're in the execution layer) + let block_contents = eth2::types::FullBlockContents::Block(block_response); + ( + block_contents.block().proposer_index(), + UnsignedBlock::Full(block_contents), + ) + } else { + // Use V3 block production for pre-Gloas forks + // Request an SSZ block from all beacon nodes in order, returning on the first successful response. + // If all nodes fail, run a second pass falling back to JSON. + // + // Proposer nodes will always be tried last during each pass since it's likely that they don't have a + // great view of attestations on the network. + let ssz_block_response = proposer_fallback + .request_proposers_last(|beacon_node| async move { + let _get_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_GET], + ); + beacon_node + .get_validator_blocks_v3_ssz::( + slot, + randao_reveal_ref, + graffiti.as_ref(), + builder_boost_factor, + self_ref.graffiti_policy, + ) + .await + }) + .await; + + let block_response = match ssz_block_response { + Ok((ssz_block_response, _metadata)) => ssz_block_response, + Err(e) => { + warn!( + slot = slot.as_u64(), + error = %e, + "SSZ block production failed, falling back to JSON" + ); + + proposer_fallback + .request_proposers_last(|beacon_node| async move { + let _get_timer = validator_metrics::start_timer_vec( + &validator_metrics::BLOCK_SERVICE_TIMES, + &[validator_metrics::BEACON_BLOCK_HTTP_GET], + ); + let (json_block_response, _metadata) = beacon_node + .get_validator_blocks_v3::( + slot, + randao_reveal_ref, + graffiti.as_ref(), + builder_boost_factor, + self_ref.graffiti_policy, + ) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error from beacon node when producing block: {:?}", + e + )) + })?; + + Ok(json_block_response.data) + }) + .await + .map_err(BlockError::from)? + } + }; + + match block_response { + eth2::types::ProduceBlockV3Response::Full(block) => { + (block.block().proposer_index(), UnsignedBlock::Full(block)) + } + eth2::types::ProduceBlockV3Response::Blinded(block) => { + (block.proposer_index(), UnsignedBlock::Blinded(block)) + } } }; @@ -539,7 +612,7 @@ impl BlockService { self_ref .sign_and_publish_block( - proposer_fallback, + &proposer_fallback, slot, graffiti, &validator_pubkey, @@ -547,6 +620,108 @@ impl BlockService { ) .await?; + // TODO(gloas) we only need to fetch, sign and publish the envelope in the local building case. + // Right now we always default to local building. Once we implement trustless/trusted builder logic + // we should check the bid for index == BUILDER_INDEX_SELF_BUILD + if fork_name.gloas_enabled() { + self_ref + .fetch_sign_and_publish_payload_envelope( + &proposer_fallback, + slot, + &validator_pubkey, + ) + .await?; + } + + Ok(()) + } + + /// Fetch, sign, and publish the execution payload envelope for Gloas. + /// This should be called after the block has been published. + /// + /// TODO(gloas): For multi-BN setups, we need to track which beacon node produced the block + /// and fetch the envelope from that same node. The envelope is cached per-BN, + /// so fetching from a different BN than the one that built the block will fail. + /// See: https://github.com/sigp/lighthouse/pull/8313 + #[instrument(skip_all)] + async fn fetch_sign_and_publish_payload_envelope( + &self, + _proposer_fallback: &ProposerFallback, + slot: Slot, + validator_pubkey: &PublicKeyBytes, + ) -> Result<(), BlockError> { + info!(slot = slot.as_u64(), "Fetching execution payload envelope"); + + // Fetch the envelope from the beacon node. Use builder_index=BUILDER_INDEX_SELF_BUILD for local building. + // TODO(gloas): Use proposer_fallback once multi-BN is supported. + let envelope = self + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .get_validator_execution_payload_envelope_ssz::( + slot, + BUILDER_INDEX_SELF_BUILD, + ) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error fetching execution payload envelope: {:?}", + e + )) + }) + }) + .await?; + + info!( + slot = slot.as_u64(), + beacon_block_root = %envelope.beacon_block_root, + "Received execution payload envelope, signing" + ); + + // Sign the envelope + let signed_envelope = self + .validator_store + .sign_execution_payload_envelope(*validator_pubkey, envelope) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error signing execution payload envelope: {:?}", + e + )) + })?; + + info!( + slot = slot.as_u64(), + "Signed execution payload envelope, publishing" + ); + + let fork_name = self.chain_spec.fork_name_at_slot::(slot); + + // Publish the signed envelope + // TODO(gloas): Use proposer_fallback once multi-BN is supported. + self.beacon_nodes + .first_success(|beacon_node| { + let signed_envelope = signed_envelope.clone(); + async move { + beacon_node + .post_beacon_execution_payload_envelope_ssz(&signed_envelope, fork_name) + .await + .map_err(|e| { + BlockError::Recoverable(format!( + "Error publishing execution payload envelope: {:?}", + e + )) + }) + } + }) + .await?; + + info!( + slot = slot.as_u64(), + beacon_block_root = %signed_envelope.message.beacon_block_root, + "Successfully published signed execution payload envelope" + ); + Ok(()) } diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index 4fdbb8064c..87ab669e8d 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -5,8 +5,9 @@ use std::fmt::Debug; use std::future::Future; use std::sync::Arc; use types::{ - Address, Attestation, AttestationError, BlindedBeaconBlock, Epoch, EthSpec, Graffiti, Hash256, - SelectionProof, SignedAggregateAndProof, SignedBlindedBeaconBlock, SignedContributionAndProof, + Address, Attestation, AttestationError, BlindedBeaconBlock, Epoch, EthSpec, + ExecutionPayloadEnvelope, Graffiti, Hash256, SelectionProof, SignedAggregateAndProof, + SignedBlindedBeaconBlock, SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedValidatorRegistrationData, Slot, SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, }; @@ -178,6 +179,13 @@ pub trait ValidatorStore: Send + Sync { /// runs. fn prune_slashing_protection_db(&self, current_epoch: Epoch, first_run: bool); + /// Sign an `ExecutionPayloadEnvelope` for Gloas. + fn sign_execution_payload_envelope( + &self, + validator_pubkey: PublicKeyBytes, + envelope: ExecutionPayloadEnvelope, + ) -> impl Future, Error>> + Send; + /// Returns `ProposalData` for the provided `pubkey` if it exists in `InitializedValidators`. /// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`, /// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`. From 67b96731913e6a12aef8c585dbdb6c2eff4c2541 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 17 Feb 2026 16:50:44 +1100 Subject: [PATCH 014/189] Gloas payload attestation consensus (#8827) - Implement `process_payload_attestation` - Implement EF tests for payload attestations (allows simplification of handler now that we support all `operations` tests). - Update the `BlockSignatureVerifier` to signature-verify payload attestations Co-Authored-By: Michael Sproul --- .../common/get_payload_attesting_indices.rs | 42 +++++++++++++++ consensus/state_processing/src/common/mod.rs | 4 ++ .../state_processing/src/consensus_context.rs | 32 +++++++++-- .../src/per_block_processing.rs | 3 ++ .../block_signature_verifier.rs | 46 +++++++++++++++- .../src/per_block_processing/errors.rs | 51 ++++++++++++++++++ .../is_valid_indexed_payload_attestation.rs | 32 +++++++++++ .../process_operations.rs | 54 ++++++++++++++++++- .../per_block_processing/signature_sets.rs | 42 +++++++++++++-- .../verify_payload_attestation.rs | 46 ++++++++++++++++ testing/ef_tests/check_all_files_accessed.py | 2 - testing/ef_tests/src/cases/operations.rs | 33 ++++++++++-- testing/ef_tests/src/handler.rs | 19 ------- testing/ef_tests/tests/tests.rs | 6 +++ 14 files changed, 378 insertions(+), 34 deletions(-) create mode 100644 consensus/state_processing/src/common/get_payload_attesting_indices.rs create mode 100644 consensus/state_processing/src/per_block_processing/is_valid_indexed_payload_attestation.rs create mode 100644 consensus/state_processing/src/per_block_processing/verify_payload_attestation.rs diff --git a/consensus/state_processing/src/common/get_payload_attesting_indices.rs b/consensus/state_processing/src/common/get_payload_attesting_indices.rs new file mode 100644 index 0000000000..407e4f1372 --- /dev/null +++ b/consensus/state_processing/src/common/get_payload_attesting_indices.rs @@ -0,0 +1,42 @@ +use crate::per_block_processing::errors::{ + BlockOperationError, PayloadAttestationInvalid as Invalid, +}; +use ssz_types::VariableList; +use types::{ + BeaconState, BeaconStateError, ChainSpec, EthSpec, IndexedPayloadAttestation, + PayloadAttestation, +}; + +pub fn get_indexed_payload_attestation( + state: &BeaconState, + payload_attestation: &PayloadAttestation, + spec: &ChainSpec, +) -> Result, BlockOperationError> { + let attesting_indices = get_payload_attesting_indices(state, payload_attestation, spec)?; + + Ok(IndexedPayloadAttestation { + attesting_indices: VariableList::new(attesting_indices)?, + data: payload_attestation.data.clone(), + signature: payload_attestation.signature.clone(), + }) +} + +pub fn get_payload_attesting_indices( + state: &BeaconState, + payload_attestation: &PayloadAttestation, + spec: &ChainSpec, +) -> Result, BeaconStateError> { + let slot = payload_attestation.data.slot; + let ptc = state.get_ptc(slot, spec)?; + let bits = &payload_attestation.aggregation_bits; + + let mut attesting_indices = vec![]; + for (i, index) in ptc.into_iter().enumerate() { + if let Ok(true) = bits.get(i) { + attesting_indices.push(index as u64); + } + } + attesting_indices.sort_unstable(); + + Ok(attesting_indices) +} diff --git a/consensus/state_processing/src/common/mod.rs b/consensus/state_processing/src/common/mod.rs index 0287748fd0..e550a6c48b 100644 --- a/consensus/state_processing/src/common/mod.rs +++ b/consensus/state_processing/src/common/mod.rs @@ -1,6 +1,7 @@ mod deposit_data_tree; mod get_attestation_participation; mod get_attesting_indices; +mod get_payload_attesting_indices; mod initiate_validator_exit; mod slash_validator; @@ -13,6 +14,9 @@ pub use get_attestation_participation::get_attestation_participation_flag_indice pub use get_attesting_indices::{ attesting_indices_base, attesting_indices_electra, get_attesting_indices_from_state, }; +pub use get_payload_attesting_indices::{ + get_indexed_payload_attestation, get_payload_attesting_indices, +}; pub use initiate_validator_exit::initiate_validator_exit; pub use slash_validator::slash_validator; diff --git a/consensus/state_processing/src/consensus_context.rs b/consensus/state_processing/src/consensus_context.rs index 07d554e303..bc7bd20384 100644 --- a/consensus/state_processing/src/consensus_context.rs +++ b/consensus/state_processing/src/consensus_context.rs @@ -1,11 +1,16 @@ use crate::EpochCacheError; -use crate::common::{attesting_indices_base, attesting_indices_electra}; -use crate::per_block_processing::errors::{AttestationInvalid, BlockOperationError}; +use crate::common::{ + attesting_indices_base, attesting_indices_electra, get_indexed_payload_attestation, +}; +use crate::per_block_processing::errors::{ + AttestationInvalid, BlockOperationError, PayloadAttestationInvalid, +}; use std::collections::{HashMap, hash_map::Entry}; use tree_hash::TreeHash; use types::{ AbstractExecPayload, AttestationRef, BeaconState, BeaconStateError, ChainSpec, Epoch, EthSpec, - Hash256, IndexedAttestation, IndexedAttestationRef, SignedBeaconBlock, Slot, + Hash256, IndexedAttestation, IndexedAttestationRef, IndexedPayloadAttestation, + PayloadAttestation, SignedBeaconBlock, Slot, }; #[derive(Debug, PartialEq, Clone)] @@ -22,6 +27,8 @@ pub struct ConsensusContext { pub current_block_root: Option, /// Cache of indexed attestations constructed during block processing. pub indexed_attestations: HashMap>, + /// Cache of indexed payload attestations constructed during block processing. + pub indexed_payload_attestations: HashMap>, } #[derive(Debug, PartialEq, Clone)] @@ -55,6 +62,7 @@ impl ConsensusContext { proposer_index: None, current_block_root: None, indexed_attestations: HashMap::new(), + indexed_payload_attestations: HashMap::new(), } } @@ -177,6 +185,24 @@ impl ConsensusContext { .map(|indexed_attestation| (*indexed_attestation).to_ref()) } + pub fn get_indexed_payload_attestation<'a>( + &'a mut self, + state: &BeaconState, + payload_attestation: &'a PayloadAttestation, + spec: &ChainSpec, + ) -> Result<&'a IndexedPayloadAttestation, BlockOperationError> + { + let key = payload_attestation.tree_hash_root(); + match self.indexed_payload_attestations.entry(key) { + Entry::Occupied(occupied) => Ok(occupied.into_mut()), + Entry::Vacant(vacant) => { + let indexed_payload_attestation = + get_indexed_payload_attestation(state, payload_attestation, spec)?; + Ok(vacant.insert(indexed_payload_attestation)) + } + } + } + pub fn num_cached_indexed_attestations(&self) -> usize { self.indexed_attestations.len() } diff --git a/consensus/state_processing/src/per_block_processing.rs b/consensus/state_processing/src/per_block_processing.rs index d9a41418cf..037e1c7cc7 100644 --- a/consensus/state_processing/src/per_block_processing.rs +++ b/consensus/state_processing/src/per_block_processing.rs @@ -20,6 +20,7 @@ pub use self::verify_proposer_slashing::verify_proposer_slashing; pub use altair::sync_committee::process_sync_aggregate; pub use block_signature_verifier::{BlockSignatureVerifier, ParallelSignatureSets}; pub use is_valid_indexed_attestation::is_valid_indexed_attestation; +pub use is_valid_indexed_payload_attestation::is_valid_indexed_payload_attestation; pub use process_operations::process_operations; pub use verify_attestation::{ verify_attestation_for_block_inclusion, verify_attestation_for_state, @@ -37,6 +38,7 @@ pub mod builder; pub mod deneb; pub mod errors; mod is_valid_indexed_attestation; +mod is_valid_indexed_payload_attestation; pub mod process_operations; pub mod signature_sets; pub mod tests; @@ -45,6 +47,7 @@ mod verify_attester_slashing; mod verify_bls_to_execution_change; mod verify_deposit; mod verify_exit; +mod verify_payload_attestation; mod verify_proposer_slashing; pub mod withdrawals; diff --git a/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs b/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs index e82ce537fd..eea9c17a14 100644 --- a/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs +++ b/consensus/state_processing/src/per_block_processing/block_signature_verifier.rs @@ -1,7 +1,9 @@ #![allow(clippy::arithmetic_side_effects)] use super::signature_sets::{Error as SignatureSetError, *}; -use crate::per_block_processing::errors::{AttestationInvalid, BlockOperationError}; +use crate::per_block_processing::errors::{ + AttestationInvalid, BlockOperationError, PayloadAttestationInvalid, +}; use crate::{ConsensusContext, ContextError}; use bls::{PublicKey, PublicKeyBytes, SignatureSet, verify_signature_sets}; use std::borrow::Cow; @@ -18,6 +20,8 @@ pub enum Error { SignatureInvalid, /// An attestation in the block was invalid. The block is invalid. AttestationValidationError(BlockOperationError), + /// A payload attestation in the block was invalid. The block is invalid. + PayloadAttestationValidationError(BlockOperationError), /// There was an error attempting to read from a `BeaconState`. Block /// validity was not determined. BeaconStateError(BeaconStateError), @@ -66,6 +70,12 @@ impl From> for Error { } } +impl From> for Error { + fn from(e: BlockOperationError) -> Error { + Error::PayloadAttestationValidationError(e) + } +} + /// Reads the BLS signatures and keys from a `SignedBeaconBlock`, storing them as a `Vec`. /// /// This allows for optimizations related to batch BLS operations (see the @@ -171,6 +181,7 @@ where self.include_sync_aggregate(block)?; self.include_bls_to_execution_changes(block)?; self.include_execution_payload_bid(block)?; + self.include_payload_attestations(block, ctxt)?; Ok(()) } @@ -296,6 +307,39 @@ where }) } + /// Includes all signatures in `self.block.body.payload_attestations` for verification. + pub fn include_payload_attestations>( + &mut self, + block: &'a SignedBeaconBlock, + ctxt: &mut ConsensusContext, + ) -> Result<()> { + let Ok(payload_attestations) = block.message().body().payload_attestations() else { + // Nothing to do pre-Gloas. + return Ok(()); + }; + + self.sets.sets.reserve(payload_attestations.len()); + + payload_attestations + .iter() + .try_for_each(|payload_attestation| { + let indexed_payload_attestation = ctxt.get_indexed_payload_attestation( + self.state, + payload_attestation, + self.spec, + )?; + + self.sets.push(indexed_payload_attestation_signature_set( + self.state, + self.get_pubkey.clone(), + &payload_attestation.signature, + indexed_payload_attestation, + self.spec, + )?); + Ok(()) + }) + } + /// Includes all signatures in `self.block.body.voluntary_exits` for verification. pub fn include_exits>( &mut self, diff --git a/consensus/state_processing/src/per_block_processing/errors.rs b/consensus/state_processing/src/per_block_processing/errors.rs index 53178a7a64..71083378db 100644 --- a/consensus/state_processing/src/per_block_processing/errors.rs +++ b/consensus/state_processing/src/per_block_processing/errors.rs @@ -41,6 +41,10 @@ pub enum BlockProcessingError { index: usize, reason: AttestationInvalid, }, + PayloadAttestationInvalid { + index: usize, + reason: PayloadAttestationInvalid, + }, DepositInvalid { index: usize, reason: DepositInvalid, @@ -217,6 +221,7 @@ impl_into_block_processing_error_with_index!( AttesterSlashingInvalid, IndexedAttestationInvalid, AttestationInvalid, + PayloadAttestationInvalid, DepositInvalid, ExitInvalid, BlsExecutionChangeInvalid @@ -422,6 +427,52 @@ pub enum IndexedAttestationInvalid { SignatureSetError(SignatureSetError), } +#[derive(Debug, PartialEq, Clone)] +pub enum PayloadAttestationInvalid { + /// Block root does not match the parent beacon block root. + BlockRootMismatch { + expected: Hash256, + found: Hash256, + }, + /// The attestation slot is not the previous slot. + SlotMismatch { + expected: Slot, + found: Slot, + }, + BadIndexedPayloadAttestation(IndexedPayloadAttestationInvalid), +} + +impl From> + for BlockOperationError +{ + fn from(e: BlockOperationError) -> Self { + match e { + BlockOperationError::Invalid(e) => BlockOperationError::invalid( + PayloadAttestationInvalid::BadIndexedPayloadAttestation(e), + ), + BlockOperationError::BeaconStateError(e) => BlockOperationError::BeaconStateError(e), + BlockOperationError::SignatureSetError(e) => BlockOperationError::SignatureSetError(e), + BlockOperationError::SszTypesError(e) => BlockOperationError::SszTypesError(e), + BlockOperationError::BitfieldError(e) => BlockOperationError::BitfieldError(e), + BlockOperationError::ConsensusContext(e) => BlockOperationError::ConsensusContext(e), + BlockOperationError::ArithError(e) => BlockOperationError::ArithError(e), + } + } +} + +#[derive(Debug, PartialEq, Clone)] +pub enum IndexedPayloadAttestationInvalid { + /// The number of indices is 0. + IndicesEmpty, + /// The validator indices were not in increasing order. + BadValidatorIndicesOrdering, + /// The indexed attestation aggregate signature was not valid. + BadSignature, + /// There was an error whilst attempting to get a set of signatures. The signatures may have + /// been invalid or an internal error occurred. + SignatureSetError(SignatureSetError), +} + #[derive(Debug, PartialEq, Clone)] pub enum DepositInvalid { /// The signature (proof-of-possession) does not match the given pubkey. diff --git a/consensus/state_processing/src/per_block_processing/is_valid_indexed_payload_attestation.rs b/consensus/state_processing/src/per_block_processing/is_valid_indexed_payload_attestation.rs new file mode 100644 index 0000000000..534f553247 --- /dev/null +++ b/consensus/state_processing/src/per_block_processing/is_valid_indexed_payload_attestation.rs @@ -0,0 +1,32 @@ +use super::errors::{BlockOperationError, IndexedPayloadAttestationInvalid as Invalid}; +use super::signature_sets::{get_pubkey_from_state, indexed_payload_attestation_signature_set}; +use crate::VerifySignatures; +use types::*; + +pub fn is_valid_indexed_payload_attestation( + state: &BeaconState, + indexed_payload_attestation: &IndexedPayloadAttestation, + verify_signatures: VerifySignatures, + spec: &ChainSpec, +) -> Result<(), BlockOperationError> { + // Verify indices are non-empty and sorted (duplicates allowed) + let indices = &indexed_payload_attestation.attesting_indices; + verify!(!indices.is_empty(), Invalid::IndicesEmpty); + verify!(indices.is_sorted(), Invalid::BadValidatorIndicesOrdering); + + if verify_signatures.is_true() { + verify!( + indexed_payload_attestation_signature_set( + state, + |i| get_pubkey_from_state(state, i), + &indexed_payload_attestation.signature, + indexed_payload_attestation, + spec + )? + .verify(), + Invalid::BadSignature + ); + } + + Ok(()) +} diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index 19109f1508..b037c74484 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -5,6 +5,7 @@ use crate::common::{ slash_validator, }; use crate::per_block_processing::errors::{BlockProcessingError, IntoWithIndex}; +use crate::per_block_processing::verify_payload_attestation::verify_payload_attestation; use bls::{PublicKeyBytes, SignatureBytes}; use ssz_types::FixedVector; use typenum::U33; @@ -39,8 +40,15 @@ pub fn process_operations>( process_bls_to_execution_changes(state, bls_to_execution_changes, verify_signatures, spec)?; } - if state.fork_name_unchecked().electra_enabled() && !state.fork_name_unchecked().gloas_enabled() - { + if state.fork_name_unchecked().gloas_enabled() { + process_payload_attestations( + state, + block_body.payload_attestations()?.iter(), + verify_signatures, + ctxt, + spec, + )?; + } else if state.fork_name_unchecked().electra_enabled() { state.update_pubkey_cache()?; process_deposit_requests_pre_gloas( state, @@ -1074,3 +1082,45 @@ pub fn process_consolidation_request( Ok(()) } + +pub fn process_payload_attestation( + state: &mut BeaconState, + payload_attestation: &PayloadAttestation, + att_index: usize, + verify_signatures: VerifySignatures, + ctxt: &mut ConsensusContext, + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> { + verify_payload_attestation(state, payload_attestation, ctxt, verify_signatures, spec) + .map_err(|e| e.into_with_index(att_index)) +} + +pub fn process_payload_attestations<'a, E: EthSpec, I>( + state: &mut BeaconState, + payload_attestations: I, + verify_signatures: VerifySignatures, + ctxt: &mut ConsensusContext, + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> +where + I: Iterator>, +{ + // Presently the PTC cache requires the committee cache for `state.slot() - 1` which is either + // in the current or previous epoch. + // TODO(gloas): These requirements may change if we introduce a PTC cache. + state.build_committee_cache(RelativeEpoch::Current, spec)?; + state.build_committee_cache(RelativeEpoch::Previous, spec)?; + + payload_attestations + .enumerate() + .try_for_each(|(i, payload_attestation)| { + process_payload_attestation( + state, + payload_attestation, + i, + verify_signatures, + ctxt, + spec, + ) + }) +} diff --git a/consensus/state_processing/src/per_block_processing/signature_sets.rs b/consensus/state_processing/src/per_block_processing/signature_sets.rs index 0cc591ba4c..71ee1f8993 100644 --- a/consensus/state_processing/src/per_block_processing/signature_sets.rs +++ b/consensus/state_processing/src/per_block_processing/signature_sets.rs @@ -10,10 +10,10 @@ use typenum::Unsigned; use types::{ AbstractExecPayload, AttesterSlashingRef, BeaconBlockRef, BeaconState, BeaconStateError, BuilderIndex, ChainSpec, DepositData, Domain, Epoch, EthSpec, Fork, Hash256, InconsistentFork, - IndexedAttestation, IndexedAttestationRef, ProposerSlashing, SignedAggregateAndProof, - SignedBeaconBlock, SignedBeaconBlockHeader, SignedBlsToExecutionChange, - SignedContributionAndProof, SignedExecutionPayloadBid, SignedRoot, SignedVoluntaryExit, - SigningData, Slot, SyncAggregate, SyncAggregatorSelectionData, + IndexedAttestation, IndexedAttestationRef, IndexedPayloadAttestation, ProposerSlashing, + SignedAggregateAndProof, SignedBeaconBlock, SignedBeaconBlockHeader, + SignedBlsToExecutionChange, SignedContributionAndProof, SignedExecutionPayloadBid, SignedRoot, + SignedVoluntaryExit, SigningData, Slot, SyncAggregate, SyncAggregatorSelectionData, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; @@ -355,6 +355,40 @@ where Ok(SignatureSet::multiple_pubkeys(signature, pubkeys, message)) } +pub fn indexed_payload_attestation_signature_set<'a, 'b, E, F>( + state: &'a BeaconState, + get_pubkey: F, + signature: &'a AggregateSignature, + indexed_payload_attestation: &'b IndexedPayloadAttestation, + spec: &'a ChainSpec, +) -> Result> +where + E: EthSpec, + F: Fn(usize) -> Option>, +{ + let mut pubkeys = Vec::with_capacity(indexed_payload_attestation.attesting_indices.len()); + for &validator_idx in indexed_payload_attestation.attesting_indices.iter() { + pubkeys.push( + get_pubkey(validator_idx as usize).ok_or(Error::ValidatorUnknown(validator_idx))?, + ); + } + + let epoch = indexed_payload_attestation + .data + .slot + .epoch(E::slots_per_epoch()); + let domain = spec.get_domain( + epoch, + Domain::PTCAttester, + &state.fork(), + state.genesis_validators_root(), + ); + + let message = indexed_payload_attestation.data.signing_root(domain); + + Ok(SignatureSet::multiple_pubkeys(signature, pubkeys, message)) +} + pub fn execution_payload_bid_signature_set<'a, E, F>( state: &'a BeaconState, get_builder_pubkey: F, diff --git a/consensus/state_processing/src/per_block_processing/verify_payload_attestation.rs b/consensus/state_processing/src/per_block_processing/verify_payload_attestation.rs new file mode 100644 index 0000000000..1c15e1c21c --- /dev/null +++ b/consensus/state_processing/src/per_block_processing/verify_payload_attestation.rs @@ -0,0 +1,46 @@ +use super::VerifySignatures; +use super::errors::{BlockOperationError, PayloadAttestationInvalid as Invalid}; +use crate::ConsensusContext; +use crate::per_block_processing::is_valid_indexed_payload_attestation; +use safe_arith::SafeArith; +use types::*; + +pub fn verify_payload_attestation<'ctxt, E: EthSpec>( + state: &mut BeaconState, + payload_attestation: &'ctxt PayloadAttestation, + ctxt: &'ctxt mut ConsensusContext, + verify_signatures: VerifySignatures, + spec: &ChainSpec, +) -> Result<(), BlockOperationError> { + let data = &payload_attestation.data; + + // Check that the attestation is for the parent beacon block + verify!( + data.beacon_block_root == state.latest_block_header().parent_root, + Invalid::BlockRootMismatch { + expected: state.latest_block_header().parent_root, + found: data.beacon_block_root, + } + ); + + // Check that the attestation is for the previous slot + verify!( + data.slot.safe_add(1)? == state.slot(), + Invalid::SlotMismatch { + expected: state.slot().saturating_sub(Slot::new(1)), + found: data.slot, + } + ); + + let indexed_payload_attestation = + ctxt.get_indexed_payload_attestation(state, payload_attestation, spec)?; + + is_valid_indexed_payload_attestation( + state, + indexed_payload_attestation, + verify_signatures, + spec, + )?; + + Ok(()) +} diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index b465a47296..b5d7a787fe 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -47,8 +47,6 @@ excluded_paths = [ "bls12-381-tests/hash_to_G2", "tests/.*/eip7732", "tests/.*/eip7805", - # TODO(gloas): remove these ignores as more Gloas operations are implemented - "tests/.*/gloas/operations/payload_attestation/.*", # TODO(gloas): remove these ignores as Gloas consensus is implemented "tests/.*/gloas/fork/.*", "tests/.*/gloas/fork_choice/.*", diff --git a/testing/ef_tests/src/cases/operations.rs b/testing/ef_tests/src/cases/operations.rs index 59d2bef24e..ca0124e1aa 100644 --- a/testing/ef_tests/src/cases/operations.rs +++ b/testing/ef_tests/src/cases/operations.rs @@ -21,7 +21,7 @@ use state_processing::{ process_operations::{ altair_deneb, base, gloas, process_attester_slashings, process_bls_to_execution_changes, process_deposits, process_exits, - process_proposer_slashings, + process_payload_attestation, process_proposer_slashings, }, process_sync_aggregate, withdrawals, }, @@ -31,8 +31,9 @@ use types::{ Attestation, AttesterSlashing, BeaconBlock, BeaconBlockBody, BeaconBlockBodyBellatrix, BeaconBlockBodyCapella, BeaconBlockBodyDeneb, BeaconBlockBodyElectra, BeaconBlockBodyFulu, BeaconState, BlindedPayload, ConsolidationRequest, Deposit, DepositRequest, ExecutionPayload, - ForkVersionDecode, FullPayload, ProposerSlashing, SignedBlsToExecutionChange, - SignedExecutionPayloadEnvelope, SignedVoluntaryExit, SyncAggregate, WithdrawalRequest, + ForkVersionDecode, FullPayload, PayloadAttestation, ProposerSlashing, + SignedBlsToExecutionChange, SignedExecutionPayloadEnvelope, SignedVoluntaryExit, SyncAggregate, + WithdrawalRequest, }; #[derive(Debug, Clone, Default, Deserialize)] @@ -667,6 +668,32 @@ impl Operation for ConsolidationRequest { } } +impl Operation for PayloadAttestation { + type Error = BlockProcessingError; + + fn handler_name() -> String { + "payload_attestation".into() + } + + fn is_enabled_for_fork(fork_name: ForkName) -> bool { + fork_name.gloas_enabled() + } + + fn decode(path: &Path, _fork_name: ForkName, _spec: &ChainSpec) -> Result { + ssz_decode_file(path) + } + + fn apply_to( + &self, + state: &mut BeaconState, + spec: &ChainSpec, + _extra: &Operations, + ) -> Result<(), BlockProcessingError> { + let mut ctxt = ConsensusContext::new(state.slot()); + process_payload_attestation(state, self, 0, VerifySignatures::True, &mut ctxt, spec) + } +} + impl> LoadCase for Operations { fn load_from_dir(path: &Path, fork_name: ForkName) -> Result { let spec = &testing_spec::(fork_name); diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index 625778c2dd..35972ce72c 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -1177,25 +1177,6 @@ impl> Handler for OperationsHandler fn handler_name(&self) -> String { O::handler_name() } - - fn is_enabled_for_fork(&self, fork_name: ForkName) -> bool { - Self::Case::is_enabled_for_fork(fork_name) - && (!fork_name.gloas_enabled() - || self.handler_name() == "attestation" - || self.handler_name() == "attester_slashing" - || self.handler_name() == "block_header" - || self.handler_name() == "bls_to_execution_change" - || self.handler_name() == "consolidation_request" - || self.handler_name() == "deposit_request" - || self.handler_name() == "deposit" - || self.handler_name() == "execution_payload" - || self.handler_name() == "execution_payload_bid" - || self.handler_name() == "proposer_slashing" - || self.handler_name() == "sync_aggregate" - || self.handler_name() == "withdrawal_request" - || self.handler_name() == "withdrawals" - || self.handler_name() == "voluntary_exit") - } } #[derive(Educe)] diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index fcf7951c3e..3893df2ef7 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -99,6 +99,12 @@ fn operations_execution_payload_bid() { OperationsHandler::>::default().run(); } +#[test] +fn operations_payload_attestation() { + OperationsHandler::>::default().run(); + OperationsHandler::>::default().run(); +} + #[test] fn operations_withdrawals() { OperationsHandler::>::default().run(); From 41291a8aecd9813243d1cef96f54ad8bc50cbe5b Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 17 Feb 2026 17:23:25 +1100 Subject: [PATCH 015/189] Gloas fork upgrade consensus (#8833) - Implement and optimise `upgrade_to_gloas` - Enable EF tests for `fork_ugprade` Co-Authored-By: Michael Sproul --- .../process_operations.rs | 2 +- .../state_processing/src/upgrade/gloas.rs | 84 ++++++++++++++++++- testing/ef_tests/check_all_files_accessed.py | 1 - testing/ef_tests/src/handler.rs | 5 -- 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index b037c74484..9743812632 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -876,7 +876,7 @@ pub fn apply_deposit_for_builder( signature: SignatureBytes, slot: Slot, spec: &ChainSpec, -) -> Result<(), BlockProcessingError> { +) -> Result<(), BeaconStateError> { match builder_index_opt { None => { // Verify the deposit signature (proof of possession) which is not checked by the deposit contract diff --git a/consensus/state_processing/src/upgrade/gloas.rs b/consensus/state_processing/src/upgrade/gloas.rs index 0e0f39fa02..7a88383ab0 100644 --- a/consensus/state_processing/src/upgrade/gloas.rs +++ b/consensus/state_processing/src/upgrade/gloas.rs @@ -1,10 +1,14 @@ +use crate::per_block_processing::{ + is_valid_deposit_signature, process_operations::apply_deposit_for_builder, +}; use milhouse::{List, Vector}; use ssz_types::BitVector; +use std::collections::HashSet; use std::mem; use typenum::Unsigned; use types::{ BeaconState, BeaconStateError as Error, BeaconStateGloas, BuilderPendingPayment, ChainSpec, - EthSpec, ExecutionPayloadBid, Fork, + DepositData, EthSpec, ExecutionPayloadBid, Fork, is_builder_withdrawal_credential, }; /// Transform a `Fulu` state into a `Gloas` state. @@ -30,7 +34,7 @@ pub fn upgrade_state_to_gloas( // // Fixed size vectors get cloned because replacing them would require the same size // allocation as cloning. - let post = BeaconState::Gloas(BeaconStateGloas { + let mut post = BeaconState::Gloas(BeaconStateGloas { // Versioning genesis_time: pre.genesis_time, genesis_validators_root: pre.genesis_validators_root, @@ -114,5 +118,81 @@ pub fn upgrade_state_to_gloas( slashings_cache: mem::take(&mut pre.slashings_cache), epoch_cache: mem::take(&mut pre.epoch_cache), }); + // [New in Gloas:EIP7732] + onboard_builders_from_pending_deposits(&mut post, spec)?; + Ok(post) } + +/// Applies any pending deposit for builders, effectively onboarding builders at the fork. +fn onboard_builders_from_pending_deposits( + state: &mut BeaconState, + spec: &ChainSpec, +) -> Result<(), Error> { + // Rather than tracking all `validator_pubkeys` in one place as the spec does, we keep a + // hashset for *just* the new validator pubkeys, and use the state's efficient + // `get_validator_index` function instead of an O(n) iteration over the full validator list. + let mut new_validator_pubkeys = HashSet::new(); + + // Clone pending deposits to avoid borrow conflicts when mutating state. + let current_pending_deposits = state.pending_deposits()?.clone(); + + let mut pending_deposits = List::empty(); + + for deposit in ¤t_pending_deposits { + // Deposits for existing validators stay in the pending queue. + if new_validator_pubkeys.contains(&deposit.pubkey) + || state.get_validator_index(&deposit.pubkey)?.is_some() + { + pending_deposits.push(deposit.clone())?; + continue; + } + + // Re-scan builder list each iteration because `apply_deposit_for_builder` may add + // new builders to the registry. + // TODO(gloas): this linear scan could be optimized, see: + // https://github.com/sigp/lighthouse/issues/8783 + let builder_index = state + .builders()? + .iter() + .position(|b| b.pubkey == deposit.pubkey); + + let has_builder_credentials = + is_builder_withdrawal_credential(deposit.withdrawal_credentials, spec); + + if builder_index.is_some() || has_builder_credentials { + let builder_index_opt = builder_index.map(|i| i as u64); + apply_deposit_for_builder( + state, + builder_index_opt, + deposit.pubkey, + deposit.withdrawal_credentials, + deposit.amount, + deposit.signature.clone(), + deposit.slot, + spec, + )?; + continue; + } + + // If there is a pending deposit for a new validator that has a valid signature, + // track the pubkey so that subsequent builder deposits for the same pubkey stay + // in pending (applied to the validator later) rather than creating a builder. + // Deposits with invalid signatures are dropped since they would fail in + // apply_pending_deposit anyway. + let deposit_data = DepositData { + pubkey: deposit.pubkey, + withdrawal_credentials: deposit.withdrawal_credentials, + amount: deposit.amount, + signature: deposit.signature.clone(), + }; + if is_valid_deposit_signature(&deposit_data, spec).is_ok() { + new_validator_pubkeys.insert(deposit.pubkey); + pending_deposits.push(deposit.clone())?; + } + } + + *state.pending_deposits_mut()? = pending_deposits; + + Ok(()) +} diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index b5d7a787fe..782b554ff1 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -48,7 +48,6 @@ excluded_paths = [ "tests/.*/eip7732", "tests/.*/eip7805", # TODO(gloas): remove these ignores as Gloas consensus is implemented - "tests/.*/gloas/fork/.*", "tests/.*/gloas/fork_choice/.*", # Ignore MatrixEntry SSZ tests for now. "tests/.*/.*/ssz_static/MatrixEntry/.*", diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index 35972ce72c..da3c5533b6 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -621,11 +621,6 @@ impl Handler for ForkHandler { fn handler_name(&self) -> String { "fork".into() } - - fn disabled_forks(&self) -> Vec { - // TODO(gloas): remove once onboard_builders_from_pending_deposits is implemented - vec![ForkName::Gloas] - } } #[derive(Educe)] From 4625cb6ab62d78ef13ff286b8243a52aaa60bfa4 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Tue, 17 Feb 2026 20:22:16 +1100 Subject: [PATCH 016/189] Gloas local block building cleanup (#8834) Continuation of #8754, some small cleanups and address TODOs Co-Authored-By: Jimmy Chen --- beacon_node/beacon_chain/src/beacon_chain.rs | 4 +- .../src/block_production/gloas.rs | 62 +++++-------------- .../src/data_column_verification.rs | 1 - beacon_node/beacon_chain/src/errors.rs | 3 + beacon_node/execution_layer/src/lib.rs | 1 + 5 files changed, 23 insertions(+), 48 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index d0f5297f1b..9f62bf11f5 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -5262,7 +5262,7 @@ impl BeaconChain { err = ?e, block_slot = %state.slot(), ?exit, - "Attempted to include an invalid proposer slashing" + "Attempted to include an invalid voluntary exit" ); }) .is_ok() @@ -5672,7 +5672,7 @@ impl BeaconChain { } BeaconState::Gloas(_) => { return Err(BlockProductionError::GloasNotImplemented( - "Attempting to produce gloas beacn block via non gloas code path".to_owned(), + "Attempting to produce gloas beacon block via non gloas code path".to_owned(), )); } }; diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 025cf21a73..607090c59d 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -59,7 +59,7 @@ pub struct PartialBeaconBlock { payload_attestations: Vec>, deposits: Vec, voluntary_exits: Vec, - sync_aggregate: Option>, + sync_aggregate: SyncAggregate, bls_to_execution_changes: Vec, } @@ -364,13 +364,13 @@ impl BeaconChain { err = ?e, block_slot = %state.slot(), ?exit, - "Attempted to include an invalid proposer slashing" + "Attempted to include an invalid voluntary exit" ); }) .is_ok() }); - // TODO(gloas) verifiy payload attestation signature here as well + // TODO(gloas) verify payload attestation signature here as well } let attester_slashings = attester_slashings @@ -391,22 +391,17 @@ impl BeaconChain { let slot = state.slot(); - let sync_aggregate = if matches!(&state, BeaconState::Base(_)) { - None - } else { - let sync_aggregate = self - .op_pool - .get_sync_aggregate(&state) - .map_err(BlockProductionError::OpPoolError)? - .unwrap_or_else(|| { - warn!( - slot = %state.slot(), - "Producing block with no sync contributions" - ); - SyncAggregate::new() - }); - Some(sync_aggregate) - }; + let sync_aggregate = self + .op_pool + .get_sync_aggregate(&state) + .map_err(BlockProductionError::OpPoolError)? + .unwrap_or_else(|| { + warn!( + slot = %state.slot(), + "Producing block with no sync contributions" + ); + SyncAggregate::new() + }); Ok(( PartialBeaconBlock { @@ -492,8 +487,7 @@ impl BeaconChain { voluntary_exits: voluntary_exits .try_into() .map_err(BlockProductionError::SszTypesError)?, - sync_aggregate: sync_aggregate - .ok_or(BlockProductionError::MissingSyncAggregate)?, + sync_aggregate, bls_to_execution_changes: bls_to_execution_changes .try_into() .map_err(BlockProductionError::SszTypesError)?, @@ -573,7 +567,6 @@ impl BeaconChain { signature: Signature::empty(), }; - // TODO(gloas) add better error variant // We skip state root verification here because the relevant state root // cant be calculated until after the new block has been constructed. process_execution_payload_envelope( @@ -584,11 +577,7 @@ impl BeaconChain { VerifyStateRoot::False, &self.spec, ) - .map_err(|_| { - BlockProductionError::GloasNotImplemented( - "process_execution_payload_envelope failed".to_owned(), - ) - })?; + .map_err(BlockProductionError::EnvelopeProcessingError)?; signed_envelope.message.state_root = state.update_tree_hash_cache()?; @@ -731,12 +720,7 @@ impl BeaconChain { Ok(( SignedExecutionPayloadBid { message: bid, - // TODO(gloas) return better error variant here - signature: Signature::infinity().map_err(|_| { - BlockProductionError::GloasNotImplemented( - "Failed to generate infinity signature".to_owned(), - ) - })?, + signature: Signature::infinity().map_err(BlockProductionError::BlsError)?, }, state, // Local building always returns payload data. @@ -752,12 +736,6 @@ impl BeaconChain { /// /// Will return an error when using a pre-Gloas `state`. Ensure to only run this function /// after the Gloas fork. -/// -/// ## Specification -/// -/// Equivalent to the `get_execution_payload` function in the Validator Guide: -/// -/// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md#block-proposal fn get_execution_payload_gloas( chain: Arc>, state: &BeaconState, @@ -813,12 +791,6 @@ fn get_execution_payload_gloas( /// /// Will return an error when using a pre-Gloas fork `state`. Ensure to only run this function /// after the Gloas fork. -/// -/// ## Specification -/// -/// Equivalent to the `prepare_execution_payload` function in the Validator Guide: -/// -/// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md#block-proposal #[allow(clippy::too_many_arguments)] async fn prepare_execution_payload( chain: &Arc>, diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index cf3385ec5b..08acfdffa4 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -496,7 +496,6 @@ where Ok(()) } -// TODO(gloas) make sure the gloas variant uses the same span name #[instrument( skip_all, name = "validate_data_column_sidecar_for_gossip", diff --git a/beacon_node/beacon_chain/src/errors.rs b/beacon_node/beacon_chain/src/errors.rs index bcccc0ec12..6c8f0d2794 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -16,6 +16,7 @@ use milhouse::Error as MilhouseError; use operation_pool::OpPoolError; use safe_arith::ArithError; use ssz_types::Error as SszTypesError; +use state_processing::envelope_processing::EnvelopeProcessingError; use state_processing::{ BlockProcessingError, BlockReplayError, EpochProcessingError, SlotProcessingError, block_signature_verifier::Error as BlockSignatureVerifierError, @@ -318,6 +319,8 @@ pub enum BlockProductionError { FailedToBuildBlobSidecars(String), MissingExecutionRequests, SszTypesError(ssz_types::Error), + EnvelopeProcessingError(EnvelopeProcessingError), + BlsError(bls::Error), // TODO(gloas): Remove this once Gloas is implemented GloasNotImplemented(String), } diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index ad2486a4ad..157fe152ef 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -914,6 +914,7 @@ impl ExecutionLayer { /// /// The result will be returned from the first node that returns successfully. No more nodes /// will be contacted. + #[instrument(level = "debug", skip_all)] pub async fn get_payload_gloas( &self, payload_parameters: PayloadParameters<'_>, From 2f7a1f3ae8b450f8042001a67ae88dd675251e3c Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 18 Feb 2026 11:38:11 +1100 Subject: [PATCH 017/189] Support pinning nightly ef test runs (#8738) Co-Authored-By: Jimmy Chen Co-Authored-By: Michael Sproul --- testing/ef_tests/download_test_vectors.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/testing/ef_tests/download_test_vectors.sh b/testing/ef_tests/download_test_vectors.sh index 21f74e817f..ff5b61bb47 100755 --- a/testing/ef_tests/download_test_vectors.sh +++ b/testing/ef_tests/download_test_vectors.sh @@ -4,7 +4,7 @@ set -Eeuo pipefail TESTS=("general" "minimal" "mainnet") version=${1} -if [[ "$version" == "nightly" ]]; then +if [[ "$version" == "nightly" || "$version" =~ ^nightly-[0-9]+$ ]]; then if [[ -z "${GITHUB_TOKEN:-}" ]]; then echo "Error GITHUB_TOKEN is not set" exit 1 @@ -21,9 +21,13 @@ if [[ "$version" == "nightly" ]]; then api="https://api.github.com" auth_header="Authorization: token ${GITHUB_TOKEN}" - run_id=$(curl -s -H "${auth_header}" \ - "${api}/repos/${repo}/actions/workflows/generate_vectors.yml/runs?branch=dev&status=success&per_page=1" | - jq -r '.workflow_runs[0].id') + if [[ "$version" == "nightly" ]]; then + run_id=$(curl --fail -s -H "${auth_header}" \ + "${api}/repos/${repo}/actions/workflows/nightly-reftests.yml/runs?branch=master&status=success&per_page=1" | + jq -r '.workflow_runs[0].id') + else + run_id="${version#nightly-}" + fi if [[ "${run_id}" == "null" || -z "${run_id}" ]]; then echo "No successful nightly workflow run found" @@ -31,7 +35,7 @@ if [[ "$version" == "nightly" ]]; then fi echo "Downloading nightly test vectors for run: ${run_id}" - curl -s -H "${auth_header}" "${api}/repos/${repo}/actions/runs/${run_id}/artifacts" | + curl --fail -H "${auth_header}" "${api}/repos/${repo}/actions/runs/${run_id}/artifacts" | jq -c '.artifacts[] | {name, url: .archive_download_url}' | while read -r artifact; do name=$(echo "${artifact}" | jq -r .name) From 9065e4a56e7327d7c084e996d5ee071a0eec038b Mon Sep 17 00:00:00 2001 From: 0xMushow <105550256+0xMushow@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:35:12 +0100 Subject: [PATCH 018/189] fix(beacon_node): add pruning of observed_column_sidecars (#8531) None I noticed that `observed_column_sidecars` is missing its prune call in the finalization handler, which results in a memory leak on long-running nodes (very slow (**7MB/day**)) : https://github.com/sigp/lighthouse/blob/13dfa9200f822c41ccd81b95a3f052df54c888e9/beacon_node/beacon_chain/src/canonical_head.rs#L940-L959 Both caches use the same generic type `ObservedDataSidecars:` https://github.com/sigp/lighthouse/blob/22ec4b327186c4a4a87d2c8c745caf3b36cb6dd6/beacon_node/beacon_chain/src/beacon_chain.rs#L413-L416 The type's documentation explicitly requires manual pruning: > "*The cache supports pruning based upon the finalized epoch. It does not automatically prune, you must call Self::prune manually.*" https://github.com/sigp/lighthouse/blob/b4704eab4ac8edf0ea0282ed9a5758b784038dd2/beacon_node/beacon_chain/src/observed_data_sidecars.rs#L66-L74 Currently: - `observed_blob_sidecars` => pruned - `observed_column_sidecars` => **NOT** pruned Without pruning, the underlying HashMap accumulates entries indefinitely, causing continuous memory growth until the node restarts. Co-Authored-By: Antoine James --- beacon_node/beacon_chain/src/canonical_head.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index db071db166..1a08ac3f88 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -918,6 +918,13 @@ impl BeaconChain { .start_slot(T::EthSpec::slots_per_epoch()), ); + self.observed_column_sidecars.write().prune( + new_view + .finalized_checkpoint + .epoch + .start_slot(T::EthSpec::slots_per_epoch()), + ); + self.observed_slashable.write().prune( new_view .finalized_checkpoint From d4ec006a3419f15041a02792b1981e68645c501d Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 18 Feb 2026 14:01:22 +1100 Subject: [PATCH 019/189] Update `time` to fix `cargo audit` failure (#8764) --- Cargo.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8748be726c..7d75f5c197 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6323,9 +6323,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-integer" @@ -8899,30 +8899,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", From c4ff9b137c9a2cb8daf7a1cf6b708dc4b0011659 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:26:06 -0700 Subject: [PATCH 020/189] Add critical instructions and hooks for Claude Code (#8715) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- .claude/settings.json | 15 +++++++++++++++ .githooks/pre-commit | 5 +++++ CLAUDE.md | 8 ++++++++ Makefile | 6 ++++++ 4 files changed, 34 insertions(+) create mode 100644 .claude/settings.json create mode 100755 .githooks/pre-commit diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..ae426dd254 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "echo '\n[Reminder] Run: cargo fmt --all && make lint-fix'" + } + ] + } + ] + } +} diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000000..42a5ca79e0 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +# Pre-commit hook: runs cargo fmt --check +# Install with: make install-hooks + +exec cargo fmt --check diff --git a/CLAUDE.md b/CLAUDE.md index 441c8e4274..79ed344e35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,14 @@ This file provides guidance for AI assistants (Claude Code, Codex, etc.) working with Lighthouse. +## CRITICAL - Always Follow + +After completing ANY code changes: +1. **MUST** run `cargo fmt --all && make lint-fix` to format and fix linting issues +2. **MUST** run `cargo check` to verify compilation before considering task complete + +Run `make install-hooks` if you have not already to install git hooks. Never skip git hooks. If cargo is not available install the toolchain. + ## Quick Reference ```bash diff --git a/Makefile b/Makefile index 0995a869f4..9786c17cc9 100644 --- a/Makefile +++ b/Makefile @@ -361,3 +361,9 @@ clean: cargo clean make -C $(EF_TESTS) clean make -C $(STATE_TRANSITION_VECTORS) clean + +# Installs git hooks from .githooks/ directory +install-hooks: + @ln -sf ../../.githooks/pre-commit .git/hooks/pre-commit + @chmod +x .githooks/pre-commit + @echo "Git hooks installed. Pre-commit hook runs 'cargo fmt --check'." From c61665b3a1efc6b353b57be37816e69825f2bab6 Mon Sep 17 00:00:00 2001 From: Akihito Nakano Date: Wed, 11 Feb 2026 07:49:20 +0900 Subject: [PATCH 021/189] Penalize peers that send an invalid rpc request (#6986) Since https://github.com/sigp/lighthouse/pull/6847, invalid `BlocksByRange`/`BlobsByRange` requests, which do not comply with the spec, are [handled in the Handler](https://github.com/sigp/lighthouse/blob/3d16d1080f5b93193404967dcb5525fa68840ea0/beacon_node/lighthouse_network/src/rpc/handler.rs#L880-L911). Any peer that sends an invalid request is penalized and disconnected. However, other kinds of invalid rpc request, which result in decoding errors, are just dropped. No penalty is applied and the connection with the peer remains. I have added handling for the `ListenUpgradeError` event to notify the application of an `RPCError:InvalidData` error and disconnect to the peer that sent the invalid rpc request. I also added tests for handling invalid rpc requests. Co-Authored-By: ackintosh --- .../lighthouse_network/src/rpc/handler.rs | 17 +- .../lighthouse_network/src/rpc/protocol.rs | 10 +- .../lighthouse_network/tests/rpc_tests.rs | 160 +++++++++++++++++- 3 files changed, 179 insertions(+), 8 deletions(-) diff --git a/beacon_node/lighthouse_network/src/rpc/handler.rs b/beacon_node/lighthouse_network/src/rpc/handler.rs index 720895bbe7..9861119ac1 100644 --- a/beacon_node/lighthouse_network/src/rpc/handler.rs +++ b/beacon_node/lighthouse_network/src/rpc/handler.rs @@ -13,7 +13,8 @@ use futures::prelude::*; use libp2p::PeerId; use libp2p::swarm::handler::{ ConnectionEvent, ConnectionHandler, ConnectionHandlerEvent, DialUpgradeError, - FullyNegotiatedInbound, FullyNegotiatedOutbound, StreamUpgradeError, SubstreamProtocol, + FullyNegotiatedInbound, FullyNegotiatedOutbound, ListenUpgradeError, StreamUpgradeError, + SubstreamProtocol, }; use libp2p::swarm::{ConnectionId, Stream}; use logging::crit; @@ -888,6 +889,16 @@ where ConnectionEvent::DialUpgradeError(DialUpgradeError { info, error }) => { self.on_dial_upgrade_error(info, error) } + ConnectionEvent::ListenUpgradeError(ListenUpgradeError { + error: (proto, error), + .. + }) => { + self.events_out.push(HandlerEvent::Err(HandlerErr::Inbound { + id: self.current_inbound_substream_id, + proto, + error, + })); + } _ => { // NOTE: ConnectionEvent is a non exhaustive enum so updates should be based on // release notes more than compiler feedback @@ -924,7 +935,7 @@ where request.count() )), })); - return self.shutdown(None); + return; } } RequestType::BlobsByRange(request) => { @@ -940,7 +951,7 @@ where max_allowed, max_requested_blobs )), })); - return self.shutdown(None); + return; } } _ => {} diff --git a/beacon_node/lighthouse_network/src/rpc/protocol.rs b/beacon_node/lighthouse_network/src/rpc/protocol.rs index f0ac9d00f9..34d8efccd1 100644 --- a/beacon_node/lighthouse_network/src/rpc/protocol.rs +++ b/beacon_node/lighthouse_network/src/rpc/protocol.rs @@ -675,7 +675,7 @@ where E: EthSpec, { type Output = InboundOutput; - type Error = RPCError; + type Error = (Protocol, RPCError); type Future = BoxFuture<'static, Result>; fn upgrade_inbound(self, socket: TSocket, protocol: ProtocolId) -> Self::Future { @@ -717,10 +717,12 @@ where ) .await { - Err(e) => Err(RPCError::from(e)), + Err(e) => Err((versioned_protocol.protocol(), RPCError::from(e))), Ok((Some(Ok(request)), stream)) => Ok((request, stream)), - Ok((Some(Err(e)), _)) => Err(e), - Ok((None, _)) => Err(RPCError::IncompleteStream), + Ok((Some(Err(e)), _)) => Err((versioned_protocol.protocol(), e)), + Ok((None, _)) => { + Err((versioned_protocol.protocol(), RPCError::IncompleteStream)) + } } } } diff --git a/beacon_node/lighthouse_network/tests/rpc_tests.rs b/beacon_node/lighthouse_network/tests/rpc_tests.rs index 553cfa6f0d..137136e97e 100644 --- a/beacon_node/lighthouse_network/tests/rpc_tests.rs +++ b/beacon_node/lighthouse_network/tests/rpc_tests.rs @@ -5,8 +5,12 @@ use crate::common::spec_with_all_forks_enabled; use crate::common::{Protocol, build_tracing_subscriber}; use bls::Signature; use fixed_bytes::FixedBytesExtended; +use libp2p::PeerId; use lighthouse_network::rpc::{RequestType, methods::*}; -use lighthouse_network::service::api_types::AppRequestId; +use lighthouse_network::service::api_types::{ + AppRequestId, BlobsByRangeRequestId, BlocksByRangeRequestId, ComponentsByRangeRequestId, + DataColumnsByRangeRequestId, DataColumnsByRangeRequester, RangeRequestId, SyncRequestId, +}; use lighthouse_network::{NetworkEvent, ReportSource, Response}; use ssz::Encode; use ssz_types::{RuntimeVariableList, VariableList}; @@ -1785,3 +1789,157 @@ fn test_active_requests() { } }) } + +// Test that when a node receives an invalid BlocksByRange request exceeding the maximum count, +// it bans the sender. +#[test] +fn test_request_too_large_blocks_by_range() { + let spec = Arc::new(spec_with_all_forks_enabled()); + + test_request_too_large( + AppRequestId::Sync(SyncRequestId::BlocksByRange(BlocksByRangeRequestId { + id: 1, + parent_request_id: ComponentsByRangeRequestId { + id: 1, + requester: RangeRequestId::RangeSync { + chain_id: 1, + batch_id: Epoch::new(1), + }, + }, + })), + RequestType::BlocksByRange(OldBlocksByRangeRequest::new( + 0, + spec.max_request_blocks(ForkName::Base) as u64 + 1, // exceeds the max request defined in the spec. + 1, + )), + ); +} + +// Test that when a node receives an invalid BlobsByRange request exceeding the maximum count, +// it bans the sender. +#[test] +fn test_request_too_large_blobs_by_range() { + let spec = Arc::new(spec_with_all_forks_enabled()); + + let max_request_blobs_count = spec.max_request_blob_sidecars(ForkName::Base) as u64 + / spec.max_blobs_per_block_within_fork(ForkName::Base); + test_request_too_large( + AppRequestId::Sync(SyncRequestId::BlobsByRange(BlobsByRangeRequestId { + id: 1, + parent_request_id: ComponentsByRangeRequestId { + id: 1, + requester: RangeRequestId::RangeSync { + chain_id: 1, + batch_id: Epoch::new(1), + }, + }, + })), + RequestType::BlobsByRange(BlobsByRangeRequest { + start_slot: 0, + count: max_request_blobs_count + 1, // exceeds the max request defined in the spec. + }), + ); +} + +// Test that when a node receives an invalid DataColumnsByRange request exceeding the columns count, +// it bans the sender. +#[test] +fn test_request_too_large_data_columns_by_range() { + test_request_too_large( + AppRequestId::Sync(SyncRequestId::DataColumnsByRange( + DataColumnsByRangeRequestId { + id: 1, + parent_request_id: DataColumnsByRangeRequester::ComponentsByRange( + ComponentsByRangeRequestId { + id: 1, + requester: RangeRequestId::RangeSync { + chain_id: 1, + batch_id: Epoch::new(1), + }, + }, + ), + peer: PeerId::random(), + }, + )), + RequestType::DataColumnsByRange(DataColumnsByRangeRequest { + start_slot: 0, + count: 0, + // exceeds the max request defined in the spec. + columns: vec![0; E::number_of_columns() + 1], + }), + ); +} + +fn test_request_too_large(app_request_id: AppRequestId, request: RequestType) { + // Set up the logging. + let log_level = "debug"; + let enable_logging = true; + let _subscriber = build_tracing_subscriber(log_level, enable_logging); + let rt = Arc::new(Runtime::new().unwrap()); + let spec = Arc::new(spec_with_all_forks_enabled()); + + rt.block_on(async { + let (mut sender, mut receiver) = common::build_node_pair( + Arc::downgrade(&rt), + ForkName::Base, + spec, + Protocol::Tcp, + false, + None, + ) + .await; + + // Build the sender future + let sender_future = async { + loop { + match sender.next_event().await { + NetworkEvent::PeerConnectedOutgoing(peer_id) => { + debug!(?request, %peer_id, "Sending RPC request"); + sender + .send_request(peer_id, app_request_id, request.clone()) + .unwrap(); + } + NetworkEvent::ResponseReceived { + app_request_id, + response, + .. + } => { + debug!(?app_request_id, ?response, "Received response"); + } + NetworkEvent::RPCFailed { error, .. } => { + // This variant should be unreachable, as the receiver doesn't respond with an error when a request exceeds the limit. + debug!(?error, "RPC failed"); + unreachable!(); + } + NetworkEvent::PeerDisconnected(peer_id) => { + // The receiver should disconnect as a result of the invalid request. + debug!(%peer_id, "Peer disconnected"); + // End the test. + return; + } + _ => {} + } + } + } + .instrument(info_span!("Sender")); + + // Build the receiver future + let receiver_future = async { + loop { + if let NetworkEvent::RequestReceived { .. } = receiver.next_event().await { + // This event should be unreachable, as the handler drops the invalid request. + unreachable!(); + } + } + } + .instrument(info_span!("Receiver")); + + tokio::select! { + _ = sender_future => {} + _ = receiver_future => {} + _ = sleep(Duration::from_secs(30)) => { + panic!("Future timed out"); + } + } + }); +} From 691c8cf8e69d4d40c4969c2bc493dc1eed9af99f Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 18 Feb 2026 15:16:57 +1100 Subject: [PATCH 022/189] Fix duplicate data columns in DataColumnsByRange responses (#8843) Co-Authored-By: Jimmy Chen --- .../network_beacon_processor/rpc_methods.rs | 5 +- .../src/network_beacon_processor/tests.rs | 116 ++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs index 5edd661bb6..279870d444 100644 --- a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs @@ -977,7 +977,10 @@ impl NetworkBeaconProcessor { }; // remove all skip slots i.e. duplicated roots - Ok(block_roots.into_iter().unique().collect::>()) + Ok(block_roots + .into_iter() + .unique_by(|(root, _)| *root) + .collect::>()) } /// Handle a `BlobsByRange` request from the peer. diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index 49b1c0c262..32ca84453a 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -120,6 +120,39 @@ impl TestRig { .await } + pub async fn new_with_skip_slots(chain_length: u64, skip_slots: &HashSet) -> Self { + let mut spec = test_spec::(); + spec.shard_committee_period = 2; + let spec = Arc::new(spec); + let beacon_processor_config = BeaconProcessorConfig::default(); + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .spec(spec.clone()) + .deterministic_keypairs(VALIDATOR_COUNT) + .fresh_ephemeral_store() + .mock_execution_layer() + .node_custody_type(NodeCustodyType::Fullnode) + .chain_config(<_>::default()) + .build(); + + harness.advance_slot(); + + for slot in 1..=chain_length { + if !skip_slots.contains(&slot) { + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + } + + harness.advance_slot(); + } + + Self::from_harness(harness, beacon_processor_config, spec).await + } + pub async fn new_parametric( chain_length: u64, beacon_processor_config: BeaconProcessorConfig, @@ -150,6 +183,14 @@ impl TestRig { harness.advance_slot(); } + Self::from_harness(harness, beacon_processor_config, spec).await + } + + async fn from_harness( + harness: BeaconChainHarness, + beacon_processor_config: BeaconProcessorConfig, + spec: Arc, + ) -> Self { let head = harness.chain.head_snapshot(); assert_eq!( @@ -1986,3 +2027,78 @@ async fn test_data_columns_by_range_request_only_returns_requested_columns() { "Should have received at least some data columns" ); } + +/// Test that DataColumnsByRange does not return duplicate data columns for skip slots. +/// +/// When skip slots occur, `forwards_iter_block_roots` returns the same block root for +/// consecutive slots. The deduplication in `get_block_roots_from_store` must use +/// `unique_by` on the root (not the full `(root, slot)` tuple) to avoid serving +/// duplicate data columns for the same block. +#[tokio::test] +async fn test_data_columns_by_range_no_duplicates_with_skip_slots() { + if test_spec::().fulu_fork_epoch.is_none() { + return; + }; + + // Build a chain of 128 slots (4 epochs) with skip slots at positions 5 and 6. + // After 4 epochs, finalized_epoch=2 (finalized_slot=64). Requesting slots 0-9 + // satisfies req_start_slot + req_count <= finalized_slot (10 <= 64), which routes + // through `get_block_roots_from_store` — the code path with the bug. + let skip_slots: HashSet = [5, 6].into_iter().collect(); + let mut rig = TestRig::new_with_skip_slots(128, &skip_slots).await; + + let all_custody_columns = rig.chain.custody_columns_for_epoch(Some(Epoch::new(0))); + let requested_column = vec![all_custody_columns[0]]; + + // Request a range that spans the skip slots (slots 0 through 9). + let start_slot = 0; + let slot_count = 10; + + rig.network_beacon_processor + .send_data_columns_by_range_request( + PeerId::random(), + InboundRequestId::new_unchecked(42, 24), + DataColumnsByRangeRequest { + start_slot, + count: slot_count, + columns: requested_column.clone(), + }, + ) + .unwrap(); + + // Collect block roots from all data column responses. + let mut block_roots: Vec = Vec::new(); + + while let Some(next) = rig.network_rx.recv().await { + if let NetworkMessage::SendResponse { + peer_id: _, + response: Response::DataColumnsByRange(data_column), + inbound_request_id: _, + } = next + { + if let Some(column) = data_column { + block_roots.push(column.block_root()); + } else { + break; + } + } else { + panic!("unexpected message {:?}", next); + } + } + + assert!( + !block_roots.is_empty(), + "Should have received at least some data columns" + ); + + // Before the fix, skip slots caused the same block root to appear multiple times + // (once per skip slot) because .unique() on (Hash256, Slot) tuples didn't deduplicate. + let unique_roots: HashSet<_> = block_roots.iter().collect(); + assert_eq!( + block_roots.len(), + unique_roots.len(), + "Response contained duplicate block roots: got {} columns but only {} unique roots", + block_roots.len(), + unique_roots.len(), + ); +} From c5b4580e37b44a57605a07a9acdd5057c1b06010 Mon Sep 17 00:00:00 2001 From: Pawan Dhananjay Date: Wed, 18 Feb 2026 09:47:07 +0530 Subject: [PATCH 023/189] Return correct variant for snappy errors (#8841) N/A Handle snappy crate errors as InvalidData instead of IoError. Co-Authored-By: Pawan Dhananjay --- .../lighthouse_network/src/rpc/codec.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/beacon_node/lighthouse_network/src/rpc/codec.rs b/beacon_node/lighthouse_network/src/rpc/codec.rs index 36d9726dd9..d1a3182fad 100644 --- a/beacon_node/lighthouse_network/src/rpc/codec.rs +++ b/beacon_node/lighthouse_network/src/rpc/codec.rs @@ -457,6 +457,9 @@ fn handle_error( Ok(None) } } + // All snappy errors from the snap crate bubble up as `Other` kind errors + // that imply invalid response + ErrorKind::Other => Err(RPCError::InvalidData(err.to_string())), _ => Err(RPCError::from(err)), } } @@ -2317,4 +2320,43 @@ mod tests { RPCError::InvalidData(_) )); } + + /// Test invalid snappy response. + #[test] + fn test_invalid_snappy_response() { + let spec = spec_with_all_forks_enabled(); + let fork_ctx = Arc::new(fork_context(ForkName::latest(), &spec)); + let max_packet_size = spec.max_payload_size as usize; // 10 MiB. + + let protocol = ProtocolId::new(SupportedProtocol::BlocksByRangeV2, Encoding::SSZSnappy); + + let mut codec = SSZSnappyOutboundCodec::::new( + protocol.clone(), + max_packet_size, + fork_ctx.clone(), + ); + + let mut payload = BytesMut::new(); + payload.extend_from_slice(&[0u8]); + let deneb_epoch = spec.deneb_fork_epoch.unwrap(); + payload.extend_from_slice(&fork_ctx.context_bytes(deneb_epoch)); + + // Claim the MAXIMUM allowed size (10 MiB) + let claimed_size = max_packet_size; + let mut uvi_codec: Uvi = Uvi::default(); + uvi_codec.encode(claimed_size, &mut payload).unwrap(); + payload.extend_from_slice(&[0xBB; 16]); // Junk snappy. + + let result = codec.decode(&mut payload); + + assert!(result.is_err(), "Expected decode to fail"); + + // IoError = reached snappy decode (allocation happened). + let err = result.unwrap_err(); + assert!( + matches!(err, RPCError::InvalidData(_)), + "Should return invalid data variant {}", + err + ); + } } From be799cb2ad2fb0243cdc2a2f368091ffee29fe8e Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 18 Feb 2026 16:28:17 +1100 Subject: [PATCH 024/189] Validator client head monitor timeout fix (#8846) Fix a bug in v8.1.0 whereby the VC times out continuously with: > Feb 18 02:03:48.030 WARN Head service failed retrying starting next slot error: "Head monitoring stream error, node: 0, error: SseClient(Transport(reqwest::Error { kind: Decode, source: reqwest::Error { kind: Body, source: TimedOut } }))" - Remove the existing timeout for the events API by using `Duration::MAX`. This is necessary as the client is configured with a default timeout. This is the only way to override/remove it. - DO NOT add a `read_timeout` (yet), as this would need to be configured on a per-client basis. We do not want to create a new Client for every call as the early commits on this branch were doing, as this would bypass the TLS cert config, and is also wasteful. Co-Authored-By: hopinheimer Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul --- common/eth2/src/lib.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 10382b028a..76b05130d7 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -76,8 +76,6 @@ const HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT: u32 = 4; const HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT: u32 = 4; const HTTP_GET_VALIDATOR_BLOCK_TIMEOUT_QUOTIENT: u32 = 4; -// Generally the timeout for events should be longer than a slot. -const HTTP_GET_EVENTS_TIMEOUT_MULTIPLIER: u32 = 50; const HTTP_DEFAULT_TIMEOUT_QUOTIENT: u32 = 4; /// A struct to define a variety of different timeouts for different validator tasks to ensure @@ -98,7 +96,6 @@ pub struct Timeouts { pub get_debug_beacon_states: Duration, pub get_deposit_snapshot: Duration, pub get_validator_block: Duration, - pub events: Duration, pub default: Duration, } @@ -119,7 +116,6 @@ impl Timeouts { get_debug_beacon_states: timeout, get_deposit_snapshot: timeout, get_validator_block: timeout, - events: HTTP_GET_EVENTS_TIMEOUT_MULTIPLIER * timeout, default: timeout, } } @@ -142,7 +138,6 @@ impl Timeouts { get_debug_beacon_states: base_timeout / HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT, get_deposit_snapshot: base_timeout / HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT, get_validator_block: base_timeout / HTTP_GET_VALIDATOR_BLOCK_TIMEOUT_QUOTIENT, - events: HTTP_GET_EVENTS_TIMEOUT_MULTIPLIER * base_timeout, default: base_timeout / HTTP_DEFAULT_TIMEOUT_QUOTIENT, } } @@ -2805,10 +2800,14 @@ impl BeaconNodeHttpClient { .join(","); path.query_pairs_mut().append_pair("topics", &topic_string); + // Do not use a timeout for the events endpoint. Using a regular timeout will trigger a + // timeout every `timeout` seconds, regardless of any data streamed from the endpoint. + // In future we could add a read_timeout, but that can only be configured globally on the + // Client. let mut es = self .client .get(path) - .timeout(self.timeouts.events) + .timeout(Duration::MAX) .eventsource() .map_err(Error::SseEventSource)?; // If we don't await `Event::Open` here, then the consumer From 54b35761452d73147a80665614230dd2b5dc2951 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 18 Feb 2026 20:31:57 +1100 Subject: [PATCH 025/189] Update agent review instructions on large PRs (#8845) Co-Authored-By: Jimmy Chen --- .ai/CODE_REVIEW.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.ai/CODE_REVIEW.md b/.ai/CODE_REVIEW.md index e4da3b22d5..2ce60c80fd 100644 --- a/.ai/CODE_REVIEW.md +++ b/.ai/CODE_REVIEW.md @@ -190,6 +190,14 @@ we typically try to avoid runtime panics outside of startup." - Edge cases handled? - Context provided with errors? +## Large PR Strategy + +Large PRs (10+ files) make it easy to miss subtle bugs in individual files. + +- **Group files by subsystem** (networking, store, types, etc.) and review each group, but pay extra attention to changes that cross subsystem boundaries. +- **Review shared type/interface changes first** — changes to function signatures, return types, or struct definitions ripple through all callers. When reviewing a large PR, identify these first and trace their impact across the codebase. Downstream code may silently change behavior even if it looks untouched. +- **Flag missing test coverage for changed behavior** — if a code path's semantics change (even subtly), check that tests exercise it. If not, flag the gap. + ## Deep Review Techniques ### Verify Against Specifications @@ -275,3 +283,4 @@ Group related state and behavior together. If two fields are always set together - [ ] Tests present: Non-trivial changes have tests - [ ] Lock safety: Lock ordering is safe and documented - [ ] No blocking: Async code doesn't block runtime + From fab77f4fc9fc8fc5d9e9b9d82999cdd015d14859 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 19 Feb 2026 08:55:13 +1100 Subject: [PATCH 026/189] Skip payload_invalidation tests prior to Bellatrix (#8856) Fix the failure of the beacon-chain tests for phase0/altair, which now only runs nightly. Just skip the payload invalidation tests, they don't make any sense prior to Bellatrix anyway. Co-Authored-By: Michael Sproul --- .../tests/payload_invalidation.rs | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index eb8e57a5d5..7fd70f0e77 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -6,7 +6,7 @@ use beacon_chain::{ INVALID_JUSTIFIED_PAYLOAD_SHUTDOWN_REASON, NotifyExecutionLayer, OverrideForkchoiceUpdate, StateSkipConfig, WhenSlotSkipped, canonical_head::{CachedHead, CanonicalHead}, - test_utils::{BeaconChainHarness, EphemeralHarnessType, test_spec}, + test_utils::{BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, test_spec}, }; use execution_layer::{ ExecutionLayer, ForkchoiceState, PayloadAttributes, @@ -389,6 +389,9 @@ impl InvalidPayloadRig { /// Simple test of the different import types. #[tokio::test] async fn valid_invalid_syncing() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new(); rig.move_to_terminal_block(); @@ -404,6 +407,9 @@ async fn valid_invalid_syncing() { /// `latest_valid_hash`. #[tokio::test] async fn invalid_payload_invalidates_parent() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. @@ -460,6 +466,9 @@ async fn immediate_forkchoice_update_invalid_test( #[tokio::test] async fn immediate_forkchoice_update_payload_invalid() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } immediate_forkchoice_update_invalid_test(|latest_valid_hash| Payload::Invalid { latest_valid_hash, }) @@ -468,11 +477,17 @@ async fn immediate_forkchoice_update_payload_invalid() { #[tokio::test] async fn immediate_forkchoice_update_payload_invalid_block_hash() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } immediate_forkchoice_update_invalid_test(|_| Payload::InvalidBlockHash).await } #[tokio::test] async fn immediate_forkchoice_update_payload_invalid_terminal_block() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } immediate_forkchoice_update_invalid_test(|_| Payload::Invalid { latest_valid_hash: Some(ExecutionBlockHash::zero()), }) @@ -482,6 +497,9 @@ async fn immediate_forkchoice_update_payload_invalid_terminal_block() { /// Ensure the client tries to exit when the justified checkpoint is invalidated. #[tokio::test] async fn justified_checkpoint_becomes_invalid() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. @@ -524,6 +542,9 @@ async fn justified_checkpoint_becomes_invalid() { /// Ensure that a `latest_valid_hash` for a pre-finality block only reverts a single block. #[tokio::test] async fn pre_finalized_latest_valid_hash() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let num_blocks = E::slots_per_epoch() * 4; let finalized_epoch = 2; @@ -571,6 +592,9 @@ async fn pre_finalized_latest_valid_hash() { /// - Will not validate `latest_valid_root` and its ancestors. #[tokio::test] async fn latest_valid_hash_will_not_validate() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } const LATEST_VALID_SLOT: u64 = 3; let mut rig = InvalidPayloadRig::new().enable_attestations(); @@ -618,6 +642,9 @@ async fn latest_valid_hash_will_not_validate() { /// Check behaviour when the `latest_valid_hash` is a junk value. #[tokio::test] async fn latest_valid_hash_is_junk() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let num_blocks = E::slots_per_epoch() * 5; let finalized_epoch = 3; @@ -659,6 +686,9 @@ async fn latest_valid_hash_is_junk() { /// Check that descendants of invalid blocks are also invalidated. #[tokio::test] async fn invalidates_all_descendants() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let num_blocks = E::slots_per_epoch() * 4 + E::slots_per_epoch() / 2; let finalized_epoch = 2; let finalized_slot = E::slots_per_epoch() * 2; @@ -766,6 +796,9 @@ async fn invalidates_all_descendants() { /// Check that the head will switch after the canonical branch is invalidated. #[tokio::test] async fn switches_heads() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let num_blocks = E::slots_per_epoch() * 4 + E::slots_per_epoch() / 2; let finalized_epoch = 2; let finalized_slot = E::slots_per_epoch() * 2; @@ -869,6 +902,9 @@ async fn switches_heads() { #[tokio::test] async fn invalid_during_processing() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new(); rig.move_to_terminal_block(); @@ -901,6 +937,9 @@ async fn invalid_during_processing() { #[tokio::test] async fn invalid_after_optimistic_sync() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. @@ -939,6 +978,9 @@ async fn invalid_after_optimistic_sync() { #[tokio::test] async fn manually_validate_child() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. @@ -957,6 +999,9 @@ async fn manually_validate_child() { #[tokio::test] async fn manually_validate_parent() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. @@ -975,6 +1020,9 @@ async fn manually_validate_parent() { #[tokio::test] async fn payload_preparation() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new(); rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; @@ -1036,6 +1084,9 @@ async fn payload_preparation() { #[tokio::test] async fn invalid_parent() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new(); rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. @@ -1108,6 +1159,9 @@ async fn invalid_parent() { /// Tests to ensure that we will still send a proposer preparation #[tokio::test] async fn payload_preparation_before_transition_block() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let rig = InvalidPayloadRig::new(); let el = rig.execution_layer(); @@ -1180,6 +1234,9 @@ async fn payload_preparation_before_transition_block() { #[tokio::test] async fn attesting_to_optimistic_head() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new(); rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. @@ -1392,6 +1449,9 @@ impl InvalidHeadSetup { #[tokio::test] async fn recover_from_invalid_head_by_importing_blocks() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let InvalidHeadSetup { rig, fork_block, @@ -1437,6 +1497,9 @@ async fn recover_from_invalid_head_by_importing_blocks() { #[tokio::test] async fn recover_from_invalid_head_after_persist_and_reboot() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let InvalidHeadSetup { rig, fork_block: _, @@ -1479,6 +1542,9 @@ async fn recover_from_invalid_head_after_persist_and_reboot() { #[tokio::test] async fn weights_after_resetting_optimistic_status() { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + return; + } let mut rig = InvalidPayloadRig::new().enable_attestations(); rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. From 5e2d296de619d582ceff214584d8056776c05fd7 Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Thu, 19 Feb 2026 05:55:16 +0800 Subject: [PATCH 027/189] Validator manager import to allow overriding fields with CLI flag (#7684) * #7651 Co-Authored-By: Tan Chee Keong Co-Authored-By: chonghe <44791194+chong-he@users.noreply.github.com> Co-Authored-By: Lion - dapplion <35266934+dapplion@users.noreply.github.com> --- book/src/help_vm_import.md | 3 + lighthouse/tests/validator_manager.rs | 65 +++++++++ validator_manager/src/import_validators.rs | 161 +++++++++++++++------ 3 files changed, 183 insertions(+), 46 deletions(-) diff --git a/book/src/help_vm_import.md b/book/src/help_vm_import.md index 3c768f6705..09c1b74f4d 100644 --- a/book/src/help_vm_import.md +++ b/book/src/help_vm_import.md @@ -24,6 +24,9 @@ Options: --debug-level Specifies the verbosity level used when emitting logs to the terminal. [default: info] [possible values: info, debug, trace, warn, error] + --enabled + When provided, the imported validator will be enabled or disabled. + [possible values: true, false] --gas-limit When provided, the imported validator will use this gas limit. It is recommended to leave this as the default value by not specifying this diff --git a/lighthouse/tests/validator_manager.rs b/lighthouse/tests/validator_manager.rs index d6d720a561..9bad1cdc91 100644 --- a/lighthouse/tests/validator_manager.rs +++ b/lighthouse/tests/validator_manager.rs @@ -16,6 +16,7 @@ use validator_manager::{ list_validators::ListConfig, move_validators::{MoveConfig, PasswordSource, Validators}, }; +use zeroize::Zeroizing; const EXAMPLE_ETH1_ADDRESS: &str = "0x00000000219ab540356cBB839Cbe05303d7705Fa"; @@ -280,6 +281,40 @@ pub fn validator_import_using_both_file_flags() { .assert_failed(); } +#[test] +pub fn validator_import_keystore_file_without_password_flag_should_fail() { + CommandLineTest::validators_import() + .flag("--vc-token", Some("./token.json")) + .flag("--keystore-file", Some("./keystore.json")) + .assert_failed(); +} + +#[test] +pub fn validator_import_keystore_file_with_password_flag_should_pass() { + CommandLineTest::validators_import() + .flag("--vc-token", Some("./token.json")) + .flag("--keystore-file", Some("./keystore.json")) + .flag("--password", Some("abcd")) + .assert_success(|config| { + let expected = ImportConfig { + validators_file_path: None, + keystore_file_path: Some(PathBuf::from("./keystore.json")), + vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), + vc_token_path: PathBuf::from("./token.json"), + ignore_duplicates: false, + password: Some(Zeroizing::new("abcd".into())), + fee_recipient: None, + builder_boost_factor: None, + gas_limit: None, + builder_proposals: None, + enabled: None, + prefer_builder_proposals: None, + }; + assert_eq!(expected, config); + println!("{:?}", expected); + }); +} + #[test] pub fn validator_import_missing_both_file_flags() { CommandLineTest::validators_import() @@ -287,6 +322,36 @@ pub fn validator_import_missing_both_file_flags() { .assert_failed(); } +#[test] +pub fn validator_import_fee_recipient_override() { + CommandLineTest::validators_import() + .flag("--validators-file", Some("./vals.json")) + .flag("--vc-token", Some("./token.json")) + .flag("--suggested-fee-recipient", Some(EXAMPLE_ETH1_ADDRESS)) + .flag("--gas-limit", Some("1337")) + .flag("--builder-proposals", Some("true")) + .flag("--builder-boost-factor", Some("150")) + .flag("--prefer-builder-proposals", Some("true")) + .flag("--enabled", Some("false")) + .assert_success(|config| { + let expected = ImportConfig { + validators_file_path: Some(PathBuf::from("./vals.json")), + keystore_file_path: None, + vc_url: SensitiveUrl::parse("http://localhost:5062").unwrap(), + vc_token_path: PathBuf::from("./token.json"), + ignore_duplicates: false, + password: None, + fee_recipient: Some(Address::from_str(EXAMPLE_ETH1_ADDRESS).unwrap()), + builder_boost_factor: Some(150), + gas_limit: Some(1337), + builder_proposals: Some(true), + enabled: Some(false), + prefer_builder_proposals: Some(true), + }; + assert_eq!(expected, config); + }); +} + #[test] pub fn validator_move_defaults() { CommandLineTest::validators_move() diff --git a/validator_manager/src/import_validators.rs b/validator_manager/src/import_validators.rs index 24917f7d1b..0d6d358edb 100644 --- a/validator_manager/src/import_validators.rs +++ b/validator_manager/src/import_validators.rs @@ -112,8 +112,7 @@ pub fn cli_app() -> Command { .value_name("ETH1_ADDRESS") .help("When provided, the imported validator will use the suggested fee recipient. Omit this flag to use the default value from the VC.") .action(ArgAction::Set) - .display_order(0) - .requires(KEYSTORE_FILE_FLAG), + .display_order(0), ) .arg( Arg::new(GAS_LIMIT) @@ -122,8 +121,7 @@ pub fn cli_app() -> Command { .help("When provided, the imported validator will use this gas limit. It is recommended \ to leave this as the default value by not specifying this flag.",) .action(ArgAction::Set) - .display_order(0) - .requires(KEYSTORE_FILE_FLAG), + .display_order(0), ) .arg( Arg::new(BUILDER_PROPOSALS) @@ -132,8 +130,7 @@ pub fn cli_app() -> Command { blocks via builder rather than the local EL.",) .value_parser(["true","false"]) .action(ArgAction::Set) - .display_order(0) - .requires(KEYSTORE_FILE_FLAG), + .display_order(0), ) .arg( Arg::new(BUILDER_BOOST_FACTOR) @@ -144,8 +141,7 @@ pub fn cli_app() -> Command { when choosing between a builder payload header and payload from \ the local execution node.",) .action(ArgAction::Set) - .display_order(0) - .requires(KEYSTORE_FILE_FLAG), + .display_order(0), ) .arg( Arg::new(PREFER_BUILDER_PROPOSALS) @@ -154,8 +150,16 @@ pub fn cli_app() -> Command { constructed by builders, regardless of payload value.",) .value_parser(["true","false"]) .action(ArgAction::Set) - .display_order(0) - .requires(KEYSTORE_FILE_FLAG), + .display_order(0), + ) + .arg( + Arg::new(ENABLED) + .long(ENABLED) + .help("When provided, the imported validator will be \ + enabled or disabled.",) + .value_parser(["true","false"]) + .action(ArgAction::Set) + .display_order(0), ) } @@ -225,48 +229,113 @@ async fn run(config: ImportConfig) -> Result<(), String> { enabled, } = config; - let validators: Vec = - if let Some(validators_format_path) = &validators_file_path { - if !validators_format_path.exists() { - return Err(format!( - "Unable to find file at {:?}", - validators_format_path - )); - } + let validators: Vec = if let Some(validators_format_path) = + &validators_file_path + { + if !validators_format_path.exists() { + return Err(format!( + "Unable to find file at {:?}", + validators_format_path + )); + } - let validators_file = fs::OpenOptions::new() - .read(true) - .create(false) - .open(validators_format_path) - .map_err(|e| format!("Unable to open {:?}: {:?}", validators_format_path, e))?; + let validators_file = fs::OpenOptions::new() + .read(true) + .create(false) + .open(validators_format_path) + .map_err(|e| format!("Unable to open {:?}: {:?}", validators_format_path, e))?; - serde_json::from_reader(&validators_file).map_err(|e| { + // Define validators as mutable so that if a relevant flag is supplied, the fields can be overridden. + let mut validators: Vec = serde_json::from_reader(&validators_file) + .map_err(|e| { format!( "Unable to parse JSON in {:?}: {:?}", validators_format_path, e ) - })? - } else if let Some(keystore_format_path) = &keystore_file_path { - vec![ValidatorSpecification { - voting_keystore: KeystoreJsonStr( - Keystore::from_json_file(keystore_format_path).map_err(|e| format!("{e:?}"))?, - ), - voting_keystore_password: password.ok_or_else(|| { - "The --password flag is required to supply the keystore password".to_string() - })?, - slashing_protection: None, - fee_recipient, - gas_limit, - builder_proposals, - builder_boost_factor, - prefer_builder_proposals, - enabled, - }] - } else { - return Err(format!( - "One of the flag --{VALIDATORS_FILE_FLAG} or --{KEYSTORE_FILE_FLAG} is required." - )); - }; + })?; + + // Log the overridden note when one or more flags is supplied + if let Some(override_fee_recipient) = fee_recipient { + eprintln!( + "Please note! --suggested-fee-recipient is provided. This will override existing fee recipient defined in validators.json with: {:?}", + override_fee_recipient + ); + } + if let Some(override_gas_limit) = gas_limit { + eprintln!( + "Please note! --gas-limit is provided. This will override existing gas limit defined in validators.json with: {}", + override_gas_limit + ); + } + if let Some(override_builder_proposals) = builder_proposals { + eprintln!( + "Please note! --builder-proposals is provided. This will override existing builder proposal setting defined in validators.json with: {}", + override_builder_proposals + ); + } + if let Some(override_builder_boost_factor) = builder_boost_factor { + eprintln!( + "Please note! --builder-boost-factor is provided. This will override existing builder boost factor defined in validators.json with: {}", + override_builder_boost_factor + ); + } + if let Some(override_prefer_builder_proposals) = prefer_builder_proposals { + eprintln!( + "Please note! --prefer-builder-proposals is provided. This will override existing prefer builder proposal setting defined in validators.json with: {}", + override_prefer_builder_proposals + ); + } + if let Some(override_enabled) = enabled { + eprintln!( + "Please note! --enabled flag is provided. This will override existing setting defined in validators.json with: {}", + override_enabled + ); + } + + // Override the fields in validators.json file if the flag is supplied + for validator in &mut validators { + if let Some(override_fee_recipient) = fee_recipient { + validator.fee_recipient = Some(override_fee_recipient); + } + if let Some(override_gas_limit) = gas_limit { + validator.gas_limit = Some(override_gas_limit); + } + if let Some(override_builder_proposals) = builder_proposals { + validator.builder_proposals = Some(override_builder_proposals); + } + if let Some(override_builder_boost_factor) = builder_boost_factor { + validator.builder_boost_factor = Some(override_builder_boost_factor); + } + if let Some(override_prefer_builder_proposals) = prefer_builder_proposals { + validator.prefer_builder_proposals = Some(override_prefer_builder_proposals); + } + if let Some(override_enabled) = enabled { + validator.enabled = Some(override_enabled); + } + } + + validators + } else if let Some(keystore_format_path) = &keystore_file_path { + vec![ValidatorSpecification { + voting_keystore: KeystoreJsonStr( + Keystore::from_json_file(keystore_format_path).map_err(|e| format!("{e:?}"))?, + ), + voting_keystore_password: password.ok_or_else(|| { + "The --password flag is required to supply the keystore password".to_string() + })?, + slashing_protection: None, + fee_recipient, + gas_limit, + builder_proposals, + builder_boost_factor, + prefer_builder_proposals, + enabled, + }] + } else { + return Err(format!( + "One of the flag --{VALIDATORS_FILE_FLAG} or --{KEYSTORE_FILE_FLAG} is required." + )); + }; let count = validators.len(); From 561898fc1c74c11a1a765f252ae504f35263f6ed Mon Sep 17 00:00:00 2001 From: Pawan Dhananjay Date: Thu, 19 Feb 2026 06:08:56 +0530 Subject: [PATCH 028/189] Process head_chains in descending order of number of peers (#8859) N/A Another find by @gitToki. Sort the preferred_ids in descending order as originally intended from the comment in the function. Co-Authored-By: Pawan Dhananjay --- beacon_node/network/src/sync/range_sync/chain_collection.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beacon_node/network/src/sync/range_sync/chain_collection.rs b/beacon_node/network/src/sync/range_sync/chain_collection.rs index 1d57ee6c3d..bd4dd6c181 100644 --- a/beacon_node/network/src/sync/range_sync/chain_collection.rs +++ b/beacon_node/network/src/sync/range_sync/chain_collection.rs @@ -351,7 +351,8 @@ impl ChainCollection { .iter() .map(|(id, chain)| (chain.available_peers(), !chain.is_syncing(), *id)) .collect::>(); - preferred_ids.sort_unstable(); + // Sort in descending order + preferred_ids.sort_unstable_by(|a, b| b.cmp(a)); let mut syncing_chains = SmallVec::<[Id; PARALLEL_HEAD_CHAINS]>::new(); for (_, _, id) in preferred_ids { From 4588971085840dc56cedc85ba0f12bcaa99be8ed Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Thu, 19 Feb 2026 12:57:53 +1100 Subject: [PATCH 029/189] Add sync batch state metrics (#8847) Co-Authored-By: Jimmy Chen --- beacon_node/network/src/metrics.rs | 7 +++++ .../network/src/sync/backfill_sync/mod.rs | 20 +++++++++++++- beacon_node/network/src/sync/batch.rs | 26 ++++++++++++++++++- .../src/sync/custody_backfill_sync/mod.rs | 21 +++++++++++++-- beacon_node/network/src/sync/manager.rs | 3 +++ .../network/src/sync/range_sync/chain.rs | 11 +++++++- .../src/sync/range_sync/chain_collection.rs | 21 +++++++++++++++ .../network/src/sync/range_sync/range.rs | 4 +++ 8 files changed, 108 insertions(+), 5 deletions(-) diff --git a/beacon_node/network/src/metrics.rs b/beacon_node/network/src/metrics.rs index cea06a28c8..0fa95b4758 100644 --- a/beacon_node/network/src/metrics.rs +++ b/beacon_node/network/src/metrics.rs @@ -462,6 +462,13 @@ pub static SYNCING_CHAIN_BATCH_AWAITING_PROCESSING: LazyLock> ]), ) }); +pub static SYNCING_CHAIN_BATCHES: LazyLock> = LazyLock::new(|| { + try_create_int_gauge_vec( + "sync_batches", + "Number of batches in sync chains by sync type and state", + &["sync_type", "state"], + ) +}); pub static SYNC_SINGLE_BLOCK_LOOKUPS: LazyLock> = LazyLock::new(|| { try_create_int_gauge( "sync_single_block_lookups", diff --git a/beacon_node/network/src/sync/backfill_sync/mod.rs b/beacon_node/network/src/sync/backfill_sync/mod.rs index 9802ec56a1..f18d31863b 100644 --- a/beacon_node/network/src/sync/backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/backfill_sync/mod.rs @@ -8,9 +8,11 @@ //! If a batch fails, the backfill sync cannot progress. In this scenario, we mark the backfill //! sync as failed, log an error and attempt to retry once a new peer joins the node. +use crate::metrics; use crate::network_beacon_processor::ChainSegmentProcessId; use crate::sync::batch::{ - BatchConfig, BatchId, BatchInfo, BatchOperationOutcome, BatchProcessingResult, BatchState, + BatchConfig, BatchId, BatchInfo, BatchMetricsState, BatchOperationOutcome, + BatchProcessingResult, BatchState, }; use crate::sync::block_sidecar_coupling::CouplingError; use crate::sync::manager::BatchProcessResult; @@ -31,6 +33,7 @@ use std::collections::{ use std::hash::{Hash, Hasher}; use std::marker::PhantomData; use std::sync::Arc; +use strum::IntoEnumIterator; use tracing::{debug, error, info, warn}; use types::{ColumnIndex, Epoch, EthSpec}; @@ -1181,6 +1184,21 @@ impl BackFillSync { .epoch(T::EthSpec::slots_per_epoch()) } + pub fn register_metrics(&self) { + for state in BatchMetricsState::iter() { + let count = self + .batches + .values() + .filter(|b| b.state().metrics_state() == state) + .count(); + metrics::set_gauge_vec( + &metrics::SYNCING_CHAIN_BATCHES, + &["backfill", state.into()], + count as i64, + ); + } + } + /// Updates the global network state indicating the current state of a backfill sync. fn set_state(&self, state: BackFillState) { *self.network_globals.backfill_state.write() = state; diff --git a/beacon_node/network/src/sync/batch.rs b/beacon_node/network/src/sync/batch.rs index 8de386f5be..8f8d39ca4b 100644 --- a/beacon_node/network/src/sync/batch.rs +++ b/beacon_node/network/src/sync/batch.rs @@ -10,10 +10,22 @@ use std::marker::PhantomData; use std::ops::Sub; use std::time::Duration; use std::time::Instant; -use strum::Display; +use strum::{Display, EnumIter, IntoStaticStr}; use types::Slot; use types::{DataColumnSidecarList, Epoch, EthSpec}; +/// Batch states used as metrics labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub enum BatchMetricsState { + AwaitingDownload, + Downloading, + AwaitingProcessing, + Processing, + AwaitingValidation, + Failed, +} + pub type BatchId = Epoch; /// Type of expected batch. @@ -142,6 +154,18 @@ impl BatchState { pub fn poison(&mut self) -> BatchState { std::mem::replace(self, BatchState::Poisoned) } + + /// Returns the metrics state for this batch. + pub fn metrics_state(&self) -> BatchMetricsState { + match self { + BatchState::AwaitingDownload => BatchMetricsState::AwaitingDownload, + BatchState::Downloading(_) => BatchMetricsState::Downloading, + BatchState::AwaitingProcessing(..) => BatchMetricsState::AwaitingProcessing, + BatchState::Processing(_) => BatchMetricsState::Processing, + BatchState::AwaitingValidation(_) => BatchMetricsState::AwaitingValidation, + BatchState::Poisoned | BatchState::Failed => BatchMetricsState::Failed, + } + } } impl BatchInfo { diff --git a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs index fa8b70c8b4..893aa849d3 100644 --- a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs @@ -12,14 +12,16 @@ use lighthouse_network::{ }; use logging::crit; use std::hash::{DefaultHasher, Hash, Hasher}; +use strum::IntoEnumIterator; use tracing::{debug, error, info, info_span, warn}; use types::{DataColumnSidecarList, Epoch, EthSpec}; +use crate::metrics; use crate::sync::{ backfill_sync::{BACKFILL_EPOCHS_PER_BATCH, ProcessResult, SyncStart}, batch::{ - BatchConfig, BatchId, BatchInfo, BatchOperationOutcome, BatchProcessingResult, BatchState, - ByRangeRequestType, + BatchConfig, BatchId, BatchInfo, BatchMetricsState, BatchOperationOutcome, + BatchProcessingResult, BatchState, ByRangeRequestType, }, block_sidecar_coupling::CouplingError, manager::CustodyBatchProcessResult, @@ -1114,6 +1116,21 @@ impl CustodyBackFillSync { *self.network_globals.custody_sync_state.write() = state; } + pub fn register_metrics(&self) { + for state in BatchMetricsState::iter() { + let count = self + .batches + .values() + .filter(|b| b.state().metrics_state() == state) + .count(); + metrics::set_gauge_vec( + &metrics::SYNCING_CHAIN_BATCHES, + &["custody_backfill", state.into()], + count as i64, + ); + } + } + /// A fully synced peer has joined us. /// If we are in a failed state, update a local variable to indicate we are able to restart /// the failed sync on the next attempt. diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 096ed9c328..c2faff5b62 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -784,6 +784,9 @@ impl SyncManager { } _ = register_metrics_interval.tick() => { self.network.register_metrics(); + self.range_sync.register_metrics(); + self.backfill_sync.register_metrics(); + self.custody_backfill_sync.register_metrics(); } _ = epoch_interval.tick() => { self.update_sync_state(); diff --git a/beacon_node/network/src/sync/range_sync/chain.rs b/beacon_node/network/src/sync/range_sync/chain.rs index d67d6468a9..61161ae6f4 100644 --- a/beacon_node/network/src/sync/range_sync/chain.rs +++ b/beacon_node/network/src/sync/range_sync/chain.rs @@ -3,7 +3,8 @@ use crate::metrics; use crate::network_beacon_processor::ChainSegmentProcessId; use crate::sync::batch::BatchId; use crate::sync::batch::{ - BatchConfig, BatchInfo, BatchOperationOutcome, BatchProcessingResult, BatchState, + BatchConfig, BatchInfo, BatchMetricsState, BatchOperationOutcome, BatchProcessingResult, + BatchState, }; use crate::sync::block_sidecar_coupling::CouplingError; use crate::sync::network_context::{RangeRequestId, RpcRequestSendError, RpcResponseError}; @@ -234,6 +235,14 @@ impl SyncingChain { .sum() } + /// Returns the number of batches in the given metrics state. + pub fn count_batches_in_state(&self, state: BatchMetricsState) -> usize { + self.batches + .values() + .filter(|b| b.state().metrics_state() == state) + .count() + } + /// Removes a peer from the chain. /// If the peer has active batches, those are considered failed and re-requested. pub fn remove_peer(&mut self, peer_id: &PeerId) -> ProcessingResult { diff --git a/beacon_node/network/src/sync/range_sync/chain_collection.rs b/beacon_node/network/src/sync/range_sync/chain_collection.rs index bd4dd6c181..b430b7c572 100644 --- a/beacon_node/network/src/sync/range_sync/chain_collection.rs +++ b/beacon_node/network/src/sync/range_sync/chain_collection.rs @@ -6,6 +6,7 @@ use super::chain::{ChainId, ProcessingResult, RemoveChain, SyncingChain}; use super::sync_type::RangeSyncType; use crate::metrics; +use crate::sync::batch::BatchMetricsState; use crate::sync::network_context::SyncNetworkContext; use beacon_chain::{BeaconChain, BeaconChainTypes}; use fnv::FnvHashMap; @@ -17,6 +18,7 @@ use smallvec::SmallVec; use std::collections::HashMap; use std::collections::hash_map::Entry; use std::sync::Arc; +use strum::IntoEnumIterator; use tracing::{debug, error}; use types::EthSpec; use types::{Epoch, Hash256, Slot}; @@ -516,6 +518,25 @@ impl ChainCollection { } } + pub fn register_metrics(&self) { + for (sync_type, chains) in [ + ("range_finalized", &self.finalized_chains), + ("range_head", &self.head_chains), + ] { + for state in BatchMetricsState::iter() { + let count: usize = chains + .values() + .map(|chain| chain.count_batches_in_state(state)) + .sum(); + metrics::set_gauge_vec( + &metrics::SYNCING_CHAIN_BATCHES, + &[sync_type, state.into()], + count as i64, + ); + } + } + } + fn update_metrics(&self) { metrics::set_gauge_vec( &metrics::SYNCING_CHAINS_COUNT, diff --git a/beacon_node/network/src/sync/range_sync/range.rs b/beacon_node/network/src/sync/range_sync/range.rs index c9656ad1d0..4c2123451a 100644 --- a/beacon_node/network/src/sync/range_sync/range.rs +++ b/beacon_node/network/src/sync/range_sync/range.rs @@ -371,6 +371,10 @@ where .update(network, &local, &mut self.awaiting_head_peers); } + pub fn register_metrics(&self) { + self.chains.register_metrics(); + } + /// Kickstarts sync. pub fn resume(&mut self, network: &mut SyncNetworkContext) { for (removed_chain, sync_type, remove_reason) in From 2d91009ab4b6452b50371aa759e40a3a7dc9be4a Mon Sep 17 00:00:00 2001 From: Mac L Date: Thu, 19 Feb 2026 23:32:42 +0400 Subject: [PATCH 030/189] Bump sqlite deps to remove `hashlink 0.8` (#8866) #8547 Bump the following crates to remove `hashlink 0.8`: - `rusqlite` - `r2d2-sqlite` - `yaml-rust2` Co-Authored-By: Mac L --- Cargo.lock | 70 +++++++++++++------ Cargo.toml | 2 +- consensus/int_to_bytes/Cargo.toml | 2 +- .../slashing_protection/Cargo.toml | 2 +- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a8e76a8a8..419ba679db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3427,9 +3427,9 @@ dependencies = [ [[package]] name = "fallible-iterator" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] name = "fallible-streaming-iterator" @@ -3933,7 +3933,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", - "allocator-api2", ] [[package]] @@ -3958,15 +3957,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "hashlink" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" -dependencies = [ - "hashbrown 0.14.5", -] - [[package]] name = "hashlink" version = "0.9.1" @@ -3985,6 +3975,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "hdrhistogram" version = "7.5.4" @@ -5323,9 +5322,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.25.2" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29f835d03d717946d28b1d1ed632eb6f0e24a299388ee623d0c23118d3e8a7fa" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "cc", "pkg-config", @@ -7163,12 +7162,13 @@ dependencies = [ [[package]] name = "r2d2_sqlite" -version = "0.21.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4f5d0337e99cd5cacd91ffc326c6cc9d8078def459df560c4f9bf9ba4a51034" +checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6" dependencies = [ "r2d2", "rusqlite", + "uuid 1.19.0", ] [[package]] @@ -7503,6 +7503,16 @@ dependencies = [ "archery", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.17", +] + [[package]] name = "rtnetlink" version = "0.13.1" @@ -7558,16 +7568,17 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "rusqlite" -version = "0.28.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01e213bc3ecb39ac32e81e51ebe31fd888a940515173e3a18a35f8c6e896422a" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", "fallible-iterator", "fallible-streaming-iterator", - "hashlink 0.8.4", + "hashlink 0.11.0", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -8374,6 +8385,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "ssz_types" version = "0.14.0" @@ -9514,6 +9537,7 @@ checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", + "rand 0.9.2", "wasm-bindgen", ] @@ -10479,13 +10503,13 @@ dependencies = [ [[package]] name = "yaml-rust2" -version = "0.8.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e" dependencies = [ "arraydeque", "encoding_rs", - "hashlink 0.8.4", + "hashlink 0.11.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 98e8c057b5..44f3a60b2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -227,7 +227,7 @@ reqwest = { version = "0.12", default-features = false, features = [ ] } ring = "0.17" rpds = "0.11" -rusqlite = { version = "0.28", features = ["bundled"] } +rusqlite = { version = "0.38", features = ["bundled"] } rust_eth_kzg = "0.9" safe_arith = "0.1" sensitive_url = { version = "0.1", features = ["serde"] } diff --git a/consensus/int_to_bytes/Cargo.toml b/consensus/int_to_bytes/Cargo.toml index c639dfce8d..75196d7437 100644 --- a/consensus/int_to_bytes/Cargo.toml +++ b/consensus/int_to_bytes/Cargo.toml @@ -9,4 +9,4 @@ bytes = { workspace = true } [dev-dependencies] hex = { workspace = true } -yaml-rust2 = "0.8" +yaml-rust2 = "0.11" diff --git a/validator_client/slashing_protection/Cargo.toml b/validator_client/slashing_protection/Cargo.toml index 86df6d01fe..45244c2e62 100644 --- a/validator_client/slashing_protection/Cargo.toml +++ b/validator_client/slashing_protection/Cargo.toml @@ -17,7 +17,7 @@ ethereum_serde_utils = { workspace = true } filesystem = { workspace = true } fixed_bytes = { workspace = true } r2d2 = { workspace = true } -r2d2_sqlite = "0.21.0" +r2d2_sqlite = "0.32" rusqlite = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } From 9cb72100d4c6f008ee5f2ac7274bd7f12128b4eb Mon Sep 17 00:00:00 2001 From: Mac L Date: Thu, 19 Feb 2026 23:32:46 +0400 Subject: [PATCH 031/189] Feature-gate all uses of `arbitrary` (#8867) Feature gate all uses of `arbitrary` so it is not compiled during release builds. Co-Authored-By: Mac L --- Cargo.toml | 2 +- consensus/state_processing/Cargo.toml | 4 +++- consensus/state_processing/src/verify_operation.rs | 12 +++++++++--- consensus/types/Cargo.toml | 1 + crypto/bls/Cargo.toml | 4 ++-- crypto/kzg/Cargo.toml | 3 ++- crypto/kzg/src/kzg_commitment.rs | 1 + crypto/kzg/src/kzg_proof.rs | 1 + validator_client/slashing_protection/Cargo.toml | 4 ++-- 9 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 44f3a60b2f..3b5a7dd6ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -240,7 +240,7 @@ signing_method = { path = "validator_client/signing_method" } slasher = { path = "slasher", default-features = false } slashing_protection = { path = "validator_client/slashing_protection" } slot_clock = { path = "common/slot_clock" } -smallvec = { version = "1.11.2", features = ["arbitrary"] } +smallvec = "1" snap = "1" ssz_types = { version = "0.14.0", features = ["context_deserialize", "runtime_types"] } state_processing = { path = "consensus/state_processing" } diff --git a/consensus/state_processing/Cargo.toml b/consensus/state_processing/Cargo.toml index a83e443e80..7426995439 100644 --- a/consensus/state_processing/Cargo.toml +++ b/consensus/state_processing/Cargo.toml @@ -8,6 +8,8 @@ edition = { workspace = true } default = [] fake_crypto = ["bls/fake_crypto"] arbitrary-fuzz = [ + "dep:arbitrary", + "smallvec/arbitrary", "types/arbitrary-fuzz", "merkle_proof/arbitrary", "ethereum_ssz/arbitrary", @@ -17,7 +19,7 @@ arbitrary-fuzz = [ portable = ["bls/supranational-portable"] [dependencies] -arbitrary = { workspace = true } +arbitrary = { workspace = true, optional = true } bls = { workspace = true } educe = { workspace = true } ethereum_hashing = { workspace = true } diff --git a/consensus/state_processing/src/verify_operation.rs b/consensus/state_processing/src/verify_operation.rs index 1f76f19586..a13786f9f6 100644 --- a/consensus/state_processing/src/verify_operation.rs +++ b/consensus/state_processing/src/verify_operation.rs @@ -7,6 +7,7 @@ use crate::per_block_processing::{ verify_attester_slashing, verify_bls_to_execution_change, verify_exit, verify_proposer_slashing, }; +#[cfg(feature = "arbitrary-fuzz")] use arbitrary::Arbitrary; use educe::Educe; use smallvec::{SmallVec, smallvec}; @@ -39,13 +40,17 @@ pub trait TransformPersist { /// /// The inner `op` field is private, meaning instances of this type can only be constructed /// by calling `validate`. -#[derive(Educe, Debug, Clone, Arbitrary)] +#[derive(Educe, Debug, Clone)] +#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] #[educe( PartialEq, Eq, Hash(bound(T: TransformPersist + std::hash::Hash, E: EthSpec)) )] -#[arbitrary(bound = "T: TransformPersist + Arbitrary<'arbitrary>, E: EthSpec")] +#[cfg_attr( + feature = "arbitrary-fuzz", + arbitrary(bound = "T: TransformPersist + Arbitrary<'arbitrary>, E: EthSpec") +)] pub struct SigVerifiedOp { op: T, verified_against: VerifiedAgainst, @@ -133,7 +138,8 @@ struct SigVerifiedOpDecode { /// /// We need to store multiple `ForkVersion`s because attester slashings contain two indexed /// attestations which may be signed using different versions. -#[derive(Debug, PartialEq, Eq, Clone, Hash, Encode, Decode, TestRandom, Arbitrary)] +#[derive(Debug, PartialEq, Eq, Clone, Hash, Encode, Decode, TestRandom)] +#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] pub struct VerifiedAgainst { fork_versions: SmallVec<[ForkVersion; MAX_FORKS_VERIFIED_AGAINST]>, } diff --git a/consensus/types/Cargo.toml b/consensus/types/Cargo.toml index a4b879ddb2..e7e382714b 100644 --- a/consensus/types/Cargo.toml +++ b/consensus/types/Cargo.toml @@ -16,6 +16,7 @@ sqlite = ["dep:rusqlite"] arbitrary = [ "dep:arbitrary", "bls/arbitrary", + "kzg/arbitrary", "ethereum_ssz/arbitrary", "milhouse/arbitrary", "ssz_types/arbitrary", diff --git a/crypto/bls/Cargo.toml b/crypto/bls/Cargo.toml index 4661288679..ac04e1fecf 100644 --- a/crypto/bls/Cargo.toml +++ b/crypto/bls/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Paul Hauner "] edition = { workspace = true } [features] -arbitrary = [] +arbitrary = ["dep:arbitrary"] default = ["supranational"] fake_crypto = [] supranational = ["blst"] @@ -14,7 +14,7 @@ supranational-force-adx = ["supranational", "blst/force-adx"] [dependencies] alloy-primitives = { workspace = true } -arbitrary = { workspace = true } +arbitrary = { workspace = true, optional = true } blst = { version = "0.3.3", optional = true } ethereum_hashing = { workspace = true } ethereum_serde_utils = { workspace = true } diff --git a/crypto/kzg/Cargo.toml b/crypto/kzg/Cargo.toml index d2558663d5..840f8cfc9c 100644 --- a/crypto/kzg/Cargo.toml +++ b/crypto/kzg/Cargo.toml @@ -7,10 +7,11 @@ edition = "2021" [features] default = [] +arbitrary = ["dep:arbitrary"] fake_crypto = [] [dependencies] -arbitrary = { workspace = true } +arbitrary = { workspace = true, optional = true } c-kzg = { workspace = true } educe = { workspace = true } ethereum_hashing = { workspace = true } diff --git a/crypto/kzg/src/kzg_commitment.rs b/crypto/kzg/src/kzg_commitment.rs index 5a5e689429..bc5fc5f5aa 100644 --- a/crypto/kzg/src/kzg_commitment.rs +++ b/crypto/kzg/src/kzg_commitment.rs @@ -114,6 +114,7 @@ impl Debug for KzgCommitment { } } +#[cfg(feature = "arbitrary")] impl arbitrary::Arbitrary<'_> for KzgCommitment { fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result { let mut bytes = [0u8; BYTES_PER_COMMITMENT]; diff --git a/crypto/kzg/src/kzg_proof.rs b/crypto/kzg/src/kzg_proof.rs index 5a83466d0c..aa9ed185a0 100644 --- a/crypto/kzg/src/kzg_proof.rs +++ b/crypto/kzg/src/kzg_proof.rs @@ -110,6 +110,7 @@ impl Debug for KzgProof { } } +#[cfg(feature = "arbitrary")] impl arbitrary::Arbitrary<'_> for KzgProof { fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result { let mut bytes = [0u8; BYTES_PER_PROOF]; diff --git a/validator_client/slashing_protection/Cargo.toml b/validator_client/slashing_protection/Cargo.toml index 45244c2e62..695a693385 100644 --- a/validator_client/slashing_protection/Cargo.toml +++ b/validator_client/slashing_protection/Cargo.toml @@ -6,11 +6,11 @@ edition = { workspace = true } autotests = false [features] -arbitrary-fuzz = ["types/arbitrary-fuzz", "eip_3076/arbitrary-fuzz"] +arbitrary-fuzz = ["dep:arbitrary", "types/arbitrary-fuzz", "eip_3076/arbitrary-fuzz"] portable = ["types/portable"] [dependencies] -arbitrary = { workspace = true, features = ["derive"] } +arbitrary = { workspace = true, features = ["derive"], optional = true } bls = { workspace = true } eip_3076 = { workspace = true, features = ["json"] } ethereum_serde_utils = { workspace = true } From 8d4af658bd5f33be3ac1c3d41443938c3808ddef Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Fri, 20 Feb 2026 15:27:33 +1100 Subject: [PATCH 032/189] Remove unreachable void pattern for ConnectionLimits (#8871) Co-Authored-By: Jimmy Chen --- beacon_node/lighthouse_network/src/service/mod.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/beacon_node/lighthouse_network/src/service/mod.rs b/beacon_node/lighthouse_network/src/service/mod.rs index 3d709ed9b5..94e0ad0710 100644 --- a/beacon_node/lighthouse_network/src/service/mod.rs +++ b/beacon_node/lighthouse_network/src/service/mod.rs @@ -1861,8 +1861,6 @@ impl Network { self.inject_upnp_event(e); None } - #[allow(unreachable_patterns)] - BehaviourEvent::ConnectionLimits(le) => libp2p::core::util::unreachable(le), }, SwarmEvent::ConnectionEstablished { .. } => None, SwarmEvent::ConnectionClosed { .. } => None, From 48071b7ae722ac915c678fe518110aee988e6d74 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Sat, 21 Feb 2026 01:22:13 +1100 Subject: [PATCH 033/189] Add --jwt-secret-path to lcli mock-el (#8864) Co-Authored-By: Jimmy Chen --- lcli/src/main.rs | 12 +++++++++++- lcli/src/mock_el.rs | 24 ++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/lcli/src/main.rs b/lcli/src/main.rs index a21dfd4386..63dd0f2c5b 100644 --- a/lcli/src/main.rs +++ b/lcli/src/main.rs @@ -492,10 +492,20 @@ fn main() { .long("jwt-output-path") .value_name("PATH") .action(ArgAction::Set) - .required(true) + .required_unless_present("jwt-secret-path") + .conflicts_with("jwt-secret-path") .help("Path to write the JWT secret.") .display_order(0) ) + .arg( + Arg::new("jwt-secret-path") + .long("jwt-secret-path") + .value_name("PATH") + .action(ArgAction::Set) + .help("Path to an existing hex-encoded JWT secret file. \ + When provided, this secret is used instead of the default.") + .display_order(0) + ) .arg( Arg::new("listen-address") .long("listen-address") diff --git a/lcli/src/mock_el.rs b/lcli/src/mock_el.rs index d6bdfb0d71..544010b6a2 100644 --- a/lcli/src/mock_el.rs +++ b/lcli/src/mock_el.rs @@ -2,7 +2,7 @@ use clap::ArgMatches; use clap_utils::{parse_optional, parse_required}; use environment::Environment; use execution_layer::{ - auth::JwtKey, + auth::{JwtKey, strip_prefix}, test_utils::{ Config, DEFAULT_JWT_SECRET, DEFAULT_TERMINAL_BLOCK, MockExecutionConfig, MockServer, }, @@ -13,7 +13,8 @@ use std::sync::Arc; use types::*; pub fn run(mut env: Environment, matches: &ArgMatches) -> Result<(), String> { - let jwt_path: PathBuf = parse_required(matches, "jwt-output-path")?; + let jwt_output_path: Option = parse_optional(matches, "jwt-output-path")?; + let jwt_secret_path: Option = parse_optional(matches, "jwt-secret-path")?; let listen_addr: Ipv4Addr = parse_required(matches, "listen-address")?; let listen_port: u16 = parse_required(matches, "listen-port")?; let all_payloads_valid: bool = parse_required(matches, "all-payloads-valid")?; @@ -25,8 +26,23 @@ pub fn run(mut env: Environment, matches: &ArgMatches) -> Result< let handle = env.core_context().executor.handle().unwrap(); let spec = Arc::new(E::default_spec()); - let jwt_key = JwtKey::from_slice(&DEFAULT_JWT_SECRET).unwrap(); - std::fs::write(jwt_path, hex::encode(DEFAULT_JWT_SECRET)).unwrap(); + + let jwt_key = if let Some(secret_path) = jwt_secret_path { + let hex_str = std::fs::read_to_string(&secret_path) + .map_err(|e| format!("Failed to read JWT secret file: {}", e))?; + let secret_bytes = hex::decode(strip_prefix(hex_str.trim())) + .map_err(|e| format!("Invalid hex in JWT secret file: {}", e))?; + JwtKey::from_slice(&secret_bytes) + .map_err(|e| format!("Invalid JWT secret length (expected 32 bytes): {}", e))? + } else if let Some(jwt_path) = jwt_output_path { + let jwt_key = JwtKey::from_slice(&DEFAULT_JWT_SECRET) + .map_err(|e| format!("Default JWT secret invalid: {}", e))?; + std::fs::write(jwt_path, hex::encode(jwt_key.as_bytes())) + .map_err(|e| format!("Failed to write JWT secret to output path: {}", e))?; + jwt_key + } else { + return Err("either --jwt-secret-path or --jwt-output-path must be provided".to_string()); + }; let config = MockExecutionConfig { server_config: Config { From 9452d5186729aab2d24460d4a293606f875409f6 Mon Sep 17 00:00:00 2001 From: Mac L Date: Sun, 22 Feb 2026 02:03:59 +0400 Subject: [PATCH 034/189] Bump `uuid` to remove duplicate (#8874) #8547 Bump the version of `uuid` in our Cargo.toml to version `1` which removes `uuid 0.8` and unifies it across the workspace to version `1.19.0`. Co-Authored-By: Mac L --- Cargo.lock | 19 +++++-------------- Cargo.toml | 2 +- deny.toml | 1 + 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 419ba679db..eccdc8b29c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3199,7 +3199,7 @@ dependencies = [ "sha2", "tempfile", "unicode-normalization", - "uuid 0.8.2", + "uuid", "zeroize", ] @@ -3239,7 +3239,7 @@ dependencies = [ "serde_repr", "tempfile", "tiny-bip39", - "uuid 0.8.2", + "uuid", ] [[package]] @@ -5901,7 +5901,7 @@ dependencies = [ "rustc_version 0.4.1", "smallvec", "tagptr", - "uuid 1.19.0", + "uuid", ] [[package]] @@ -7168,7 +7168,7 @@ checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6" dependencies = [ "r2d2", "rusqlite", - "uuid 1.19.0", + "uuid", ] [[package]] @@ -9519,16 +9519,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" -dependencies = [ - "getrandom 0.2.16", - "serde", -] - [[package]] name = "uuid" version = "1.19.0" @@ -9538,6 +9528,7 @@ dependencies = [ "getrandom 0.3.4", "js-sys", "rand 0.9.2", + "serde_core", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 3b5a7dd6ba..f735b97540 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -273,7 +273,7 @@ tree_hash_derive = "0.12.0" typenum = "1" types = { path = "consensus/types", features = ["saturating-arith"] } url = "2" -uuid = { version = "0.8", features = ["serde", "v4"] } +uuid = { version = "1", features = ["serde", "v4"] } validator_client = { path = "validator_client" } validator_dir = { path = "common/validator_dir" } validator_http_api = { path = "validator_client/http_api" } diff --git a/deny.toml b/deny.toml index e6c30f6a48..3b230155f7 100644 --- a/deny.toml +++ b/deny.toml @@ -18,6 +18,7 @@ deny = [ { crate = "pbkdf2", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "scrypt", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "syn", deny-multiple-versions = true, reason = "takes a long time to compile" }, + { crate = "uuid", deny-multiple-versions = true, reason = "dependency hygiene" }, ] [sources] From 2b214175d5001b3022321cb0bfcacb13a4ab0d0d Mon Sep 17 00:00:00 2001 From: 0xMushow <105550256+0xMushow@users.noreply.github.com> Date: Mon, 23 Feb 2026 06:02:56 +0400 Subject: [PATCH 035/189] Enforce stricter checks on certain constants (#8500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which issue # does this PR address? None All of these are performing a check, and adding a batch, or creating a new lookup, or a new query, etc.. Hence all of these limits would be off by one. Example: ```rust // BACKFILL_BATCH_BUFFER_SIZE = 5 if self.batches.iter().filter(...).count() >= BACKFILL_BATCH_BUFFER_SIZE { return None; // ← REJECT } // ... later adds batch via Entry::Vacant(entry).insert(...) ``` Without the `>` being changed to a `>=` , we would allow 6. The same idea applies to all changes proposed. Co-Authored-By: Antoine James Co-Authored-By: Jimmy Chen Co-Authored-By: Jimmy Chen --- beacon_node/lighthouse_network/src/discovery/mod.rs | 2 +- beacon_node/network/src/sync/backfill_sync/mod.rs | 2 +- beacon_node/network/src/sync/block_lookups/mod.rs | 2 +- beacon_node/network/src/sync/custody_backfill_sync/mod.rs | 2 +- beacon_node/network/src/sync/network_context/custody.rs | 2 +- beacon_node/network/src/sync/range_sync/chain.rs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/beacon_node/lighthouse_network/src/discovery/mod.rs b/beacon_node/lighthouse_network/src/discovery/mod.rs index 38a6a84b44..21b1146aff 100644 --- a/beacon_node/lighthouse_network/src/discovery/mod.rs +++ b/beacon_node/lighthouse_network/src/discovery/mod.rs @@ -674,7 +674,7 @@ impl Discovery { /// updates the min_ttl field. fn add_subnet_query(&mut self, subnet: Subnet, min_ttl: Option, retries: usize) { // remove the entry and complete the query if greater than the maximum search count - if retries > MAX_DISCOVERY_RETRY { + if retries >= MAX_DISCOVERY_RETRY { debug!("Subnet peer discovery did not find sufficient peers. Reached max retry limit"); return; } diff --git a/beacon_node/network/src/sync/backfill_sync/mod.rs b/beacon_node/network/src/sync/backfill_sync/mod.rs index 9802ec56a1..7ef72c7f3a 100644 --- a/beacon_node/network/src/sync/backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/backfill_sync/mod.rs @@ -1071,7 +1071,7 @@ impl BackFillSync { .iter() .filter(|&(_epoch, batch)| in_buffer(batch)) .count() - > BACKFILL_BATCH_BUFFER_SIZE as usize + >= BACKFILL_BATCH_BUFFER_SIZE as usize { return None; } diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index cbf65505ef..394f2fc37d 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -398,7 +398,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() >= MAX_LOOKUPS { warn!(?block_root, "Dropping lookup reached max"); return false; } diff --git a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs index fa8b70c8b4..a964ad9a3c 100644 --- a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs @@ -422,7 +422,7 @@ impl CustodyBackFillSync { .iter() .filter(|&(_epoch, batch)| in_buffer(batch)) .count() - > BACKFILL_BATCH_BUFFER_SIZE as usize + >= BACKFILL_BATCH_BUFFER_SIZE as usize { return None; } diff --git a/beacon_node/network/src/sync/network_context/custody.rs b/beacon_node/network/src/sync/network_context/custody.rs index de5d9b6e0b..ae0eee9964 100644 --- a/beacon_node/network/src/sync/network_context/custody.rs +++ b/beacon_node/network/src/sync/network_context/custody.rs @@ -239,7 +239,7 @@ impl ActiveCustodyRequest { if let Some(wait_duration) = request.is_awaiting_download() { // Note: an empty response is considered a successful response, so we may end up // retrying many more times than `MAX_CUSTODY_COLUMN_DOWNLOAD_ATTEMPTS`. - if request.download_failures > MAX_CUSTODY_COLUMN_DOWNLOAD_ATTEMPTS { + if request.download_failures >= MAX_CUSTODY_COLUMN_DOWNLOAD_ATTEMPTS { return Err(Error::TooManyFailures); } diff --git a/beacon_node/network/src/sync/range_sync/chain.rs b/beacon_node/network/src/sync/range_sync/chain.rs index d67d6468a9..25ea1af76a 100644 --- a/beacon_node/network/src/sync/range_sync/chain.rs +++ b/beacon_node/network/src/sync/range_sync/chain.rs @@ -1277,7 +1277,7 @@ impl SyncingChain { .iter() .filter(|&(_epoch, batch)| in_buffer(batch)) .count() - > BATCH_BUFFER_SIZE as usize + >= BATCH_BUFFER_SIZE as usize { return None; } From dcc43e3d20f44146963aa880fd46cda9e53bda04 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 22 Feb 2026 22:17:24 -0800 Subject: [PATCH 036/189] Implement gloas block gossip verification changes (#8878) Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/beacon_chain.rs | 18 ++-- .../beacon_chain/src/block_verification.rs | 84 +++++++++++++++---- .../beacon_chain/src/execution_payload.rs | 11 ++- .../gossip_methods.rs | 33 ++++---- consensus/types/src/block/beacon_block.rs | 20 +++++ 5 files changed, 132 insertions(+), 34 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 9f62bf11f5..26ad2e714b 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -3378,11 +3378,19 @@ impl BeaconChain { ); } - self.data_availability_checker.put_pre_execution_block( - block_root, - unverified_block.block_cloned(), - block_source, - )?; + // Gloas blocks dont need to be inserted into the DA cache + // they are always available. + if !unverified_block + .block() + .fork_name_unchecked() + .gloas_enabled() + { + self.data_availability_checker.put_pre_execution_block( + block_root, + unverified_block.block_cloned(), + block_source, + )?; + } // Start the Prometheus timer. let _full_timer = metrics::start_timer(&metrics::BLOCK_PROCESSING_TIMES); diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index e0943d5d93..292560d6a7 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -51,7 +51,9 @@ use crate::beacon_snapshot::PreProcessingSnapshot; use crate::blob_verification::GossipBlobError; use crate::block_verification_types::{AsBlock, BlockImportData, RpcBlock}; -use crate::data_availability_checker::{AvailabilityCheckError, MaybeAvailableBlock}; +use crate::data_availability_checker::{ + AvailabilityCheckError, AvailableBlock, AvailableBlockData, MaybeAvailableBlock, +}; use crate::data_column_verification::GossipDataColumnError; use crate::execution_payload::{ AllowOptimisticImport, NotifyExecutionLayer, PayloadNotifier, @@ -334,6 +336,15 @@ pub enum BlockError { max_blobs_at_epoch: usize, block: usize, }, + /// The bid's parent_block_root does not match the block's parent_root. + /// + /// ## Peer scoring + /// + /// The block is invalid and the peer should be penalized. + BidParentRootMismatch { + bid_parent_root: Hash256, + block_parent_root: Hash256, + }, } /// Which specific signature(s) are invalid in a SignedBeaconBlock @@ -887,15 +898,15 @@ impl GossipVerifiedBlock { // Do not gossip blocks that claim to contain more blobs than the max allowed // at the given block epoch. - if let Ok(commitments) = block.message().body().blob_kzg_commitments() { + if let Some(blob_kzg_commitments_len) = block.message().blob_kzg_commitments_len() { let max_blobs_at_epoch = chain .spec .max_blobs_per_block(block.slot().epoch(T::EthSpec::slots_per_epoch())) as usize; - if commitments.len() > max_blobs_at_epoch { + if blob_kzg_commitments_len > max_blobs_at_epoch { return Err(BlockError::InvalidBlobCount { max_blobs_at_epoch, - block: commitments.len(), + block: blob_kzg_commitments_len, }); } } @@ -932,6 +943,24 @@ impl GossipVerifiedBlock { let block_epoch = block.slot().epoch(T::EthSpec::slots_per_epoch()); let (parent_block, block) = verify_parent_block_is_known::(&fork_choice_read_lock, block)?; + + // [New in Gloas]: Verify bid.parent_block_root matches block.parent_root. + if let Ok(bid) = block.message().body().signed_execution_payload_bid() + && bid.message.parent_block_root != block.message().parent_root() + { + return Err(BlockError::BidParentRootMismatch { + bid_parent_root: bid.message.parent_block_root, + block_parent_root: block.message().parent_root(), + }); + } + + // TODO(gloas) The following validation can only be completed once fork choice has been implemented: + // The block's parent execution payload (defined by bid.parent_block_hash) has been seen + // (via gossip or non-gossip sources) (a client MAY queue blocks for processing + // once the parent payload is retrieved). If execution_payload verification of block's execution + // payload parent by an execution node is complete, verify the block's execution payload + // parent (defined by bid.parent_block_hash) passes all validation. + drop(fork_choice_read_lock); // Track the number of skip slots between the block and its parent. @@ -1038,8 +1067,15 @@ impl GossipVerifiedBlock { }); } - // Validate the block's execution_payload (if any). - validate_execution_payload_for_gossip(&parent_block, block.message(), chain)?; + // [New in Gloas]: Skip payload validation checks. The payload now arrives separately + // via `ExecutionPayloadEnvelope`. + if !chain + .spec + .fork_name_at_slot::(block.slot()) + .gloas_enabled() + { + validate_execution_payload_for_gossip(&parent_block, block.message(), chain)?; + } // Beacon API block_gossip events if let Some(event_handler) = chain.event_handler.as_ref() @@ -1211,15 +1247,35 @@ impl SignatureVerifiedBlock { let result = info_span!("signature_verify").in_scope(|| signature_verifier.verify()); match result { - Ok(_) => Ok(Self { - block: MaybeAvailableBlock::AvailabilityPending { + Ok(_) => { + // gloas blocks are always available. + let maybe_available = if chain + .spec + .fork_name_at_slot::(block.slot()) + .gloas_enabled() + { + MaybeAvailableBlock::Available( + AvailableBlock::new( + block, + AvailableBlockData::NoData, + &chain.data_availability_checker, + chain.spec.clone(), + ) + .map_err(BlockError::AvailabilityCheck)?, + ) + } else { + MaybeAvailableBlock::AvailabilityPending { + block_root: from.block_root, + block, + } + }; + Ok(Self { + block: maybe_available, block_root: from.block_root, - block, - }, - block_root: from.block_root, - parent: Some(parent), - consensus_context, - }), + parent: Some(parent), + consensus_context, + }) + } Err(_) => Err(BlockError::InvalidSignature( InvalidSignature::BlockBodySignatures, )), diff --git a/beacon_node/beacon_chain/src/execution_payload.rs b/beacon_node/beacon_chain/src/execution_payload.rs index bdf3ab9594..f32a3ba2a3 100644 --- a/beacon_node/beacon_chain/src/execution_payload.rs +++ b/beacon_node/beacon_chain/src/execution_payload.rs @@ -62,7 +62,10 @@ impl PayloadNotifier { state: &BeaconState, notify_execution_layer: NotifyExecutionLayer, ) -> Result { - let payload_verification_status = if is_execution_enabled(state, block.message().body()) { + let payload_verification_status = if block.fork_name_unchecked().gloas_enabled() { + // Gloas blocks don't contain an execution payload. + Some(PayloadVerificationStatus::Irrelevant) + } else if is_execution_enabled(state, block.message().body()) { // Perform the initial stages of payload verification. // // We will duplicate these checks again during `per_block_processing`, however these @@ -294,6 +297,12 @@ pub fn validate_execution_payload_for_gossip( block: BeaconBlockRef<'_, T::EthSpec>, chain: &BeaconChain, ) -> Result<(), BlockError> { + // Gloas blocks don't have an execution payload in the block body. + // Bid-related validations are handled in gossip block verification. + if block.fork_name_unchecked().gloas_enabled() { + return Ok(()); + } + // Only apply this validation if this is a Bellatrix beacon block. if let Ok(execution_payload) = block.body().execution_payload() { // This logic should match `is_execution_enabled`. We use only the execution block hash of diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index a9198f1943..e90018c851 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -1356,7 +1356,8 @@ impl NetworkBeaconProcessor { | Err(e @ BlockError::ParentExecutionPayloadInvalid { .. }) | Err(e @ BlockError::KnownInvalidExecutionPayload(_)) | Err(e @ BlockError::GenesisBlock) - | Err(e @ BlockError::InvalidBlobCount { .. }) => { + | Err(e @ BlockError::InvalidBlobCount { .. }) + | Err(e @ BlockError::BidParentRootMismatch { .. }) => { warn!(error = %e, "Could not verify block for gossip. Rejecting the block"); self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); self.gossip_penalize_peer( @@ -1490,19 +1491,23 @@ impl NetworkBeaconProcessor { // Block is gossip valid. Attempt to fetch blobs from the EL using versioned hashes derived // from kzg commitments, without having to wait for all blobs to be sent from the peers. - let publish_blobs = true; - let self_clone = self.clone(); - let block_clone = block.clone(); - let current_span = Span::current(); - self.executor.spawn( - async move { - self_clone - .fetch_engine_blobs_and_publish(block_clone, block_root, publish_blobs) - .await - } - .instrument(current_span), - "fetch_blobs_gossip", - ); + // TODO(gloas) we'll want to use this same optimization, but we need to refactor the + // `fetch_and_process_engine_blobs` flow to support gloas. + if !block.fork_name_unchecked().gloas_enabled() { + let publish_blobs = true; + let self_clone = self.clone(); + let block_clone = block.clone(); + let current_span = Span::current(); + self.executor.spawn( + async move { + self_clone + .fetch_engine_blobs_and_publish(block_clone, block_root, publish_blobs) + .await + } + .instrument(current_span), + "fetch_blobs_gossip", + ); + } let result = self .chain diff --git a/consensus/types/src/block/beacon_block.rs b/consensus/types/src/block/beacon_block.rs index bee3cdb274..5634d842b6 100644 --- a/consensus/types/src/block/beacon_block.rs +++ b/consensus/types/src/block/beacon_block.rs @@ -309,6 +309,26 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> BeaconBlockRef<'a, E, Payl pub fn execution_payload(&self) -> Result, BeaconStateError> { self.body().execution_payload() } + + pub fn blob_kzg_commitments_len(&self) -> Option { + match self { + BeaconBlockRef::Base(_) => None, + BeaconBlockRef::Altair(_) => None, + BeaconBlockRef::Bellatrix(_) => None, + BeaconBlockRef::Capella(_) => None, + BeaconBlockRef::Deneb(block) => Some(block.body.blob_kzg_commitments.len()), + BeaconBlockRef::Electra(block) => Some(block.body.blob_kzg_commitments.len()), + BeaconBlockRef::Fulu(block) => Some(block.body.blob_kzg_commitments.len()), + BeaconBlockRef::Gloas(block) => Some( + block + .body + .signed_execution_payload_bid + .message + .blob_kzg_commitments + .len(), + ), + } + } } impl<'a, E: EthSpec, Payload: AbstractExecPayload> BeaconBlockRefMut<'a, E, Payload> { From 341682e7196a598d2e767e655d37ce370d27a350 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Tue, 24 Feb 2026 11:15:39 +1100 Subject: [PATCH 037/189] Add unit tests for BatchInfo and fix doc comments (#8873) Co-Authored-By: Jimmy Chen --- beacon_node/network/src/sync/batch.rs | 203 +++++++++++++++++- .../network/src/sync/range_sync/mod.rs | 2 + 2 files changed, 202 insertions(+), 3 deletions(-) diff --git a/beacon_node/network/src/sync/batch.rs b/beacon_node/network/src/sync/batch.rs index 8de386f5be..f9a1fcce39 100644 --- a/beacon_node/network/src/sync/batch.rs +++ b/beacon_node/network/src/sync/batch.rs @@ -213,6 +213,9 @@ impl BatchInfo { /// After different operations over a batch, this could be in a state that allows it to /// continue, or in failed state. When the batch has failed, we check if it did mainly due to /// processing failures. In this case the batch is considered failed and faulty. + /// + /// When failure counts are equal, `blacklist` is `false` — we assume network issues over + /// peer fault when the evidence is ambiguous. pub fn outcome(&self) -> BatchOperationOutcome { match self.state { BatchState::Poisoned => unreachable!("Poisoned batch"), @@ -255,8 +258,10 @@ impl BatchInfo { /// Mark the batch as failed and return whether we can attempt a re-download. /// /// This can happen if a peer disconnects or some error occurred that was not the peers fault. - /// The `peer` parameter, when set to None, does not increment the failed attempts of - /// this batch and register the peer, rather attempts a re-download. + /// The `peer` parameter, when set to `None`, still counts toward + /// `max_batch_download_attempts` (to prevent infinite retries on persistent failures) + /// but does not register a peer in `failed_peers()`. Use + /// [`Self::downloading_to_awaiting_download`] to retry without counting a failed attempt. #[must_use = "Batch may have failed"] pub fn download_failed( &mut self, @@ -272,7 +277,6 @@ impl BatchInfo { { BatchState::Failed } else { - // drop the blocks BatchState::AwaitingDownload }; Ok(self.outcome()) @@ -524,3 +528,196 @@ impl BatchState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync::range_sync::RangeSyncBatchConfig; + use types::MinimalEthSpec; + + type Cfg = RangeSyncBatchConfig; + type TestBatch = BatchInfo>; + + fn max_dl() -> u8 { + Cfg::max_batch_download_attempts() + } + + fn max_proc() -> u8 { + Cfg::max_batch_processing_attempts() + } + + fn new_batch() -> TestBatch { + BatchInfo::new(&Epoch::new(0), 1, ByRangeRequestType::Blocks) + } + + fn peer() -> PeerId { + PeerId::random() + } + + fn advance_to_processing(batch: &mut TestBatch, req_id: Id, peer_id: PeerId) { + batch.start_downloading(req_id).unwrap(); + batch.download_completed(vec![1, 2, 3], peer_id).unwrap(); + batch.start_processing().unwrap(); + } + + fn advance_to_awaiting_validation(batch: &mut TestBatch, req_id: Id, peer_id: PeerId) { + advance_to_processing(batch, req_id, peer_id); + batch + .processing_completed(BatchProcessingResult::Success) + .unwrap(); + } + + #[test] + fn happy_path_lifecycle() { + let mut batch = new_batch(); + let p = peer(); + + assert!(matches!(batch.state(), BatchState::AwaitingDownload)); + + batch.start_downloading(1).unwrap(); + assert!(matches!(batch.state(), BatchState::Downloading(1))); + + batch.download_completed(vec![10, 20], p).unwrap(); + assert!(matches!(batch.state(), BatchState::AwaitingProcessing(..))); + + let (data, _duration) = batch.start_processing().unwrap(); + assert_eq!(data, vec![10, 20]); + assert!(matches!(batch.state(), BatchState::Processing(..))); + + let outcome = batch + .processing_completed(BatchProcessingResult::Success) + .unwrap(); + assert!(matches!(outcome, BatchOperationOutcome::Continue)); + assert!(matches!(batch.state(), BatchState::AwaitingValidation(..))); + } + + #[test] + fn download_failures_count_toward_limit() { + let mut batch = new_batch(); + + for i in 1..max_dl() as Id { + batch.start_downloading(i).unwrap(); + let outcome = batch.download_failed(Some(peer())).unwrap(); + assert!(matches!(outcome, BatchOperationOutcome::Continue)); + } + + // Next failure hits the limit + batch.start_downloading(max_dl() as Id).unwrap(); + let outcome = batch.download_failed(Some(peer())).unwrap(); + assert!(matches!( + outcome, + BatchOperationOutcome::Failed { blacklist: false } + )); + } + + #[test] + fn download_failed_none_counts_but_does_not_blame_peer() { + let mut batch = new_batch(); + + // None still counts toward the limit (prevents infinite retry on persistent + // network failures), but doesn't register a peer in failed_peers(). + for i in 0..max_dl() as Id { + batch.start_downloading(i).unwrap(); + batch.download_failed(None).unwrap(); + } + assert!(matches!(batch.state(), BatchState::Failed)); + assert!(batch.failed_peers().is_empty()); + } + + #[test] + fn faulty_processing_failures_count_toward_limit() { + let mut batch = new_batch(); + + for i in 1..max_proc() as Id { + advance_to_processing(&mut batch, i, peer()); + let outcome = batch + .processing_completed(BatchProcessingResult::FaultyFailure) + .unwrap(); + assert!(matches!(outcome, BatchOperationOutcome::Continue)); + } + + // Next faulty failure: limit reached + advance_to_processing(&mut batch, max_proc() as Id, peer()); + let outcome = batch + .processing_completed(BatchProcessingResult::FaultyFailure) + .unwrap(); + assert!(matches!( + outcome, + BatchOperationOutcome::Failed { blacklist: true } + )); + } + + #[test] + fn non_faulty_processing_failures_never_exhaust_batch() { + let mut batch = new_batch(); + + // Well past both limits — non-faulty failures should never cause failure + let iterations = (max_dl() + max_proc()) as Id * 2; + for i in 0..iterations { + advance_to_processing(&mut batch, i, peer()); + let outcome = batch + .processing_completed(BatchProcessingResult::NonFaultyFailure) + .unwrap(); + assert!(matches!(outcome, BatchOperationOutcome::Continue)); + } + // Non-faulty failures also don't register peers as failed + assert!(batch.failed_peers().is_empty()); + } + + #[test] + fn validation_failures_count_toward_processing_limit() { + let mut batch = new_batch(); + + for i in 1..max_proc() as Id { + advance_to_awaiting_validation(&mut batch, i, peer()); + let outcome = batch.validation_failed().unwrap(); + assert!(matches!(outcome, BatchOperationOutcome::Continue)); + } + + advance_to_awaiting_validation(&mut batch, max_proc() as Id, peer()); + let outcome = batch.validation_failed().unwrap(); + assert!(matches!( + outcome, + BatchOperationOutcome::Failed { blacklist: true } + )); + } + + #[test] + fn mixed_failure_types_interact_correctly() { + let mut batch = new_batch(); + let mut req_id: Id = 0; + let mut next_id = || { + req_id += 1; + req_id + }; + + // One download failure + batch.start_downloading(next_id()).unwrap(); + batch.download_failed(Some(peer())).unwrap(); + + // One faulty processing failure (requires a successful download first) + advance_to_processing(&mut batch, next_id(), peer()); + batch + .processing_completed(BatchProcessingResult::FaultyFailure) + .unwrap(); + + // One non-faulty processing failure + advance_to_processing(&mut batch, next_id(), peer()); + batch + .processing_completed(BatchProcessingResult::NonFaultyFailure) + .unwrap(); + assert!(matches!(batch.state(), BatchState::AwaitingDownload)); + + // Fill remaining download failures to hit the limit + for _ in 1..max_dl() { + batch.start_downloading(next_id()).unwrap(); + batch.download_failed(Some(peer())).unwrap(); + } + + // Download failures > processing failures → blacklist: false + assert!(matches!( + batch.outcome(), + BatchOperationOutcome::Failed { blacklist: false } + )); + } +} diff --git a/beacon_node/network/src/sync/range_sync/mod.rs b/beacon_node/network/src/sync/range_sync/mod.rs index dd9f17bfd1..3b65e1c84a 100644 --- a/beacon_node/network/src/sync/range_sync/mod.rs +++ b/beacon_node/network/src/sync/range_sync/mod.rs @@ -5,6 +5,8 @@ mod chain_collection; mod range; mod sync_type; +#[cfg(test)] +pub use chain::RangeSyncBatchConfig; pub use chain::{ChainId, EPOCHS_PER_BATCH}; #[cfg(test)] pub use chain_collection::SyncChainStatus; From 886d31fe7e1b6aff7ae81c8b2c35d17061b0b1fd Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 24 Feb 2026 17:27:16 +1100 Subject: [PATCH 038/189] Delete dysfunctional fork_revert feature (#8891) I found myself having to update this code for Gloas, and figured we may as well delete it seeing as it doesn't work. See: - https://github.com/sigp/lighthouse/issues/4198 Delete all `fork_revert` logic and the accompanying test. Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/builder.rs | 43 +--- beacon_node/beacon_chain/src/fork_revert.rs | 204 ------------------ beacon_node/beacon_chain/src/lib.rs | 1 - beacon_node/beacon_chain/tests/store_tests.rs | 182 ---------------- beacon_node/store/src/hot_cold_store.rs | 8 - beacon_node/store/src/iter.rs | 22 +- consensus/types/src/state/beacon_state.rs | 7 +- 7 files changed, 13 insertions(+), 454 deletions(-) delete mode 100644 beacon_node/beacon_chain/src/fork_revert.rs diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 4c82c93ba3..2c1dae9215 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -7,7 +7,6 @@ use crate::beacon_proposer_cache::BeaconProposerCache; use crate::custody_context::NodeCustodyType; use crate::data_availability_checker::DataAvailabilityChecker; use crate::fork_choice_signal::ForkChoiceSignalTx; -use crate::fork_revert::{reset_fork_choice_to_finalization, revert_to_fork_boundary}; use crate::graffiti_calculator::{GraffitiCalculator, GraffitiOrigin}; use crate::kzg_utils::{build_data_column_sidecars_fulu, build_data_column_sidecars_gloas}; use crate::light_client_server_cache::LightClientServerCache; @@ -778,49 +777,17 @@ where .get_head(current_slot, &self.spec) .map_err(|e| format!("Unable to get fork choice head: {:?}", e))?; - // Try to decode the head block according to the current fork, if that fails, try - // to backtrack to before the most recent fork. - let (head_block_root, head_block, head_reverted) = - match store.get_full_block(&initial_head_block_root) { - Ok(Some(block)) => (initial_head_block_root, block, false), - Ok(None) => return Err("Head block not found in store".into()), - Err(StoreError::SszDecodeError(_)) => { - error!( - message = "This node has likely missed a hard fork. \ - It will try to revert the invalid blocks and keep running, \ - but any stray blocks and states will not be deleted. \ - Long-term you should consider re-syncing this node.", - "Error decoding head block" - ); - let (block_root, block) = revert_to_fork_boundary( - current_slot, - initial_head_block_root, - store.clone(), - &self.spec, - )?; - - (block_root, block, true) - } - Err(e) => return Err(descriptive_db_error("head block", &e)), - }; + let head_block_root = initial_head_block_root; + let head_block = store + .get_full_block(&initial_head_block_root) + .map_err(|e| descriptive_db_error("head block", &e))? + .ok_or("Head block not found in store")?; let (_head_state_root, head_state) = store .get_advanced_hot_state(head_block_root, current_slot, head_block.state_root()) .map_err(|e| descriptive_db_error("head state", &e))? .ok_or("Head state not found in store")?; - // If the head reverted then we need to reset fork choice using the new head's finalized - // checkpoint. - if head_reverted { - fork_choice = reset_fork_choice_to_finalization( - head_block_root, - &head_state, - store.clone(), - Some(current_slot), - &self.spec, - )?; - } - let head_shuffling_ids = BlockShufflingIds::try_from_head(head_block_root, &head_state)?; let mut head_snapshot = BeaconSnapshot { diff --git a/beacon_node/beacon_chain/src/fork_revert.rs b/beacon_node/beacon_chain/src/fork_revert.rs deleted file mode 100644 index 4db79790d3..0000000000 --- a/beacon_node/beacon_chain/src/fork_revert.rs +++ /dev/null @@ -1,204 +0,0 @@ -use crate::{BeaconForkChoiceStore, BeaconSnapshot}; -use fork_choice::{ForkChoice, PayloadVerificationStatus}; -use itertools::process_results; -use state_processing::state_advance::complete_state_advance; -use state_processing::{ - ConsensusContext, VerifyBlockRoot, per_block_processing, - per_block_processing::BlockSignatureStrategy, -}; -use std::sync::Arc; -use std::time::Duration; -use store::{HotColdDB, ItemStore, iter::ParentRootBlockIterator}; -use tracing::{info, warn}; -use types::{BeaconState, ChainSpec, EthSpec, ForkName, Hash256, SignedBeaconBlock, Slot}; - -const CORRUPT_DB_MESSAGE: &str = "The database could be corrupt. Check its file permissions or \ - consider deleting it by running with the --purge-db flag."; - -/// Revert the head to the last block before the most recent hard fork. -/// -/// This function is destructive and should only be used if there is no viable alternative. It will -/// cause the reverted blocks and states to be completely forgotten, lying dormant in the database -/// forever. -/// -/// Return the `(head_block_root, head_block)` that should be used post-reversion. -pub fn revert_to_fork_boundary, Cold: ItemStore>( - current_slot: Slot, - head_block_root: Hash256, - store: Arc>, - spec: &ChainSpec, -) -> Result<(Hash256, SignedBeaconBlock), String> { - let current_fork = spec.fork_name_at_slot::(current_slot); - let fork_epoch = spec - .fork_epoch(current_fork) - .ok_or_else(|| format!("Current fork '{}' never activates", current_fork))?; - - if current_fork == ForkName::Base { - return Err(format!( - "Cannot revert to before phase0 hard fork. {}", - CORRUPT_DB_MESSAGE - )); - } - - warn!( - target_fork = %current_fork, - %fork_epoch, - "Reverting invalid head block" - ); - let block_iter = ParentRootBlockIterator::fork_tolerant(&store, head_block_root); - - let (block_root, blinded_block) = process_results(block_iter, |mut iter| { - iter.find_map(|(block_root, block)| { - if block.slot() < fork_epoch.start_slot(E::slots_per_epoch()) { - Some((block_root, block)) - } else { - info!( - ?block_root, - slot = %block.slot(), - "Reverting block" - ); - None - } - }) - }) - .map_err(|e| { - format!( - "Error fetching blocks to revert: {:?}. {}", - e, CORRUPT_DB_MESSAGE - ) - })? - .ok_or_else(|| format!("No pre-fork blocks found. {}", CORRUPT_DB_MESSAGE))?; - - let block = store - .make_full_block(&block_root, blinded_block) - .map_err(|e| format!("Unable to add payload to new head block: {:?}", e))?; - - Ok((block_root, block)) -} - -/// Reset fork choice to the finalized checkpoint of the supplied head state. -/// -/// The supplied `head_block_root` should correspond to the most recently applied block on -/// `head_state`. -/// -/// This function avoids quirks of fork choice initialization by replaying all of the blocks from -/// the checkpoint to the head. -/// -/// See this issue for details: https://github.com/ethereum/consensus-specs/issues/2566 -/// -/// It will fail if the finalized state or any of the blocks to replay are unavailable. -/// -/// WARNING: this function is destructive and causes fork choice to permanently forget all -/// chains other than the chain leading to `head_block_root`. It should only be used in extreme -/// circumstances when there is no better alternative. -pub fn reset_fork_choice_to_finalization, Cold: ItemStore>( - head_block_root: Hash256, - head_state: &BeaconState, - store: Arc>, - current_slot: Option, - spec: &ChainSpec, -) -> Result, E>, String> { - // Fetch finalized block. - let finalized_checkpoint = head_state.finalized_checkpoint(); - let finalized_block_root = finalized_checkpoint.root; - let finalized_block = store - .get_full_block(&finalized_block_root) - .map_err(|e| format!("Error loading finalized block: {:?}", e))? - .ok_or_else(|| { - format!( - "Finalized block missing for revert: {:?}", - finalized_block_root - ) - })?; - - // Advance finalized state to finalized epoch (to handle skipped slots). - let finalized_state_root = finalized_block.state_root(); - // The enshrined finalized state should be in the state cache. - let mut finalized_state = store - .get_state(&finalized_state_root, Some(finalized_block.slot()), true) - .map_err(|e| format!("Error loading finalized state: {:?}", e))? - .ok_or_else(|| { - format!( - "Finalized block state missing from database: {:?}", - finalized_state_root - ) - })?; - let finalized_slot = finalized_checkpoint.epoch.start_slot(E::slots_per_epoch()); - complete_state_advance( - &mut finalized_state, - Some(finalized_state_root), - finalized_slot, - spec, - ) - .map_err(|e| { - format!( - "Error advancing finalized state to finalized epoch: {:?}", - e - ) - })?; - let finalized_snapshot = BeaconSnapshot { - beacon_block_root: finalized_block_root, - beacon_block: Arc::new(finalized_block), - beacon_state: finalized_state, - }; - - let fc_store = - BeaconForkChoiceStore::get_forkchoice_store(store.clone(), finalized_snapshot.clone()) - .map_err(|e| format!("Unable to reset fork choice store for revert: {e:?}"))?; - - let mut fork_choice = ForkChoice::from_anchor( - fc_store, - finalized_block_root, - &finalized_snapshot.beacon_block, - &finalized_snapshot.beacon_state, - current_slot, - spec, - ) - .map_err(|e| format!("Unable to reset fork choice for revert: {:?}", e))?; - - // Replay blocks from finalized checkpoint back to head. - // We do not replay attestations presently, relying on the absence of other blocks - // to guarantee `head_block_root` as the head. - let blocks = store - .load_blocks_to_replay(finalized_slot + 1, head_state.slot(), head_block_root) - .map_err(|e| format!("Error loading blocks to replay for fork choice: {:?}", e))?; - - let mut state = finalized_snapshot.beacon_state; - for block in blocks { - complete_state_advance(&mut state, None, block.slot(), spec) - .map_err(|e| format!("State advance failed: {:?}", e))?; - - let mut ctxt = ConsensusContext::new(block.slot()) - .set_proposer_index(block.message().proposer_index()); - per_block_processing( - &mut state, - &block, - BlockSignatureStrategy::NoVerification, - VerifyBlockRoot::True, - &mut ctxt, - spec, - ) - .map_err(|e| format!("Error replaying block: {:?}", e))?; - - // Setting this to unverified is the safest solution, since we don't have a way to - // retro-actively determine if they were valid or not. - // - // This scenario is so rare that it seems OK to double-verify some blocks. - let payload_verification_status = PayloadVerificationStatus::Optimistic; - - fork_choice - .on_block( - block.slot(), - block.message(), - block.canonical_root(), - // Reward proposer boost. We are reinforcing the canonical chain. - Duration::from_secs(0), - &state, - payload_verification_status, - spec, - ) - .map_err(|e| format!("Error applying replayed block to fork choice: {:?}", e))?; - } - - Ok(fork_choice) -} diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index 3b03395a66..e1a190ffb3 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -26,7 +26,6 @@ pub mod events; pub mod execution_payload; pub mod fetch_blobs; pub mod fork_choice_signal; -pub mod fork_revert; pub mod graffiti_calculator; pub mod historical_blocks; pub mod historical_data_columns; diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 6bea5f6013..ff20e999bb 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -3924,188 +3924,6 @@ async fn finalizes_after_resuming_from_db() { ); } -#[allow(clippy::large_stack_frames)] -#[tokio::test] -async fn revert_minority_fork_on_resume() { - let validator_count = 16; - let slots_per_epoch = MinimalEthSpec::slots_per_epoch(); - - let fork_epoch = Epoch::new(4); - let fork_slot = fork_epoch.start_slot(slots_per_epoch); - let initial_blocks = slots_per_epoch * fork_epoch.as_u64() - 1; - let post_fork_blocks = slots_per_epoch * 3; - - let mut spec1 = MinimalEthSpec::default_spec(); - spec1.altair_fork_epoch = None; - let mut spec2 = MinimalEthSpec::default_spec(); - spec2.altair_fork_epoch = Some(fork_epoch); - - let all_validators = (0..validator_count).collect::>(); - - // Chain with no fork epoch configured. - let db_path1 = tempdir().unwrap(); - let store1 = get_store_generic(&db_path1, StoreConfig::default(), spec1.clone()); - let harness1 = BeaconChainHarness::builder(MinimalEthSpec) - .spec(spec1.clone().into()) - .keypairs(KEYPAIRS[0..validator_count].to_vec()) - .fresh_disk_store(store1) - .mock_execution_layer() - .build(); - - // Chain with fork epoch configured. - let db_path2 = tempdir().unwrap(); - let store2 = get_store_generic(&db_path2, StoreConfig::default(), spec2.clone()); - let harness2 = BeaconChainHarness::builder(MinimalEthSpec) - .spec(spec2.clone().into()) - .keypairs(KEYPAIRS[0..validator_count].to_vec()) - .fresh_disk_store(store2) - .mock_execution_layer() - .build(); - - // Apply the same blocks to both chains initially. - let mut state = harness1.get_current_state(); - let mut block_root = harness1.chain.genesis_block_root; - for slot in (1..=initial_blocks).map(Slot::new) { - let state_root = state.update_tree_hash_cache().unwrap(); - - let attestations = harness1.make_attestations( - &all_validators, - &state, - state_root, - block_root.into(), - slot, - ); - harness1.set_current_slot(slot); - harness2.set_current_slot(slot); - harness1.process_attestations(attestations.clone(), &state); - harness2.process_attestations(attestations, &state); - - let ((block, blobs), new_state) = harness1.make_block(state, slot).await; - - harness1 - .process_block(slot, block.canonical_root(), (block.clone(), blobs.clone())) - .await - .unwrap(); - harness2 - .process_block(slot, block.canonical_root(), (block.clone(), blobs.clone())) - .await - .unwrap(); - - state = new_state; - block_root = block.canonical_root(); - } - - assert_eq!(harness1.head_slot(), fork_slot - 1); - assert_eq!(harness2.head_slot(), fork_slot - 1); - - // Fork the two chains. - let mut state1 = state.clone(); - let mut state2 = state.clone(); - - let mut majority_blocks = vec![]; - - for i in 0..post_fork_blocks { - let slot = fork_slot + i; - - // Attestations on majority chain. - let state_root = state.update_tree_hash_cache().unwrap(); - - let attestations = harness2.make_attestations( - &all_validators, - &state2, - state_root, - block_root.into(), - slot, - ); - harness2.set_current_slot(slot); - harness2.process_attestations(attestations, &state2); - - // Minority chain block (no attesters). - let ((block1, blobs1), new_state1) = harness1.make_block(state1, slot).await; - harness1 - .process_block(slot, block1.canonical_root(), (block1, blobs1)) - .await - .unwrap(); - state1 = new_state1; - - // Majority chain block (all attesters). - let ((block2, blobs2), new_state2) = harness2.make_block(state2, slot).await; - harness2 - .process_block(slot, block2.canonical_root(), (block2.clone(), blobs2)) - .await - .unwrap(); - - state2 = new_state2; - block_root = block2.canonical_root(); - - majority_blocks.push(block2); - } - - let end_slot = fork_slot + post_fork_blocks - 1; - assert_eq!(harness1.head_slot(), end_slot); - assert_eq!(harness2.head_slot(), end_slot); - - // Resume from disk with the hard-fork activated: this should revert the post-fork blocks. - // We have to do some hackery with the `slot_clock` so that the correct slot is set when - // the beacon chain builder loads the head block. - drop(harness1); - let resume_store = get_store_generic(&db_path1, StoreConfig::default(), spec2.clone()); - - let resumed_harness = TestHarness::builder(MinimalEthSpec) - .spec(spec2.clone().into()) - .keypairs(KEYPAIRS[0..validator_count].to_vec()) - .resumed_disk_store(resume_store) - .override_store_mutator(Box::new(move |mut builder| { - builder = builder - .resume_from_db() - .unwrap() - .testing_slot_clock(spec2.get_slot_duration()) - .unwrap(); - builder - .get_slot_clock() - .unwrap() - .set_slot(end_slot.as_u64()); - builder - })) - .mock_execution_layer() - .build(); - - // Head should now be just before the fork. - resumed_harness.chain.recompute_head_at_current_slot().await; - assert_eq!(resumed_harness.head_slot(), fork_slot - 1); - - // Fork choice should only know the canonical head. When we reverted the head we also should - // have called `reset_fork_choice_to_finalization` which rebuilds fork choice from scratch - // without the reverted block. - assert_eq!( - resumed_harness.chain.heads(), - vec![(resumed_harness.head_block_root(), fork_slot - 1)] - ); - - // Apply blocks from the majority chain and trigger finalization. - let initial_split_slot = resumed_harness.chain.store.get_split_slot(); - for block in &majority_blocks { - resumed_harness - .process_block_result((block.clone(), None)) - .await - .unwrap(); - - // The canonical head should be the block from the majority chain. - resumed_harness.chain.recompute_head_at_current_slot().await; - assert_eq!(resumed_harness.head_slot(), block.slot()); - assert_eq!(resumed_harness.head_block_root(), block.canonical_root()); - } - let advanced_split_slot = resumed_harness.chain.store.get_split_slot(); - - // Check that the migration ran successfully. - assert!(advanced_split_slot > initial_split_slot); - - // Check that there is only a single head now matching harness2 (the minority chain is gone). - let heads = resumed_harness.chain.heads(); - assert_eq!(heads, harness2.chain.heads()); - assert_eq!(heads.len(), 1); -} - // This test checks whether the schema downgrade from the latest version to some minimum supported // version is correct. This is the easiest schema test to write without historic versions of // Lighthouse on-hand, but has the disadvantage that the min version needs to be adjusted manually diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index 6e165702a2..4d00ed1c4a 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -721,14 +721,6 @@ impl, Cold: ItemStore> HotColdDB }) } - /// Fetch a block from the store, ignoring which fork variant it *should* be for. - pub fn get_block_any_variant>( - &self, - block_root: &Hash256, - ) -> Result>, Error> { - self.get_block_with(block_root, SignedBeaconBlock::any_from_ssz_bytes) - } - /// Fetch a block from the store using a custom decode function. /// /// This is useful for e.g. ignoring the slot-indicated fork to forcefully load a block as if it diff --git a/beacon_node/store/src/iter.rs b/beacon_node/store/src/iter.rs index e2b666e597..0cb803d1ed 100644 --- a/beacon_node/store/src/iter.rs +++ b/beacon_node/store/src/iter.rs @@ -249,7 +249,6 @@ impl, Cold: ItemStore> Iterator pub struct ParentRootBlockIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { store: &'a HotColdDB, next_block_root: Hash256, - decode_any_variant: bool, _phantom: PhantomData, } @@ -260,17 +259,6 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> Self { store, next_block_root: start_block_root, - decode_any_variant: false, - _phantom: PhantomData, - } - } - - /// Block iterator that is tolerant of blocks that have the wrong fork for their slot. - pub fn fork_tolerant(store: &'a HotColdDB, start_block_root: Hash256) -> Self { - Self { - store, - next_block_root: start_block_root, - decode_any_variant: true, _phantom: PhantomData, } } @@ -285,12 +273,10 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> Ok(None) } else { let block_root = self.next_block_root; - let block = if self.decode_any_variant { - self.store.get_block_any_variant(&block_root) - } else { - self.store.get_blinded_block(&block_root) - }? - .ok_or(Error::BlockNotFound(block_root))?; + let block = self + .store + .get_blinded_block(&block_root)? + .ok_or(Error::BlockNotFound(block_root))?; self.next_block_root = block.message().parent_root(); Ok(Some((block_root, block))) } diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 6228e40ef8..bd67f469d2 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -56,9 +56,10 @@ use crate::{ pub const CACHED_EPOCHS: usize = 3; -// Pre-electra WS calculations are not supported. On mainnet, pre-electra epochs are outside the weak subjectivity -// period. The default pre-electra WS value is set to 256 to allow for `basic-sim``, `fallback-sim`` test case `revert_minority_fork_on_resume` -// to pass. 256 is a small enough number to trigger the WS safety check pre-electra on mainnet. +// Pre-electra WS calculations are not supported. On mainnet, pre-electra epochs are outside the +// weak subjectivity period. The default pre-electra WS value is set to 256 to allow for `basic-sim` +// and `fallback-sim` tests to pass. 256 is a small enough number to trigger the WS safety check +// pre-electra on mainnet. pub const DEFAULT_PRE_ELECTRA_WS_PERIOD: u64 = 256; const MAX_RANDOM_BYTE: u64 = (1 << 8) - 1; From e59f1f03effdef50b4b2fcdbe8918ad1d2e34f87 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 25 Feb 2026 07:53:33 +1100 Subject: [PATCH 039/189] Add debug spans to DB write paths (#8895) Co-Authored-By: Jimmy Chen --- .../beacon_chain/src/historical_blocks.rs | 17 ++++++++++---- beacon_node/store/src/hot_cold_store.rs | 22 ++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/beacon_node/beacon_chain/src/historical_blocks.rs b/beacon_node/beacon_chain/src/historical_blocks.rs index 3a3c3739c7..1dae2258f6 100644 --- a/beacon_node/beacon_chain/src/historical_blocks.rs +++ b/beacon_node/beacon_chain/src/historical_blocks.rs @@ -12,7 +12,7 @@ use std::time::Duration; use store::metadata::DataColumnInfo; use store::{AnchorInfo, BlobInfo, DBColumn, Error as StoreError, KeyValueStore, KeyValueStoreOp}; use strum::IntoStaticStr; -use tracing::{debug, instrument}; +use tracing::{debug, debug_span, instrument}; use types::{Hash256, Slot}; /// Use a longer timeout on the pubkey cache. @@ -256,9 +256,18 @@ impl BeaconChain { // Write the I/O batches to disk, writing the blocks themselves first, as it's better // for the hot DB to contain extra blocks than for the cold DB to point to blocks that // do not exist. - self.store.blobs_db.do_atomically(blob_batch)?; - self.store.hot_db.do_atomically(hot_batch)?; - self.store.cold_db.do_atomically(cold_batch)?; + { + let _span = debug_span!("backfill_write_blobs_db").entered(); + self.store.blobs_db.do_atomically(blob_batch)?; + } + { + let _span = debug_span!("backfill_write_hot_db").entered(); + self.store.hot_db.do_atomically(hot_batch)?; + } + { + let _span = debug_span!("backfill_write_cold_db").entered(); + self.store.cold_db.do_atomically(cold_batch)?; + } let mut anchor_and_blob_batch = Vec::with_capacity(3); diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index 4d00ed1c4a..fe3477dbfe 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -38,7 +38,7 @@ use std::num::NonZeroUsize; use std::path::Path; use std::sync::Arc; use std::time::Duration; -use tracing::{debug, error, info, instrument, warn}; +use tracing::{debug, debug_span, error, info, instrument, warn}; use typenum::Unsigned; use types::data::{ColumnIndex, DataColumnSidecar, DataColumnSidecarList}; use types::*; @@ -1510,14 +1510,24 @@ impl, Cold: ItemStore> HotColdDB let blob_cache_ops = blobs_ops.clone(); // Try to execute blobs store ops. - self.blobs_db - .do_atomically(self.convert_to_kv_batch(blobs_ops)?)?; + let kv_blob_ops = self.convert_to_kv_batch(blobs_ops)?; + { + let _span = debug_span!("write_blobs_db").entered(); + self.blobs_db.do_atomically(kv_blob_ops)?; + } let hot_db_cache_ops = hot_db_ops.clone(); // Try to execute hot db store ops. - let tx_res = match self.convert_to_kv_batch(hot_db_ops) { - Ok(kv_store_ops) => self.hot_db.do_atomically(kv_store_ops), - Err(e) => Err(e), + let tx_res = { + let _convert_span = debug_span!("convert_hot_db_ops").entered(); + match self.convert_to_kv_batch(hot_db_ops) { + Ok(kv_store_ops) => { + drop(_convert_span); + let _span = debug_span!("write_hot_db").entered(); + self.hot_db.do_atomically(kv_store_ops) + } + Err(e) => Err(e), + } }; // Rollback on failure if let Err(e) = tx_res { From d6bf53834f2646c640bda9838b3033850cd484cc Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:20:28 -0700 Subject: [PATCH 040/189] Remove merge transition code (#8761) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- .../beacon_chain/src/beacon_block_streamer.rs | 29 +- beacon_node/beacon_chain/src/beacon_chain.rs | 84 +--- .../beacon_chain/src/bellatrix_readiness.rs | 171 +------- .../beacon_chain/src/block_verification.rs | 74 +--- .../overflow_lru_cache.rs | 28 +- .../beacon_chain/src/execution_payload.rs | 143 +------ .../src/otb_verification_service.rs | 369 ------------------ beacon_node/beacon_chain/src/test_utils.rs | 120 +++++- .../tests/attestation_verification.rs | 10 +- beacon_node/beacon_chain/tests/bellatrix.rs | 212 ---------- .../beacon_chain/tests/block_verification.rs | 9 +- beacon_node/beacon_chain/tests/capella.rs | 156 -------- beacon_node/beacon_chain/tests/events.rs | 4 +- beacon_node/beacon_chain/tests/main.rs | 2 - .../tests/payload_invalidation.rs | 117 +----- beacon_node/beacon_chain/tests/store_tests.rs | 220 +++++++++++ beacon_node/client/src/builder.rs | 2 +- beacon_node/client/src/notifier.rs | 90 +---- beacon_node/execution_layer/src/lib.rs | 353 +---------------- beacon_node/execution_layer/src/metrics.rs | 2 - .../test_utils/execution_block_generator.rs | 123 +----- .../src/test_utils/mock_execution_layer.rs | 62 +-- .../execution_layer/src/test_utils/mod.rs | 23 -- beacon_node/http_api/src/lib.rs | 22 +- .../tests/broadcast_validation_tests.rs | 64 ++- beacon_node/http_api/tests/fork_tests.rs | 2 +- .../http_api/tests/interactive_tests.rs | 6 - beacon_node/http_api/tests/status_tests.rs | 7 - beacon_node/http_api/tests/tests.rs | 9 - beacon_node/operation_pool/src/lib.rs | 225 ++++------- .../src/per_block_processing/tests.rs | 226 +++-------- .../src/per_epoch_processing/tests.rs | 4 +- consensus/types/tests/committee_cache.rs | 1 + consensus/types/tests/state.rs | 1 + lcli/src/mock_el.rs | 9 +- .../src/test_rig.rs | 20 +- testing/state_transition_vectors/src/exit.rs | 5 +- testing/state_transition_vectors/src/main.rs | 1 + validator_manager/src/exit_validators.rs | 9 +- 39 files changed, 581 insertions(+), 2433 deletions(-) delete mode 100644 beacon_node/beacon_chain/src/otb_verification_service.rs delete mode 100644 beacon_node/beacon_chain/tests/bellatrix.rs delete mode 100644 beacon_node/beacon_chain/tests/capella.rs diff --git a/beacon_node/beacon_chain/src/beacon_block_streamer.rs b/beacon_node/beacon_chain/src/beacon_block_streamer.rs index edbdd6d4d9..9ddc50a9f7 100644 --- a/beacon_node/beacon_chain/src/beacon_block_streamer.rs +++ b/beacon_node/beacon_chain/src/beacon_block_streamer.rs @@ -686,7 +686,6 @@ mod tests { use crate::beacon_block_streamer::{BeaconBlockStreamer, CheckCaches}; use crate::test_utils::{BeaconChainHarness, EphemeralHarnessType, test_spec}; use bls::Keypair; - use execution_layer::test_utils::Block; use fixed_bytes::FixedBytesExtended; use std::sync::Arc; use std::sync::LazyLock; @@ -720,7 +719,7 @@ mod tests { async fn check_all_blocks_from_altair_to_fulu() { let slots_per_epoch = MinimalEthSpec::slots_per_epoch() as usize; let num_epochs = 12; - let bellatrix_fork_epoch = 2usize; + let bellatrix_fork_epoch = 0usize; let capella_fork_epoch = 4usize; let deneb_fork_epoch = 6usize; let electra_fork_epoch = 8usize; @@ -737,32 +736,8 @@ mod tests { let spec = Arc::new(spec); let harness = get_harness(VALIDATOR_COUNT, spec.clone()); - // go to bellatrix fork - harness - .extend_slots(bellatrix_fork_epoch * slots_per_epoch) - .await; - // extend half an epoch - harness.extend_slots(slots_per_epoch / 2).await; - // trigger merge - harness - .execution_block_generator() - .move_to_terminal_block() - .expect("should move to terminal block"); - let timestamp = - harness.get_timestamp_at_slot() + harness.spec.get_slot_duration().as_secs(); - harness - .execution_block_generator() - .modify_last_block(|block| { - if let Block::PoW(terminal_block) = block { - terminal_block.timestamp = timestamp; - } - }); - // finish out merge epoch - harness.extend_slots(slots_per_epoch / 2).await; // finish rest of epochs - harness - .extend_slots((num_epochs - 1 - bellatrix_fork_epoch) * slots_per_epoch) - .await; + harness.extend_slots(num_epochs * slots_per_epoch).await; let head = harness.chain.head_snapshot(); let state = &head.beacon_state; diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 26ad2e714b..9d204ac7f2 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -9,7 +9,6 @@ use crate::beacon_proposer_cache::{ }; use crate::blob_verification::{GossipBlobError, GossipVerifiedBlob}; use crate::block_times_cache::BlockTimesCache; -use crate::block_verification::POS_PANDA_BANNER; use crate::block_verification::{ BlockError, ExecutionPendingBlock, GossipVerifiedBlock, IntoExecutionPendingBlock, check_block_is_finalized_checkpoint_or_descendant, check_block_relevancy, @@ -3513,28 +3512,6 @@ impl BeaconChain { .map_err(BeaconChainError::TokioJoin)? .ok_or(BeaconChainError::RuntimeShutdown)??; - // Log the PoS pandas if a merge transition just occurred. - if payload_verification_outcome.is_valid_merge_transition_block { - info!("{}", POS_PANDA_BANNER); - info!(slot = %block.slot(), "Proof of Stake Activated"); - info!( - terminal_pow_block_hash = ?block - .message() - .execution_payload()? - .parent_hash() - .into_root(), - ); - info!( - merge_transition_block_root = ?block.message().tree_hash_root(), - ); - info!( - merge_transition_execution_hash = ?block - .message() - .execution_payload()? - .block_hash() - .into_root(), - ); - } Ok(ExecutedBlock::new( block, import_data, @@ -6078,21 +6055,6 @@ impl BeaconChain { input_params: ForkchoiceUpdateParameters, override_forkchoice_update: OverrideForkchoiceUpdate, ) -> Result<(), Error> { - let next_slot = current_slot + 1; - - // There is no need to issue a `forkchoiceUpdated` (fcU) message unless the Bellatrix fork - // has: - // - // 1. Already happened. - // 2. Will happen in the next slot. - // - // The reason for a fcU message in the slot prior to the Bellatrix fork is in case the - // terminal difficulty has already been reached and a payload preparation message needs to - // be issued. - if self.slot_is_prior_to_bellatrix(next_slot) { - return Ok(()); - } - let execution_layer = self .execution_layer .as_ref() @@ -6140,50 +6102,8 @@ impl BeaconChain { .unwrap_or_else(ExecutionBlockHash::zero), ) } else { - // The head block does not have an execution block hash. We must check to see if we - // happen to be the proposer of the transition block, in which case we still need to - // send forkchoice_updated. - if self - .spec - .fork_name_at_slot::(next_slot) - .bellatrix_enabled() - { - // We are post-bellatrix - if let Some(payload_attributes) = execution_layer - .payload_attributes(next_slot, params.head_root) - .await - { - // We are a proposer, check for terminal_pow_block_hash - if let Some(terminal_pow_block_hash) = execution_layer - .get_terminal_pow_block_hash(&self.spec, payload_attributes.timestamp()) - .await - .map_err(Error::ForkchoiceUpdate)? - { - info!( - slot = %next_slot, - "Prepared POS transition block proposer" - ); - ( - params.head_root, - terminal_pow_block_hash, - params - .justified_hash - .unwrap_or_else(ExecutionBlockHash::zero), - params - .finalized_hash - .unwrap_or_else(ExecutionBlockHash::zero), - ) - } else { - // TTD hasn't been reached yet, no need to update the EL. - return Ok(()); - } - } else { - // We are not a proposer, no need to update the EL. - return Ok(()); - } - } else { - return Ok(()); - } + // Proposing the block for the merge is no longer supported. + return Ok(()); }; let forkchoice_updated_response = execution_layer diff --git a/beacon_node/beacon_chain/src/bellatrix_readiness.rs b/beacon_node/beacon_chain/src/bellatrix_readiness.rs index 88ccc21b85..34d9795b84 100644 --- a/beacon_node/beacon_chain/src/bellatrix_readiness.rs +++ b/beacon_node/beacon_chain/src/bellatrix_readiness.rs @@ -1,126 +1,9 @@ -//! Provides tools for checking if a node is ready for the Bellatrix upgrade and following merge -//! transition. +//! Provides tools for checking genesis execution payload consistency. use crate::{BeaconChain, BeaconChainError as Error, BeaconChainTypes}; use execution_layer::BlockByNumberQuery; -use serde::{Deserialize, Serialize, Serializer}; -use std::fmt; -use std::fmt::Write; use types::*; -/// The time before the Bellatrix fork when we will start issuing warnings about preparation. -pub const SECONDS_IN_A_WEEK: u64 = 604800; -pub const BELLATRIX_READINESS_PREPARATION_SECONDS: u64 = SECONDS_IN_A_WEEK * 2; - -#[derive(Default, Debug, Serialize, Deserialize)] -pub struct MergeConfig { - #[serde(serialize_with = "serialize_uint256")] - pub terminal_total_difficulty: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub terminal_block_hash: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub terminal_block_hash_epoch: Option, -} - -impl fmt::Display for MergeConfig { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.terminal_block_hash.is_none() - && self.terminal_block_hash_epoch.is_none() - && self.terminal_total_difficulty.is_none() - { - return write!( - f, - "Merge terminal difficulty parameters not configured, check your config" - ); - } - let mut display_string = String::new(); - if let Some(terminal_total_difficulty) = self.terminal_total_difficulty { - write!( - display_string, - "terminal_total_difficulty: {},", - terminal_total_difficulty - )?; - } - if let Some(terminal_block_hash) = self.terminal_block_hash { - write!( - display_string, - "terminal_block_hash: {},", - terminal_block_hash - )?; - } - if let Some(terminal_block_hash_epoch) = self.terminal_block_hash_epoch { - write!( - display_string, - "terminal_block_hash_epoch: {},", - terminal_block_hash_epoch - )?; - } - write!(f, "{}", display_string.trim_end_matches(','))?; - Ok(()) - } -} -impl MergeConfig { - /// Instantiate `self` from the values in a `ChainSpec`. - pub fn from_chainspec(spec: &ChainSpec) -> Self { - let mut params = MergeConfig::default(); - if spec.terminal_total_difficulty != Uint256::MAX { - params.terminal_total_difficulty = Some(spec.terminal_total_difficulty); - } - if spec.terminal_block_hash != ExecutionBlockHash::zero() { - params.terminal_block_hash = Some(spec.terminal_block_hash); - } - if spec.terminal_block_hash_activation_epoch != Epoch::max_value() { - params.terminal_block_hash_epoch = Some(spec.terminal_block_hash_activation_epoch); - } - params - } -} - -/// Indicates if a node is ready for the Bellatrix upgrade and subsequent merge transition. -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -#[serde(tag = "type")] -pub enum BellatrixReadiness { - /// The node is ready, as far as we can tell. - Ready { - config: MergeConfig, - #[serde(serialize_with = "serialize_uint256")] - current_difficulty: Option, - }, - /// The EL can be reached and has the correct configuration, however it's not yet synced. - NotSynced, - /// The user has not configured this node to use an execution endpoint. - NoExecutionEndpoint, -} - -impl fmt::Display for BellatrixReadiness { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - BellatrixReadiness::Ready { - config: params, - current_difficulty, - } => { - write!( - f, - "This node appears ready for Bellatrix \ - Params: {}, current_difficulty: {:?}", - params, current_difficulty - ) - } - BellatrixReadiness::NotSynced => write!( - f, - "The execution endpoint is connected and configured, \ - however it is not yet synced" - ), - BellatrixReadiness::NoExecutionEndpoint => write!( - f, - "The --execution-endpoint flag is not specified, this is a \ - requirement for Bellatrix" - ), - } - } -} - pub enum GenesisExecutionPayloadStatus { Correct(ExecutionBlockHash), BlockHashMismatch { @@ -141,47 +24,6 @@ pub enum GenesisExecutionPayloadStatus { } impl BeaconChain { - /// Returns `true` if user has an EL configured, or if the Bellatrix fork has occurred or will - /// occur within `BELLATRIX_READINESS_PREPARATION_SECONDS`. - pub fn is_time_to_prepare_for_bellatrix(&self, current_slot: Slot) -> bool { - if let Some(bellatrix_epoch) = self.spec.bellatrix_fork_epoch { - let bellatrix_slot = bellatrix_epoch.start_slot(T::EthSpec::slots_per_epoch()); - let bellatrix_readiness_preparation_slots = - BELLATRIX_READINESS_PREPARATION_SECONDS / self.spec.get_slot_duration().as_secs(); - - if self.execution_layer.is_some() { - // The user has already configured an execution layer, start checking for readiness - // right away. - true - } else { - // Return `true` if Bellatrix has happened or is within the preparation time. - current_slot + bellatrix_readiness_preparation_slots > bellatrix_slot - } - } else { - // The Bellatrix fork epoch has not been defined yet, no need to prepare. - false - } - } - - /// Attempts to connect to the EL and confirm that it is ready for Bellatrix. - pub async fn check_bellatrix_readiness(&self, current_slot: Slot) -> BellatrixReadiness { - if let Some(el) = self.execution_layer.as_ref() { - if !el.is_synced_for_notifier(current_slot).await { - // The EL is not synced. - return BellatrixReadiness::NotSynced; - } - let params = MergeConfig::from_chainspec(&self.spec); - let current_difficulty = el.get_current_difficulty().await.ok().flatten(); - BellatrixReadiness::Ready { - config: params, - current_difficulty, - } - } else { - // There is no EL configured. - BellatrixReadiness::NoExecutionEndpoint - } - } - /// Check that the execution payload embedded in the genesis state matches the EL's genesis /// block. pub async fn check_genesis_execution_payload_is_correct( @@ -223,14 +65,3 @@ impl BeaconChain { Ok(GenesisExecutionPayloadStatus::Correct(exec_block_hash)) } } - -/// Utility function to serialize a Uint256 as a decimal string. -fn serialize_uint256(val: &Option, s: S) -> Result -where - S: Serializer, -{ - match val { - Some(v) => v.to_string().serialize(s), - None => s.serialize_none(), - } -} diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 292560d6a7..d126c3af00 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -56,8 +56,7 @@ use crate::data_availability_checker::{ }; use crate::data_column_verification::GossipDataColumnError; use crate::execution_payload::{ - AllowOptimisticImport, NotifyExecutionLayer, PayloadNotifier, - validate_execution_payload_for_gossip, validate_merge_block, + NotifyExecutionLayer, PayloadNotifier, validate_execution_payload_for_gossip, }; use crate::kzg_utils::blobs_to_data_column_sidecars; use crate::observed_block_producers::SeenBlock; @@ -80,7 +79,7 @@ use safe_arith::ArithError; use slot_clock::SlotClock; use ssz::Encode; use ssz_derive::{Decode, Encode}; -use state_processing::per_block_processing::{errors::IntoWithIndex, is_merge_transition_block}; +use state_processing::per_block_processing::errors::IntoWithIndex; use state_processing::{ AllCaches, BlockProcessingError, BlockSignatureStrategy, ConsensusContext, SlotProcessingError, VerifyBlockRoot, @@ -99,34 +98,10 @@ use task_executor::JoinHandle; use tracing::{Instrument, Span, debug, debug_span, error, info_span, instrument}; use types::{ BeaconBlockRef, BeaconState, BeaconStateError, BlobsList, ChainSpec, DataColumnSidecarList, - Epoch, EthSpec, ExecutionBlockHash, FullPayload, Hash256, InconsistentFork, KzgProofs, - RelativeEpoch, SignedBeaconBlock, SignedBeaconBlockHeader, Slot, data::DataColumnSidecarError, + Epoch, EthSpec, FullPayload, Hash256, InconsistentFork, KzgProofs, RelativeEpoch, + SignedBeaconBlock, SignedBeaconBlockHeader, Slot, data::DataColumnSidecarError, }; -pub const POS_PANDA_BANNER: &str = r#" - ,,, ,,, ,,, ,,, - ;" ^; ;' ", ;" ^; ;' ", - ; s$$$$$$$s ; ; s$$$$$$$s ; - , ss$$$$$$$$$$s ,' ooooooooo. .oooooo. .oooooo..o , ss$$$$$$$$$$s ,' - ;s$$$$$$$$$$$$$$$ `888 `Y88. d8P' `Y8b d8P' `Y8 ;s$$$$$$$$$$$$$$$ - $$$$$$$$$$$$$$$$$$ 888 .d88'888 888Y88bo. $$$$$$$$$$$$$$$$$$ - $$$$P""Y$$$Y""W$$$$$ 888ooo88P' 888 888 `"Y8888o. $$$$P""Y$$$Y""W$$$$$ - $$$$ p"LFG"q $$$$$ 888 888 888 `"Y88b $$$$ p"LFG"q $$$$$ - $$$$ .$$$$$. $$$$ 888 `88b d88'oo .d8P $$$$ .$$$$$. $$$$ - $$DcaU$$$$$$$$$$ o888o `Y8bood8P' 8""88888P' $$DcaU$$$$$$$$$$ - "Y$$$"*"$$$Y" "Y$$$"*"$$$Y" - "$b.$$" "$b.$$" - - .o. . o8o . .o8 - .888. .o8 `"' .o8 "888 - .8"888. .ooooo. .o888oooooo oooo ooo .oooo. .o888oo .ooooo. .oooo888 - .8' `888. d88' `"Y8 888 `888 `88. .8' `P )88b 888 d88' `88bd88' `888 - .88ooo8888. 888 888 888 `88..8' .oP"888 888 888ooo888888 888 - .8' `888. 888 .o8 888 . 888 `888' d8( 888 888 .888 .o888 888 - o88o o8888o`Y8bod8P' "888"o888o `8' `Y888""8o "888"`Y8bod8P'`Y8bod88P" - -"#; - /// Maximum block slot number. Block with slots bigger than this constant will NOT be processed. const MAXIMUM_BLOCK_SLOT_NUMBER: u64 = 4_294_967_296; // 2^32 @@ -392,13 +367,6 @@ pub enum ExecutionPayloadError { /// /// The block is invalid and the peer is faulty InvalidPayloadTimestamp { expected: u64, found: u64 }, - /// The execution payload references an execution block that cannot trigger the merge. - /// - /// ## Peer scoring - /// - /// The block is invalid and the peer sent us a block that passes gossip propagation conditions, - /// but is invalid upon further verification. - InvalidTerminalPoWBlock { parent_hash: ExecutionBlockHash }, /// The `TERMINAL_BLOCK_HASH` is set, but the block has not reached the /// `TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH`. /// @@ -410,16 +378,6 @@ pub enum ExecutionPayloadError { activation_epoch: Epoch, epoch: Epoch, }, - /// The `TERMINAL_BLOCK_HASH` is set, but does not match the value specified by the block. - /// - /// ## Peer scoring - /// - /// The block is invalid and the peer sent us a block that passes gossip propagation conditions, - /// but is invalid upon further verification. - InvalidTerminalBlockHash { - terminal_block_hash: ExecutionBlockHash, - payload_parent_hash: ExecutionBlockHash, - }, /// The execution node is syncing but we fail the conditions for optimistic sync /// /// ## Peer scoring @@ -444,16 +402,11 @@ impl ExecutionPayloadError { // This is a trivial gossip validation condition, there is no reason for an honest peer // to propagate a block with an invalid payload time stamp. ExecutionPayloadError::InvalidPayloadTimestamp { .. } => true, - // An honest optimistic node may propagate blocks with an invalid terminal PoW block, we - // should not penalized them. - ExecutionPayloadError::InvalidTerminalPoWBlock { .. } => false, // This condition is checked *after* gossip propagation, therefore penalizing gossip // peers for this block would be unfair. There may be an argument to penalize RPC // blocks, since even an optimistic node shouldn't verify this block. We will remove the // penalties for all block imports to keep things simple. ExecutionPayloadError::InvalidActivationEpoch { .. } => false, - // As per `Self::InvalidActivationEpoch`. - ExecutionPayloadError::InvalidTerminalBlockHash { .. } => false, // Do not penalize the peer since it's not their fault that *we're* optimistic. ExecutionPayloadError::UnverifiedNonOptimisticCandidate => false, } @@ -537,7 +490,6 @@ impl From for BlockError { #[derive(Debug, PartialEq, Clone, Encode, Decode)] pub struct PayloadVerificationOutcome { pub payload_verification_status: PayloadVerificationStatus, - pub is_valid_merge_transition_block: bool, } /// Information about invalid blocks which might still be slashable despite being invalid. @@ -1469,27 +1421,10 @@ impl ExecutionPendingBlock { &parent.pre_state, notify_execution_layer, )?; - let is_valid_merge_transition_block = - is_merge_transition_block(&parent.pre_state, block.message().body()); - let payload_verification_future = async move { let chain = payload_notifier.chain.clone(); let block = payload_notifier.block.clone(); - // If this block triggers the merge, check to ensure that it references valid execution - // blocks. - // - // The specification defines this check inside `on_block` in the fork-choice specification, - // however we perform the check here for two reasons: - // - // - There's no point in importing a block that will fail fork choice, so it's best to fail - // early. - // - Doing the check here means we can keep our fork-choice implementation "pure". I.e., no - // calls to remote servers. - if is_valid_merge_transition_block { - validate_merge_block(&chain, block.message(), AllowOptimisticImport::Yes).await?; - }; - // The specification declares that this should be run *inside* `per_block_processing`, // however we run it here to keep `per_block_processing` pure (i.e., no calls to external // servers). @@ -1504,7 +1439,6 @@ impl ExecutionPendingBlock { Ok(PayloadVerificationOutcome { payload_verification_status, - is_valid_merge_transition_block, }) }; // Spawn the payload verification future as a new task, but don't wait for it to complete. diff --git a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs index 7260a4aca0..c0403595ee 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs @@ -791,8 +791,8 @@ mod test { use store::{HotColdDB, ItemStore, StoreConfig, database::interface::BeaconNodeBackend}; use tempfile::{TempDir, tempdir}; use tracing::info; + use types::MinimalEthSpec; use types::new_non_zero_usize; - use types::{ExecPayload, MinimalEthSpec}; const LOW_VALIDATOR_COUNT: usize = 32; @@ -820,9 +820,8 @@ mod test { async fn get_deneb_chain( db_path: &TempDir, ) -> BeaconChainHarness> { - let altair_fork_epoch = Epoch::new(1); - let bellatrix_fork_epoch = Epoch::new(2); - let bellatrix_fork_slot = bellatrix_fork_epoch.start_slot(E::slots_per_epoch()); + let altair_fork_epoch = Epoch::new(0); + let bellatrix_fork_epoch = Epoch::new(0); let capella_fork_epoch = Epoch::new(3); let deneb_fork_epoch = Epoch::new(4); let deneb_fork_slot = deneb_fork_epoch.start_slot(E::slots_per_epoch()); @@ -844,25 +843,6 @@ mod test { .mock_execution_layer() .build(); - // go to bellatrix slot - harness.extend_to_slot(bellatrix_fork_slot).await; - let bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!(bellatrix_head.as_bellatrix().is_ok()); - assert_eq!(bellatrix_head.slot(), bellatrix_fork_slot); - assert!( - bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "Bellatrix head is default payload" - ); - // Trigger the terminal PoW block. - harness - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); // go right before deneb slot harness.extend_to_slot(deneb_fork_slot - 1).await; @@ -942,7 +922,6 @@ mod test { let payload_verification_outcome = PayloadVerificationOutcome { payload_verification_status: PayloadVerificationStatus::Verified, - is_valid_merge_transition_block: false, }; let availability_pending_block = AvailabilityPendingExecutedBlock { @@ -1183,7 +1162,6 @@ mod pending_components_tests { }, payload_verification_outcome: PayloadVerificationOutcome { payload_verification_status: PayloadVerificationStatus::Verified, - is_valid_merge_transition_block: false, }, }; (block, blobs, invalid_blobs) diff --git a/beacon_node/beacon_chain/src/execution_payload.rs b/beacon_node/beacon_chain/src/execution_payload.rs index f32a3ba2a3..a2ebed32ee 100644 --- a/beacon_node/beacon_chain/src/execution_payload.rs +++ b/beacon_node/beacon_chain/src/execution_payload.rs @@ -12,19 +12,19 @@ use crate::{ ExecutionPayloadError, }; use execution_layer::{ - BlockProposalContents, BlockProposalContentsType, BuilderParams, NewPayloadRequest, - PayloadAttributes, PayloadParameters, PayloadStatus, + BlockProposalContentsType, BuilderParams, NewPayloadRequest, PayloadAttributes, + PayloadParameters, PayloadStatus, }; use fork_choice::{InvalidationOperation, PayloadVerificationStatus}; use proto_array::{Block as ProtoBlock, ExecutionStatus}; use slot_clock::SlotClock; use state_processing::per_block_processing::{ compute_timestamp_at_slot, get_expected_withdrawals, is_execution_enabled, - is_merge_transition_complete, partially_verify_execution_payload, + partially_verify_execution_payload, }; use std::sync::Arc; use tokio::task::JoinHandle; -use tracing::{Instrument, debug, debug_span, warn}; +use tracing::{Instrument, debug_span, warn}; use tree_hash::TreeHash; use types::execution::BlockProductionVersion; use types::*; @@ -32,12 +32,6 @@ use types::*; pub type PreparePayloadResult = Result, BlockProductionError>; pub type PreparePayloadHandle = JoinHandle>>; -#[derive(PartialEq)] -pub enum AllowOptimisticImport { - Yes, - No, -} - /// Signal whether the execution payloads of new blocks should be /// immediately verified with the EL or imported optimistically without /// any EL communication. @@ -218,78 +212,6 @@ async fn notify_new_payload( } } -/// Verify that the block which triggers the merge is valid to be imported to fork choice. -/// -/// ## Errors -/// -/// Will return an error when using a pre-merge fork `state`. Ensure to only run this function -/// after the merge fork. -/// -/// ## Specification -/// -/// Equivalent to the `validate_merge_block` function in the merge Fork Choice Changes: -/// -/// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/fork-choice.md#validate_merge_block -pub async fn validate_merge_block( - chain: &Arc>, - block: BeaconBlockRef<'_, T::EthSpec>, - allow_optimistic_import: AllowOptimisticImport, -) -> Result<(), BlockError> { - let spec = &chain.spec; - let block_epoch = block.slot().epoch(T::EthSpec::slots_per_epoch()); - let execution_payload = block.execution_payload()?; - - if spec.terminal_block_hash != ExecutionBlockHash::zero() { - if block_epoch < spec.terminal_block_hash_activation_epoch { - return Err(ExecutionPayloadError::InvalidActivationEpoch { - activation_epoch: spec.terminal_block_hash_activation_epoch, - epoch: block_epoch, - } - .into()); - } - - if execution_payload.parent_hash() != spec.terminal_block_hash { - return Err(ExecutionPayloadError::InvalidTerminalBlockHash { - terminal_block_hash: spec.terminal_block_hash, - payload_parent_hash: execution_payload.parent_hash(), - } - .into()); - } - - return Ok(()); - } - - let execution_layer = chain - .execution_layer - .as_ref() - .ok_or(ExecutionPayloadError::NoExecutionConnection)?; - - let is_valid_terminal_pow_block = execution_layer - .is_valid_terminal_pow_block_hash(execution_payload.parent_hash(), spec) - .await - .map_err(ExecutionPayloadError::from)?; - - match is_valid_terminal_pow_block { - Some(true) => Ok(()), - Some(false) => Err(ExecutionPayloadError::InvalidTerminalPoWBlock { - parent_hash: execution_payload.parent_hash(), - } - .into()), - None => { - if allow_optimistic_import == AllowOptimisticImport::Yes { - debug!( - block_hash = ?execution_payload.parent_hash(), - msg = "the terminal block/parent was unavailable", - "Optimistically importing merge transition block" - ); - Ok(()) - } else { - Err(ExecutionPayloadError::UnverifiedNonOptimisticCandidate.into()) - } - } - } -} - /// Validate the gossip block's execution_payload according to the checks described here: /// https://github.com/ethereum/consensus-specs/blob/dev/specs/merge/p2p-interface.md#beacon_block pub fn validate_execution_payload_for_gossip( @@ -305,14 +227,14 @@ pub fn validate_execution_payload_for_gossip( // Only apply this validation if this is a Bellatrix beacon block. if let Ok(execution_payload) = block.body().execution_payload() { - // This logic should match `is_execution_enabled`. We use only the execution block hash of - // the parent here in order to avoid loading the parent state during gossip verification. + // Check parent execution status to determine if we should validate the payload. + // We use only the execution status of the parent here to avoid loading the parent state + // during gossip verification. - let is_merge_transition_complete = match parent_block.execution_status { - // Optimistically declare that an "unknown" status block has completed the merge. + let parent_has_execution = match parent_block.execution_status { + // Parent has valid or optimistic execution status. ExecutionStatus::Valid(_) | ExecutionStatus::Optimistic(_) => true, - // It's impossible for an irrelevant block to have completed the merge. It is pre-merge - // by definition. + // Pre-merge blocks have irrelevant execution status. ExecutionStatus::Irrelevant(_) => false, // If the parent has an invalid payload then it's impossible to build a valid block upon // it. Reject the block. @@ -323,7 +245,7 @@ pub fn validate_execution_payload_for_gossip( } }; - if is_merge_transition_complete || !execution_payload.is_default_with_empty_roots() { + if parent_has_execution || !execution_payload.is_default_with_empty_roots() { let expected_timestamp = chain .slot_clock .start_of(block.slot()) @@ -372,7 +294,6 @@ pub fn get_execution_payload( // task. let spec = &chain.spec; let current_epoch = state.current_epoch(); - let is_merge_transition_complete = is_merge_transition_complete(state); let timestamp = compute_timestamp_at_slot(state, state.slot(), spec).map_err(BeaconStateError::from)?; let random = *state.get_randao_mix(current_epoch)?; @@ -399,7 +320,6 @@ pub fn get_execution_payload( async move { prepare_execution_payload::( &chain, - is_merge_transition_complete, timestamp, random, proposer_index, @@ -423,8 +343,6 @@ pub fn get_execution_payload( /// Prepares an execution payload for inclusion in a block. /// -/// Will return `Ok(None)` if the Bellatrix fork has occurred, but a terminal block has not been found. -/// /// ## Errors /// /// Will return an error when using a pre-Bellatrix fork `state`. Ensure to only run this function @@ -438,7 +356,6 @@ pub fn get_execution_payload( #[allow(clippy::too_many_arguments)] pub async fn prepare_execution_payload( chain: &Arc>, - is_merge_transition_complete: bool, timestamp: u64, random: Hash256, proposer_index: u64, @@ -453,7 +370,6 @@ pub async fn prepare_execution_payload( where T: BeaconChainTypes, { - let current_epoch = builder_params.slot.epoch(T::EthSpec::slots_per_epoch()); let spec = &chain.spec; let fork = spec.fork_name_at_slot::(builder_params.slot); let execution_layer = chain @@ -461,42 +377,7 @@ where .as_ref() .ok_or(BlockProductionError::ExecutionLayerMissing)?; - let parent_hash = if !is_merge_transition_complete { - let is_terminal_block_hash_set = spec.terminal_block_hash != ExecutionBlockHash::zero(); - let is_activation_epoch_reached = - current_epoch >= spec.terminal_block_hash_activation_epoch; - - if is_terminal_block_hash_set && !is_activation_epoch_reached { - // Use the "empty" payload if there's a terminal block hash, but we haven't reached the - // terminal block epoch yet. - return Ok(BlockProposalContentsType::Full( - BlockProposalContents::Payload { - payload: FullPayload::default_at_fork(fork)?, - block_value: Uint256::ZERO, - }, - )); - } - - let terminal_pow_block_hash = execution_layer - .get_terminal_pow_block_hash(spec, timestamp) - .await - .map_err(BlockProductionError::TerminalPoWBlockLookupFailed)?; - - if let Some(terminal_pow_block_hash) = terminal_pow_block_hash { - terminal_pow_block_hash - } else { - // If the merge transition hasn't occurred yet and the EL hasn't found the terminal - // block, return an "empty" payload. - return Ok(BlockProposalContentsType::Full( - BlockProposalContents::Payload { - payload: FullPayload::default_at_fork(fork)?, - block_value: Uint256::ZERO, - }, - )); - } - } else { - latest_execution_payload_header_block_hash - }; + let parent_hash = latest_execution_payload_header_block_hash; // Try to obtain the fork choice update parameters from the cached head. // diff --git a/beacon_node/beacon_chain/src/otb_verification_service.rs b/beacon_node/beacon_chain/src/otb_verification_service.rs deleted file mode 100644 index e02705f5da..0000000000 --- a/beacon_node/beacon_chain/src/otb_verification_service.rs +++ /dev/null @@ -1,369 +0,0 @@ -use crate::execution_payload::{validate_merge_block, AllowOptimisticImport}; -use crate::{ - BeaconChain, BeaconChainError, BeaconChainTypes, BlockError, ExecutionPayloadError, - INVALID_FINALIZED_MERGE_TRANSITION_BLOCK_SHUTDOWN_REASON, -}; -use itertools::process_results; -use logging::crit; -use proto_array::InvalidationOperation; -use slot_clock::SlotClock; -use ssz::{Decode, Encode}; -use ssz_derive::{Decode, Encode}; -use state_processing::per_block_processing::is_merge_transition_complete; -use std::sync::Arc; -use store::{DBColumn, Error as StoreError, HotColdDB, KeyValueStore, StoreItem}; -use task_executor::{ShutdownReason, TaskExecutor}; -use tokio::time::sleep; -use tracing::{debug, error, info, warn}; -use tree_hash::TreeHash; -use types::{BeaconBlockRef, EthSpec, Hash256, Slot}; -use DBColumn::OptimisticTransitionBlock as OTBColumn; - -#[derive(Clone, Debug, Decode, Encode, PartialEq)] -pub struct OptimisticTransitionBlock { - root: Hash256, - slot: Slot, -} - -impl OptimisticTransitionBlock { - // types::BeaconBlockRef<'_, ::EthSpec> - pub fn from_block(block: BeaconBlockRef) -> Self { - Self { - root: block.tree_hash_root(), - slot: block.slot(), - } - } - - pub fn root(&self) -> &Hash256 { - &self.root - } - - pub fn slot(&self) -> &Slot { - &self.slot - } - - pub fn persist_in_store(&self, store: A) -> Result<(), StoreError> - where - T: BeaconChainTypes, - A: AsRef>, - { - if store - .as_ref() - .item_exists::(&self.root)? - { - Ok(()) - } else { - store.as_ref().put_item(&self.root, self) - } - } - - pub fn remove_from_store(&self, store: A) -> Result<(), StoreError> - where - T: BeaconChainTypes, - A: AsRef>, - { - store - .as_ref() - .hot_db - .key_delete(OTBColumn.into(), self.root.as_slice()) - } - - fn is_canonical( - &self, - chain: &BeaconChain, - ) -> Result { - Ok(chain - .forwards_iter_block_roots_until(self.slot, self.slot)? - .next() - .transpose()? - .map(|(root, _)| root) - == Some(self.root)) - } -} - -impl StoreItem for OptimisticTransitionBlock { - fn db_column() -> DBColumn { - OTBColumn - } - - fn as_store_bytes(&self) -> Vec { - self.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> Result { - Ok(Self::from_ssz_bytes(bytes)?) - } -} - -/// The routine is expected to run once per epoch, 1/4th through the epoch. -pub const EPOCH_DELAY_FACTOR: u32 = 4; - -/// Spawns a routine which checks the validity of any optimistically imported transition blocks -/// -/// This routine will run once per epoch, at `epoch_duration / EPOCH_DELAY_FACTOR` after -/// the start of each epoch. -/// -/// The service will not be started if there is no `execution_layer` on the `chain`. -pub fn start_otb_verification_service( - executor: TaskExecutor, - chain: Arc>, -) { - // Avoid spawning the service if there's no EL, it'll just error anyway. - if chain.execution_layer.is_some() { - executor.spawn( - async move { otb_verification_service(chain).await }, - "otb_verification_service", - ); - } -} - -pub fn load_optimistic_transition_blocks( - chain: &BeaconChain, -) -> Result, StoreError> { - process_results( - chain.store.hot_db.iter_column::(OTBColumn), - |iter| { - iter.map(|(_, bytes)| OptimisticTransitionBlock::from_store_bytes(&bytes)) - .collect() - }, - )? -} - -#[derive(Debug)] -pub enum Error { - ForkChoice(String), - BeaconChain(BeaconChainError), - StoreError(StoreError), - NoBlockFound(OptimisticTransitionBlock), -} - -pub async fn validate_optimistic_transition_blocks( - chain: &Arc>, - otbs: Vec, -) -> Result<(), Error> { - let finalized_slot = chain - .canonical_head - .fork_choice_read_lock() - .get_finalized_block() - .map_err(|e| Error::ForkChoice(format!("{:?}", e)))? - .slot; - - // separate otbs into - // non-canonical - // finalized canonical - // unfinalized canonical - let mut non_canonical_otbs = vec![]; - let (finalized_canonical_otbs, unfinalized_canonical_otbs) = process_results( - otbs.into_iter().map(|otb| { - otb.is_canonical(chain) - .map(|is_canonical| (otb, is_canonical)) - }), - |pair_iter| { - pair_iter - .filter_map(|(otb, is_canonical)| { - if is_canonical { - Some(otb) - } else { - non_canonical_otbs.push(otb); - None - } - }) - .partition::, _>(|otb| *otb.slot() <= finalized_slot) - }, - ) - .map_err(Error::BeaconChain)?; - - // remove non-canonical blocks that conflict with finalized checkpoint from the database - for otb in non_canonical_otbs { - if *otb.slot() <= finalized_slot { - otb.remove_from_store::(&chain.store) - .map_err(Error::StoreError)?; - } - } - - // ensure finalized canonical otb are valid, otherwise kill client - for otb in finalized_canonical_otbs { - match chain.get_block(otb.root()).await { - Ok(Some(block)) => { - match validate_merge_block(chain, block.message(), AllowOptimisticImport::No).await - { - Ok(()) => { - // merge transition block is valid, remove it from OTB - otb.remove_from_store::(&chain.store) - .map_err(Error::StoreError)?; - info!( - block_root = %otb.root(), - "type" = "finalized", - "Validated merge transition block" - ); - } - // The block was not able to be verified by the EL. Leave the OTB in the - // database since the EL is likely still syncing and may verify the block - // later. - Err(BlockError::ExecutionPayloadError( - ExecutionPayloadError::UnverifiedNonOptimisticCandidate, - )) => (), - Err(BlockError::ExecutionPayloadError( - ExecutionPayloadError::InvalidTerminalPoWBlock { .. }, - )) => { - // Finalized Merge Transition Block is Invalid! Kill the Client! - crit!( - msg = "You must use the `--purge-db` flag to clear the database and restart sync. \ - You may be on a hostile network.", - block_hash = ?block.canonical_root(), - "Finalized merge transition block is invalid!" - ); - let mut shutdown_sender = chain.shutdown_sender(); - if let Err(e) = shutdown_sender.try_send(ShutdownReason::Failure( - INVALID_FINALIZED_MERGE_TRANSITION_BLOCK_SHUTDOWN_REASON, - )) { - crit!( - error = ?e, - shutdown_reason = INVALID_FINALIZED_MERGE_TRANSITION_BLOCK_SHUTDOWN_REASON, - "Failed to shut down client" - ); - } - } - _ => {} - } - } - Ok(None) => return Err(Error::NoBlockFound(otb)), - // Our database has pruned the payload and the payload was unavailable on the EL since - // the EL is still syncing or the payload is non-canonical. - Err(BeaconChainError::BlockHashMissingFromExecutionLayer(_)) => (), - Err(e) => return Err(Error::BeaconChain(e)), - } - } - - // attempt to validate any non-finalized canonical otb blocks - for otb in unfinalized_canonical_otbs { - match chain.get_block(otb.root()).await { - Ok(Some(block)) => { - match validate_merge_block(chain, block.message(), AllowOptimisticImport::No).await - { - Ok(()) => { - // merge transition block is valid, remove it from OTB - otb.remove_from_store::(&chain.store) - .map_err(Error::StoreError)?; - info!( - block_root = ?otb.root(), - "type" = "not finalized", - "Validated merge transition block" - ); - } - // The block was not able to be verified by the EL. Leave the OTB in the - // database since the EL is likely still syncing and may verify the block - // later. - Err(BlockError::ExecutionPayloadError( - ExecutionPayloadError::UnverifiedNonOptimisticCandidate, - )) => (), - Err(BlockError::ExecutionPayloadError( - ExecutionPayloadError::InvalidTerminalPoWBlock { .. }, - )) => { - // Unfinalized Merge Transition Block is Invalid -> Run process_invalid_execution_payload - warn!( - block_root = ?otb.root(), - "Merge transition block invalid" - ); - chain - .process_invalid_execution_payload( - &InvalidationOperation::InvalidateOne { - block_root: *otb.root(), - }, - ) - .await - .map_err(|e| { - warn!( - error = ?e, - location = "process_invalid_execution_payload", - "Error checking merge transition block" - ); - Error::BeaconChain(e) - })?; - } - _ => {} - } - } - Ok(None) => return Err(Error::NoBlockFound(otb)), - // Our database has pruned the payload and the payload was unavailable on the EL since - // the EL is still syncing or the payload is non-canonical. - Err(BeaconChainError::BlockHashMissingFromExecutionLayer(_)) => (), - Err(e) => return Err(Error::BeaconChain(e)), - } - } - - Ok(()) -} - -/// Loop until any optimistically imported merge transition blocks have been verified and -/// the merge has been finalized. -async fn otb_verification_service(chain: Arc>) { - let epoch_duration = chain.slot_clock.slot_duration() * T::EthSpec::slots_per_epoch() as u32; - loop { - match chain - .slot_clock - .duration_to_next_epoch(T::EthSpec::slots_per_epoch()) - { - Some(duration) => { - let additional_delay = epoch_duration / EPOCH_DELAY_FACTOR; - sleep(duration + additional_delay).await; - - debug!("OTB verification service firing"); - - if !is_merge_transition_complete( - &chain.canonical_head.cached_head().snapshot.beacon_state, - ) { - // We are pre-merge. Nothing to do yet. - continue; - } - - // load all optimistically imported transition blocks from the database - match load_optimistic_transition_blocks(chain.as_ref()) { - Ok(otbs) => { - if otbs.is_empty() { - if chain - .canonical_head - .fork_choice_read_lock() - .get_finalized_block() - .map_or(false, |block| { - block.execution_status.is_execution_enabled() - }) - { - // there are no optimistic blocks in the database, we can exit - // the service since the merge transition is finalized and we'll - // never see another transition block - break; - } else { - debug!( - info = "waiting for the merge transition to finalize", - "No optimistic transition blocks" - ) - } - } - if let Err(e) = validate_optimistic_transition_blocks(&chain, otbs).await { - warn!( - error = ?e, - "Error while validating optimistic transition blocks" - ); - } - } - Err(e) => { - error!( - error = ?e, - "Error loading optimistic transition blocks" - ); - } - }; - } - None => { - error!("Failed to read slot clock"); - // If we can't read the slot clock, just wait another slot. - sleep(chain.slot_clock.slot_duration()).await; - } - }; - } - debug!( - msg = "shutting down OTB verification service", - "No optimistic transition blocks in database" - ); -} diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 096a0516fc..eefb5d48b7 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -29,10 +29,7 @@ use execution_layer::test_utils::generate_genesis_header; use execution_layer::{ ExecutionLayer, auth::JwtKey, - test_utils::{ - DEFAULT_JWT_SECRET, DEFAULT_TERMINAL_BLOCK, ExecutionBlockGenerator, MockBuilder, - MockExecutionLayer, - }, + test_utils::{DEFAULT_JWT_SECRET, ExecutionBlockGenerator, MockBuilder, MockExecutionLayer}, }; use fixed_bytes::FixedBytesExtended; use futures::channel::mpsc::Receiver; @@ -52,7 +49,11 @@ use rayon::prelude::*; use sensitive_url::SensitiveUrl; use slot_clock::{SlotClock, TestingSlotClock}; use ssz_types::{RuntimeVariableList, VariableList}; +use state_processing::ConsensusContext; use state_processing::per_block_processing::compute_timestamp_at_slot; +use state_processing::per_block_processing::{ + BlockSignatureStrategy, VerifyBlockRoot, per_block_processing, +}; use state_processing::state_advance::complete_state_advance; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; @@ -202,11 +203,12 @@ pub fn fork_name_from_env() -> Option { /// Return a `ChainSpec` suitable for test usage. /// /// If the `fork_from_env` feature is enabled, read the fork to use from the FORK_NAME environment -/// variable. Otherwise use the default spec. +/// variable. Otherwise we default to Bellatrix as the minimum fork (we no longer support +/// starting test networks prior to Bellatrix). pub fn test_spec() -> ChainSpec { let mut spec = fork_name_from_env() .map(|fork| fork.make_genesis_spec(E::default_spec())) - .unwrap_or_else(|| E::default_spec()); + .unwrap_or_else(|| ForkName::Bellatrix.make_genesis_spec(E::default_spec())); // Set target aggregators to a high value by default. spec.target_aggregators_per_committee = DEFAULT_TARGET_AGGREGATORS; @@ -277,16 +279,25 @@ impl Builder> { }); let mutator = move |builder: BeaconChainBuilder<_>| { - let header = generate_genesis_header::(builder.get_spec(), false); + let spec = builder.get_spec(); + let header = generate_genesis_header::(spec); let genesis_state = genesis_state_builder - .set_opt_execution_payload_header(header) + .set_opt_execution_payload_header(header.clone()) .build_genesis_state( &validator_keypairs, HARNESS_GENESIS_TIME, Hash256::from_slice(DEFAULT_ETH1_BLOCK_HASH), - builder.get_spec(), + spec, ) .expect("should generate interop state"); + // For post-Bellatrix forks, verify the merge is complete at genesis + if header.is_some() { + assert!( + state_processing::per_block_processing::is_merge_transition_complete( + &genesis_state + ) + ); + } builder .genesis_state(genesis_state) .expect("should build state using recent genesis") @@ -344,7 +355,7 @@ impl Builder> { }); let mutator = move |builder: BeaconChainBuilder<_>| { - let header = generate_genesis_header::(builder.get_spec(), false); + let header = generate_genesis_header::(builder.get_spec()); let genesis_state = genesis_state_builder .set_opt_execution_payload_header(header) .build_genesis_state( @@ -688,7 +699,6 @@ pub fn mock_execution_layer_from_parts( MockExecutionLayer::new( task_executor, - DEFAULT_TERMINAL_BLOCK, shanghai_time, cancun_time, prague_time, @@ -1178,6 +1188,94 @@ where ) } + /// Build a Bellatrix block with the given execution payload, compute the + /// correct state root, sign it, and import it into the chain. + /// + /// This bypasses the normal block production pipeline, which always requests + /// a payload from the execution layer. That makes it possible to construct + /// blocks with **default (zeroed) payloads** — something the EL-backed flow + /// cannot do — which is needed to simulate the pre-merge portion of a chain + /// that starts at Bellatrix genesis with `is_merge_transition_complete = false`. + /// + /// `state` is expected to be the head state *before* `slot`. It will be + /// advanced to `slot` in-place via `complete_state_advance`, then used to + /// derive the proposer, RANDAO reveal, and parent root. After processing, + /// the caller should typically replace `state` with the chain's new head + /// state (`self.get_current_state()`). + pub async fn build_and_import_block_with_payload( + &self, + state: &mut BeaconState, + slot: Slot, + execution_payload: ExecutionPayloadBellatrix, + ) { + complete_state_advance(state, None, slot, &self.spec).expect("should advance state"); + state.build_caches(&self.spec).expect("should build caches"); + + let proposer_index = state.get_beacon_proposer_index(slot, &self.spec).unwrap(); + let randao_reveal = self.sign_randao_reveal(state, proposer_index, slot); + let parent_root = state.latest_block_header().canonical_root(); + + let mut block = BeaconBlock::Bellatrix(BeaconBlockBellatrix { + slot, + proposer_index: proposer_index as u64, + parent_root, + state_root: Hash256::zero(), + body: BeaconBlockBodyBellatrix { + randao_reveal, + eth1_data: state.eth1_data().clone(), + graffiti: Graffiti::default(), + proposer_slashings: VariableList::empty(), + attester_slashings: VariableList::empty(), + attestations: VariableList::empty(), + deposits: VariableList::empty(), + voluntary_exits: VariableList::empty(), + sync_aggregate: SyncAggregate::new(), + execution_payload: FullPayloadBellatrix { execution_payload }, + }, + }); + + // Run per_block_processing on a clone to compute the post-state root. + let signed_tmp = block.clone().sign( + &self.validator_keypairs[proposer_index].sk, + &state.fork(), + state.genesis_validators_root(), + &self.spec, + ); + let mut ctxt = ConsensusContext::new(slot).set_proposer_index(proposer_index as u64); + let mut post_state = state.clone(); + per_block_processing( + &mut post_state, + &signed_tmp, + BlockSignatureStrategy::NoVerification, + VerifyBlockRoot::False, + &mut ctxt, + &self.spec, + ) + .unwrap_or_else(|e| panic!("per_block_processing failed at slot {}: {e:?}", slot)); + + let state_root = post_state.update_tree_hash_cache().unwrap(); + *block.state_root_mut() = state_root; + + let signed_block = self.sign_beacon_block(block, state); + let block_root = signed_block.canonical_root(); + let rpc_block = RpcBlock::BlockOnly { + block_root, + block: Arc::new(signed_block), + }; + self.chain.slot_clock.set_slot(slot.as_u64()); + self.chain + .process_block( + block_root, + rpc_block, + NotifyExecutionLayer::No, + BlockImportSource::Lookup, + || Ok(()), + ) + .await + .unwrap_or_else(|e| panic!("import failed at slot {}: {e:?}", slot)); + self.chain.recompute_head_at_current_slot().await; + } + #[allow(clippy::too_many_arguments)] pub fn produce_single_attestation_for_block( &self, diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index 96071be89f..e8ee628f28 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -14,6 +14,7 @@ use beacon_chain::{ }, }; use bls::{AggregateSignature, Keypair, SecretKey}; +use execution_layer::test_utils::generate_genesis_header; use fixed_bytes::FixedBytesExtended; use genesis::{DEFAULT_ETH1_BLOCK_HASH, interop_genesis_state}; use int_to_bytes::int_to_bytes32; @@ -79,11 +80,13 @@ fn get_harness_capella_spec( let spec = Arc::new(spec); let validator_keypairs = KEYPAIRS[0..validator_count].to_vec(); + // Use the proper genesis execution payload header that matches the mock execution layer + let execution_payload_header = generate_genesis_header(&spec); let genesis_state = interop_genesis_state( &validator_keypairs, HARNESS_GENESIS_TIME, Hash256::from_slice(DEFAULT_ETH1_BLOCK_HASH), - None, + execution_payload_header, &spec, ) .unwrap(); @@ -106,11 +109,6 @@ fn get_harness_capella_spec( .mock_execution_layer() .build(); - harness - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - harness.advance_slot(); (harness, spec) diff --git a/beacon_node/beacon_chain/tests/bellatrix.rs b/beacon_node/beacon_chain/tests/bellatrix.rs deleted file mode 100644 index fc0f96ef88..0000000000 --- a/beacon_node/beacon_chain/tests/bellatrix.rs +++ /dev/null @@ -1,212 +0,0 @@ -#![cfg(not(debug_assertions))] // Tests run too slow in debug. - -use beacon_chain::test_utils::BeaconChainHarness; -use execution_layer::test_utils::{Block, DEFAULT_TERMINAL_BLOCK, generate_pow_block}; -use types::*; - -const VALIDATOR_COUNT: usize = 32; - -type E = MainnetEthSpec; - -fn verify_execution_payload_chain(chain: &[FullPayload]) { - let mut prev_ep: Option> = None; - - for ep in chain { - assert!(!ep.is_default_with_empty_roots()); - assert!(ep.block_hash() != ExecutionBlockHash::zero()); - - // Check against previous `ExecutionPayload`. - if let Some(prev_ep) = prev_ep { - assert_eq!(prev_ep.block_hash(), ep.parent_hash()); - assert_eq!(prev_ep.block_number() + 1, ep.block_number()); - assert!(ep.timestamp() > prev_ep.timestamp()); - } - prev_ep = Some(ep.clone()); - } -} - -#[tokio::test] -// TODO(merge): This isn't working cause the non-zero values in `initialize_beacon_state_from_eth1` -// are causing failed lookups to the execution node. I need to come back to this. -#[should_panic] -async fn merge_with_terminal_block_hash_override() { - let altair_fork_epoch = Epoch::new(0); - let bellatrix_fork_epoch = Epoch::new(0); - - let mut spec = E::default_spec(); - spec.altair_fork_epoch = Some(altair_fork_epoch); - spec.bellatrix_fork_epoch = Some(bellatrix_fork_epoch); - - let genesis_pow_block_hash = generate_pow_block( - spec.terminal_total_difficulty, - DEFAULT_TERMINAL_BLOCK, - 0, - ExecutionBlockHash::zero(), - ) - .unwrap() - .block_hash; - - spec.terminal_block_hash = genesis_pow_block_hash; - - let harness = BeaconChainHarness::builder(E::default()) - .spec(spec.into()) - .deterministic_keypairs(VALIDATOR_COUNT) - .fresh_ephemeral_store() - .mock_execution_layer() - .build(); - - assert_eq!( - harness - .execution_block_generator() - .latest_block() - .unwrap() - .block_hash(), - genesis_pow_block_hash, - "pre-condition" - ); - - assert!( - harness - .chain - .head_snapshot() - .beacon_block - .as_bellatrix() - .is_ok(), - "genesis block should be a bellatrix block" - ); - - let mut execution_payloads = vec![]; - for i in 0..E::slots_per_epoch() * 3 { - harness.extend_slots(1).await; - - let block = &harness.chain.head_snapshot().beacon_block; - - let execution_payload = block.message().body().execution_payload().unwrap(); - if i == 0 { - assert_eq!(execution_payload.block_hash(), genesis_pow_block_hash); - } - execution_payloads.push(execution_payload.into()); - } - - verify_execution_payload_chain(execution_payloads.as_slice()); -} - -#[tokio::test] -async fn base_altair_bellatrix_with_terminal_block_after_fork() { - let altair_fork_epoch = Epoch::new(4); - let altair_fork_slot = altair_fork_epoch.start_slot(E::slots_per_epoch()); - let bellatrix_fork_epoch = Epoch::new(8); - let bellatrix_fork_slot = bellatrix_fork_epoch.start_slot(E::slots_per_epoch()); - - let mut spec = E::default_spec(); - spec.altair_fork_epoch = Some(altair_fork_epoch); - spec.bellatrix_fork_epoch = Some(bellatrix_fork_epoch); - - let mut execution_payloads = vec![]; - - let harness = BeaconChainHarness::builder(E::default()) - .spec(spec.into()) - .deterministic_keypairs(VALIDATOR_COUNT) - .fresh_ephemeral_store() - .mock_execution_layer() - .build(); - - /* - * Start with the base fork. - */ - - assert!(harness.chain.head_snapshot().beacon_block.as_base().is_ok()); - - /* - * Do the Altair fork. - */ - - harness.extend_to_slot(altair_fork_slot).await; - - let altair_head = &harness.chain.head_snapshot().beacon_block; - assert!(altair_head.as_altair().is_ok()); - assert_eq!(altair_head.slot(), altair_fork_slot); - - /* - * Do the Bellatrix fork, without a terminal PoW block. - */ - - Box::pin(harness.extend_to_slot(bellatrix_fork_slot)).await; - - let bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!(bellatrix_head.as_bellatrix().is_ok()); - assert_eq!(bellatrix_head.slot(), bellatrix_fork_slot); - assert!( - bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "Bellatrix head is default payload" - ); - - /* - * Next Bellatrix block shouldn't include an exec payload. - */ - - harness.extend_slots(1).await; - - let one_after_bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!( - one_after_bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "One after bellatrix head is default payload" - ); - assert_eq!(one_after_bellatrix_head.slot(), bellatrix_fork_slot + 1); - - /* - * Trigger the terminal PoW block. - */ - - harness - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - - // Add a slot duration to get to the next slot - let timestamp = harness.get_timestamp_at_slot() + harness.spec.get_slot_duration().as_secs(); - - harness - .execution_block_generator() - .modify_last_block(|block| { - if let Block::PoW(terminal_block) = block { - terminal_block.timestamp = timestamp; - } - }); - - harness.extend_slots(1).await; - - let two_after_bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!( - two_after_bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "Two after bellatrix head is default payload" - ); - assert_eq!(two_after_bellatrix_head.slot(), bellatrix_fork_slot + 2); - - /* - * Next Bellatrix block should include an exec payload. - */ - for _ in 0..4 { - harness.extend_slots(1).await; - - let block = &harness.chain.head_snapshot().beacon_block; - execution_payloads.push(block.message().body().execution_payload().unwrap().into()); - } - - verify_execution_payload_chain(execution_payloads.as_slice()); -} diff --git a/beacon_node/beacon_chain/tests/block_verification.rs b/beacon_node/beacon_chain/tests/block_verification.rs index d214ea6b15..e94e64e91d 100644 --- a/beacon_node/beacon_chain/tests/block_verification.rs +++ b/beacon_node/beacon_chain/tests/block_verification.rs @@ -20,10 +20,9 @@ use fixed_bytes::FixedBytesExtended; use logging::create_test_tracing_subscriber; use slasher::{Config as SlasherConfig, Slasher}; use state_processing::{ - BlockProcessingError, ConsensusContext, VerifyBlockRoot, + BlockProcessingError, BlockSignatureStrategy, ConsensusContext, VerifyBlockRoot, common::{attesting_indices_base, attesting_indices_electra}, - per_block_processing::{BlockSignatureStrategy, per_block_processing}, - per_slot_processing, + per_block_processing, per_slot_processing, }; use std::marker::PhantomData; use std::sync::{Arc, LazyLock}; @@ -1849,10 +1848,8 @@ async fn add_altair_block_to_base_chain() { // https://github.com/sigp/lighthouse/issues/4332#issuecomment-1565092279 #[tokio::test] async fn import_duplicate_block_unrealized_justification() { - let spec = MainnetEthSpec::default_spec(); - let harness = BeaconChainHarness::builder(MainnetEthSpec) - .spec(spec.into()) + .default_spec() .keypairs(KEYPAIRS[..].to_vec()) .fresh_ephemeral_store() .mock_execution_layer() diff --git a/beacon_node/beacon_chain/tests/capella.rs b/beacon_node/beacon_chain/tests/capella.rs deleted file mode 100644 index e8ab795366..0000000000 --- a/beacon_node/beacon_chain/tests/capella.rs +++ /dev/null @@ -1,156 +0,0 @@ -#![cfg(not(debug_assertions))] // Tests run too slow in debug. - -use beacon_chain::test_utils::BeaconChainHarness; -use execution_layer::test_utils::Block; -use types::*; - -const VALIDATOR_COUNT: usize = 32; -type E = MainnetEthSpec; - -fn verify_execution_payload_chain(chain: &[FullPayload]) { - let mut prev_ep: Option> = None; - - for ep in chain { - assert!(!ep.is_default_with_empty_roots()); - assert!(ep.block_hash() != ExecutionBlockHash::zero()); - - // Check against previous `ExecutionPayload`. - if let Some(prev_ep) = prev_ep { - assert_eq!(prev_ep.block_hash(), ep.parent_hash()); - assert_eq!(prev_ep.block_number() + 1, ep.block_number()); - assert!(ep.timestamp() > prev_ep.timestamp()); - } - prev_ep = Some(ep.clone()); - } -} - -#[tokio::test] -async fn base_altair_bellatrix_capella() { - let altair_fork_epoch = Epoch::new(4); - let altair_fork_slot = altair_fork_epoch.start_slot(E::slots_per_epoch()); - let bellatrix_fork_epoch = Epoch::new(8); - let bellatrix_fork_slot = bellatrix_fork_epoch.start_slot(E::slots_per_epoch()); - let capella_fork_epoch = Epoch::new(12); - let capella_fork_slot = capella_fork_epoch.start_slot(E::slots_per_epoch()); - - let mut spec = E::default_spec(); - spec.altair_fork_epoch = Some(altair_fork_epoch); - spec.bellatrix_fork_epoch = Some(bellatrix_fork_epoch); - spec.capella_fork_epoch = Some(capella_fork_epoch); - - let harness = BeaconChainHarness::builder(E::default()) - .spec(spec.into()) - .deterministic_keypairs(VALIDATOR_COUNT) - .fresh_ephemeral_store() - .mock_execution_layer() - .build(); - - /* - * Start with the base fork. - */ - assert!(harness.chain.head_snapshot().beacon_block.as_base().is_ok()); - - /* - * Do the Altair fork. - */ - Box::pin(harness.extend_to_slot(altair_fork_slot)).await; - - let altair_head = &harness.chain.head_snapshot().beacon_block; - assert!(altair_head.as_altair().is_ok()); - assert_eq!(altair_head.slot(), altair_fork_slot); - - /* - * Do the Bellatrix fork, without a terminal PoW block. - */ - Box::pin(harness.extend_to_slot(bellatrix_fork_slot)).await; - - let bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!(bellatrix_head.as_bellatrix().is_ok()); - assert_eq!(bellatrix_head.slot(), bellatrix_fork_slot); - assert!( - bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "Bellatrix head is default payload" - ); - - /* - * Next Bellatrix block shouldn't include an exec payload. - */ - Box::pin(harness.extend_slots(1)).await; - - let one_after_bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!( - one_after_bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "One after bellatrix head is default payload" - ); - assert_eq!(one_after_bellatrix_head.slot(), bellatrix_fork_slot + 1); - - /* - * Trigger the terminal PoW block. - */ - harness - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - - // Add a slot duration to get to the next slot - let timestamp = harness.get_timestamp_at_slot() + harness.spec.get_slot_duration().as_secs(); - harness - .execution_block_generator() - .modify_last_block(|block| { - if let Block::PoW(terminal_block) = block { - terminal_block.timestamp = timestamp; - } - }); - Box::pin(harness.extend_slots(1)).await; - - let two_after_bellatrix_head = &harness.chain.head_snapshot().beacon_block; - assert!( - two_after_bellatrix_head - .message() - .body() - .execution_payload() - .unwrap() - .is_default_with_empty_roots(), - "Two after bellatrix head is default payload" - ); - assert_eq!(two_after_bellatrix_head.slot(), bellatrix_fork_slot + 2); - - /* - * Next Bellatrix block should include an exec payload. - */ - let mut execution_payloads = vec![]; - for _ in (bellatrix_fork_slot.as_u64() + 3)..capella_fork_slot.as_u64() { - harness.extend_slots(1).await; - let block = &harness.chain.head_snapshot().beacon_block; - let full_payload: FullPayload = - block.message().body().execution_payload().unwrap().into(); - // pre-capella shouldn't have withdrawals - assert!(full_payload.withdrawals_root().is_err()); - execution_payloads.push(full_payload); - } - - /* - * Should enter capella fork now. - */ - for _ in 0..16 { - harness.extend_slots(1).await; - let block = &harness.chain.head_snapshot().beacon_block; - let full_payload: FullPayload = - block.message().body().execution_payload().unwrap().into(); - // post-capella should have withdrawals - assert!(full_payload.withdrawals_root().is_ok()); - execution_payloads.push(full_payload); - } - - verify_execution_payload_chain(execution_payloads.as_slice()); -} diff --git a/beacon_node/beacon_chain/tests/events.rs b/beacon_node/beacon_chain/tests/events.rs index 92727ffd76..121f8c255d 100644 --- a/beacon_node/beacon_chain/tests/events.rs +++ b/beacon_node/beacon_chain/tests/events.rs @@ -115,7 +115,7 @@ async fn data_column_sidecar_event_on_process_gossip_data_column() { /// Verifies that a blob event is emitted when blobs are received via RPC. #[tokio::test] async fn blob_sidecar_event_on_process_rpc_blobs() { - if fork_name_from_env().is_some_and(|f| !f.deneb_enabled() || f.fulu_enabled()) { + if fork_name_from_env().is_none_or(|f| !f.deneb_enabled() || f.fulu_enabled()) { return; }; @@ -170,7 +170,7 @@ async fn blob_sidecar_event_on_process_rpc_blobs() { #[tokio::test] async fn data_column_sidecar_event_on_process_rpc_columns() { - if fork_name_from_env().is_some_and(|f| !f.fulu_enabled()) { + if fork_name_from_env().is_none_or(|f| !f.fulu_enabled()) { return; }; diff --git a/beacon_node/beacon_chain/tests/main.rs b/beacon_node/beacon_chain/tests/main.rs index aec4416419..e02c488ac6 100644 --- a/beacon_node/beacon_chain/tests/main.rs +++ b/beacon_node/beacon_chain/tests/main.rs @@ -1,9 +1,7 @@ mod attestation_production; mod attestation_verification; -mod bellatrix; mod blob_verification; mod block_verification; -mod capella; mod column_verification; mod events; mod op_verification; diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index 7fd70f0e77..b282adecd5 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -3,8 +3,8 @@ use beacon_chain::block_verification_types::RpcBlock; use beacon_chain::{ BeaconChainError, BlockError, ChainConfig, ExecutionPayloadError, - INVALID_JUSTIFIED_PAYLOAD_SHUTDOWN_REASON, NotifyExecutionLayer, OverrideForkchoiceUpdate, - StateSkipConfig, WhenSlotSkipped, + INVALID_JUSTIFIED_PAYLOAD_SHUTDOWN_REASON, NotifyExecutionLayer, StateSkipConfig, + WhenSlotSkipped, canonical_head::{CachedHead, CanonicalHead}, test_utils::{BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, test_spec}, }; @@ -138,25 +138,6 @@ impl InvalidPayloadRig { payload_attributes } - fn move_to_terminal_block(&self) { - let mock_execution_layer = self.harness.mock_execution_layer.as_ref().unwrap(); - mock_execution_layer - .server - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - } - - fn latest_execution_block_hash(&self) -> ExecutionBlockHash { - let mock_execution_layer = self.harness.mock_execution_layer.as_ref().unwrap(); - mock_execution_layer - .server - .execution_block_generator() - .latest_execution_block() - .unwrap() - .block_hash - } - async fn build_blocks(&mut self, num_blocks: u64, is_valid: Payload) -> Vec { let mut roots = Vec::with_capacity(num_blocks as usize); for _ in 0..num_blocks { @@ -393,7 +374,6 @@ async fn valid_invalid_syncing() { return; } let mut rig = InvalidPayloadRig::new(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; rig.import_block(Payload::Invalid { @@ -411,7 +391,6 @@ async fn invalid_payload_invalidates_parent() { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. rig.move_to_first_justification(Payload::Syncing).await; @@ -443,7 +422,6 @@ async fn immediate_forkchoice_update_invalid_test( invalid_payload: impl FnOnce(Option) -> Payload, ) { let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. rig.move_to_first_justification(Payload::Syncing).await; @@ -501,7 +479,6 @@ async fn justified_checkpoint_becomes_invalid() { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. rig.move_to_first_justification(Payload::Syncing).await; @@ -549,7 +526,6 @@ async fn pre_finalized_latest_valid_hash() { let finalized_epoch = 2; let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); let mut blocks = vec![]; blocks.push(rig.import_block(Payload::Valid).await); // Import a valid transition block. blocks.extend(rig.build_blocks(num_blocks - 1, Payload::Syncing).await); @@ -598,7 +574,6 @@ async fn latest_valid_hash_will_not_validate() { const LATEST_VALID_SLOT: u64 = 3; let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); let mut blocks = vec![]; blocks.push(rig.import_block(Payload::Valid).await); // Import a valid transition block. @@ -649,7 +624,6 @@ async fn latest_valid_hash_is_junk() { let finalized_epoch = 3; let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); let mut blocks = vec![]; blocks.push(rig.import_block(Payload::Valid).await); // Import a valid transition block. blocks.extend(rig.build_blocks(num_blocks, Payload::Syncing).await); @@ -694,7 +668,6 @@ async fn invalidates_all_descendants() { let finalized_slot = E::slots_per_epoch() * 2; let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let blocks = rig.build_blocks(num_blocks, Payload::Syncing).await; @@ -804,7 +777,6 @@ async fn switches_heads() { let finalized_slot = E::slots_per_epoch() * 2; let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let blocks = rig.build_blocks(num_blocks, Payload::Syncing).await; @@ -906,7 +878,6 @@ async fn invalid_during_processing() { return; } let mut rig = InvalidPayloadRig::new(); - rig.move_to_terminal_block(); let roots = &[ rig.import_block(Payload::Valid).await, @@ -941,7 +912,6 @@ async fn invalid_after_optimistic_sync() { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let mut roots = vec![ @@ -982,7 +952,6 @@ async fn manually_validate_child() { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let parent = rig.import_block(Payload::Syncing).await; @@ -1003,7 +972,6 @@ async fn manually_validate_parent() { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let parent = rig.import_block(Payload::Syncing).await; @@ -1024,7 +992,6 @@ async fn payload_preparation() { return; } let mut rig = InvalidPayloadRig::new(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; let el = rig.execution_layer(); @@ -1088,7 +1055,6 @@ async fn invalid_parent() { return; } let mut rig = InvalidPayloadRig::new(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. // Import a syncing block atop the transition block (we'll call this the "parent block" since we @@ -1156,89 +1122,12 @@ async fn invalid_parent() { )); } -/// Tests to ensure that we will still send a proposer preparation -#[tokio::test] -async fn payload_preparation_before_transition_block() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { - return; - } - let rig = InvalidPayloadRig::new(); - let el = rig.execution_layer(); - - // Run the watchdog routine so that the status of the execution engine is set. This ensures - // that we don't end up with `eth_syncing` requests later in this function that will impede - // testing. - el.watchdog_task().await; - - let head = rig.harness.chain.head_snapshot(); - assert_eq!( - head.beacon_block - .message() - .body() - .execution_payload() - .unwrap() - .block_hash(), - ExecutionBlockHash::zero(), - "the head block is post-bellatrix but pre-transition" - ); - - let current_slot = rig.harness.chain.slot().unwrap(); - let next_slot = current_slot + 1; - let proposer = head - .beacon_state - .get_beacon_proposer_index(next_slot, &rig.harness.chain.spec) - .unwrap(); - let fee_recipient = Address::repeat_byte(99); - - // Provide preparation data to the EL for `proposer`. - el.update_proposer_preparation( - Epoch::new(0), - [( - &ProposerPreparationData { - validator_index: proposer as u64, - fee_recipient, - }, - &None, - )], - ) - .await; - - rig.move_to_terminal_block(); - - rig.harness - .chain - .prepare_beacon_proposer(current_slot) - .await - .unwrap(); - let forkchoice_update_params = rig - .harness - .chain - .canonical_head - .fork_choice_read_lock() - .get_forkchoice_update_parameters(); - rig.harness - .chain - .update_execution_engine_forkchoice( - current_slot, - forkchoice_update_params, - OverrideForkchoiceUpdate::Yes, - ) - .await - .unwrap(); - - let (fork_choice_state, payload_attributes) = rig.previous_forkchoice_update_params(); - let latest_block_hash = rig.latest_execution_block_hash(); - assert_eq!(payload_attributes.suggested_fee_recipient(), fee_recipient); - assert_eq!(fork_choice_state.head_block_hash, latest_block_hash); -} - #[tokio::test] async fn attesting_to_optimistic_head() { if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { return; } let mut rig = InvalidPayloadRig::new(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let root = rig.import_block(Payload::Syncing).await; @@ -1361,7 +1250,6 @@ impl InvalidHeadSetup { async fn new() -> InvalidHeadSetup { let slots_per_epoch = E::slots_per_epoch(); let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. // Import blocks until the first time the chain finalizes. This avoids @@ -1546,7 +1434,6 @@ async fn weights_after_resetting_optimistic_status() { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); - rig.move_to_terminal_block(); rig.import_block(Payload::Valid).await; // Import a valid transition block. let mut roots = vec![]; diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index ff20e999bb..cfc53c8ce0 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -5558,6 +5558,226 @@ fn check_iterators_from_slot(harness: &TestHarness, slot: Slot) { ); } +/// Test that blocks with default (pre-merge) execution payloads and non-default (post-merge) +/// execution payloads can be produced, stored, and retrieved correctly through a merge transition. +/// +/// Spec (see .claude/plans/8658.md): +/// - Bellatrix at epoch 0 (genesis), genesis has default execution payload header +/// - Slots 1-9: blocks have default (zeroed) execution payloads +/// - Slot 10: first block with a non-default execution payload (merge transition block) +/// - Slots 11-32+: non-default payloads, each with parent_hash == prev payload block_hash +/// - Chain must finalize past genesis +#[tokio::test] +async fn bellatrix_produce_and_store_payloads() { + use beacon_chain::test_utils::{ + DEFAULT_ETH1_BLOCK_HASH, HARNESS_GENESIS_TIME, InteropGenesisBuilder, + }; + use safe_arith::SafeArith; + use state_processing::per_block_processing::is_merge_transition_complete; + use tree_hash::TreeHash; + + let merge_slot = 10u64; + let total_slots = 48u64; + let spec = ForkName::Bellatrix.make_genesis_spec(E::default_spec()); + + // Build genesis state with a default (zeroed) execution payload header so that + // is_merge_transition_complete = false at genesis. + let keypairs = KEYPAIRS[0..LOW_VALIDATOR_COUNT].to_vec(); + let genesis_state = InteropGenesisBuilder::default() + .set_alternating_eth1_withdrawal_credentials() + .set_opt_execution_payload_header(None) + .build_genesis_state( + &keypairs, + HARNESS_GENESIS_TIME, + Hash256::from_slice(DEFAULT_ETH1_BLOCK_HASH), + &spec, + ) + .unwrap(); + + assert!( + !is_merge_transition_complete(&genesis_state), + "genesis should NOT have merge complete" + ); + + let db_path = tempdir().unwrap(); + let store = get_store_generic( + &db_path, + StoreConfig { + prune_payloads: false, + ..StoreConfig::default() + }, + spec.clone(), + ); + + let chain_config = ChainConfig { + archive: true, + ..ChainConfig::default() + }; + let harness = TestHarness::builder(MinimalEthSpec) + .spec(store.get_chain_spec().clone()) + .keypairs(keypairs.clone()) + .fresh_disk_store(store.clone()) + .override_store_mutator(Box::new(move |builder: BeaconChainBuilder<_>| { + builder + .genesis_state(genesis_state) + .expect("should set genesis state") + })) + .mock_execution_layer() + .chain_config(chain_config) + .build(); + + harness + .mock_execution_layer + .as_ref() + .unwrap() + .server + .all_payloads_valid(); + + harness.advance_slot(); + + // Phase 1: slots 1 to merge_slot-1 — blocks with default execution payloads. + let mut state = harness.get_current_state(); + for slot_num in 1..merge_slot { + let slot = Slot::new(slot_num); + harness.advance_slot(); + harness + .build_and_import_block_with_payload( + &mut state, + slot, + ExecutionPayloadBellatrix::default(), + ) + .await; + state = harness.get_current_state(); + } + + // Phase 2: slot merge_slot — the merge transition block with a real payload. + { + let slot = Slot::new(merge_slot); + harness.advance_slot(); + + // Advance state to compute correct timestamp and randao. + let mut pre_state = state.clone(); + complete_state_advance(&mut pre_state, None, slot, &harness.spec) + .expect("should advance state"); + pre_state + .build_caches(&harness.spec) + .expect("should build caches"); + + let timestamp = pre_state + .genesis_time() + .safe_add( + slot.as_u64() + .safe_mul(harness.spec.seconds_per_slot) + .unwrap(), + ) + .unwrap(); + let prev_randao = *pre_state.get_randao_mix(pre_state.current_epoch()).unwrap(); + + let mut transition_payload = ExecutionPayloadBellatrix { + parent_hash: ExecutionBlockHash::zero(), + fee_recipient: Address::repeat_byte(42), + receipts_root: Hash256::repeat_byte(42), + state_root: Hash256::repeat_byte(43), + logs_bloom: vec![0; 256].try_into().unwrap(), + prev_randao, + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp, + extra_data: VariableList::empty(), + base_fee_per_gas: Uint256::from(1u64), + block_hash: ExecutionBlockHash::zero(), + transactions: VariableList::empty(), + }; + transition_payload.block_hash = + ExecutionBlockHash::from_root(transition_payload.tree_hash_root()); + + // Insert the transition payload into the mock EL so subsequent blocks can chain. + { + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + let mut block_gen = mock_el.server.execution_block_generator(); + block_gen.insert_block_without_checks(execution_layer::test_utils::Block::PoS( + ExecutionPayload::Bellatrix(transition_payload.clone()), + )); + } + + harness + .build_and_import_block_with_payload(&mut state, slot, transition_payload) + .await; + state = harness.get_current_state(); + + assert!( + is_merge_transition_complete(&state), + "merge should be complete after slot {merge_slot}" + ); + } + + // Phase 3: slots merge_slot+1 to total_slots — use harness with attestations. + let post_merge_slots = (total_slots - merge_slot) as usize; + harness.extend_slots(post_merge_slots).await; + + // ---- Verification: check all blocks in the store against plan invariants ---- + + let mut prev_payload_block_hash: Option = None; + + for slot_num in 1..=total_slots { + let slot = Slot::new(slot_num); + let block_root = harness + .chain + .block_root_at_slot(slot, WhenSlotSkipped::Prev) + .unwrap() + .unwrap_or_else(|| panic!("missing block at slot {slot_num}")); + let block = store + .get_blinded_block(&block_root) + .unwrap() + .unwrap_or_else(|| panic!("block not in store at slot {slot_num}")); + let payload = block + .message() + .body() + .execution_payload() + .expect("bellatrix block should have execution payload"); + + if slot_num < merge_slot { + // Slots 1 to merge_slot-1: payload must be default. + assert!( + payload.is_default_with_empty_roots(), + "slot {slot_num} should have default payload" + ); + } else if slot_num == merge_slot { + // Merge transition block: first non-default payload. + assert!( + !payload.is_default_with_empty_roots(), + "slot {slot_num} (merge) should have non-default payload" + ); + prev_payload_block_hash = Some(payload.block_hash()); + } else { + // Post-merge: non-default payload with valid parent_hash chain. + assert!( + !payload.is_default_with_empty_roots(), + "slot {slot_num} should have non-default payload" + ); + assert_eq!( + payload.parent_hash(), + prev_payload_block_hash.unwrap(), + "slot {slot_num} payload parent_hash should chain from previous payload" + ); + prev_payload_block_hash = Some(payload.block_hash()); + } + } + + // Verify finalization. + let finalized_epoch = harness + .chain + .canonical_head + .cached_head() + .finalized_checkpoint() + .epoch; + assert!( + finalized_epoch > 0, + "chain should have finalized past genesis" + ); +} + fn get_finalized_epoch_boundary_blocks( dump: &[BeaconSnapshot>], ) -> HashSet { diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index 1b395ac8da..865599b9bd 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -281,7 +281,7 @@ where validator_count, genesis_time, } => { - let execution_payload_header = generate_genesis_header(&spec, true); + let execution_payload_header = generate_genesis_header(&spec); let keypairs = generate_deterministic_keypairs(validator_count); let genesis_state = interop_genesis_state( &keypairs, diff --git a/beacon_node/client/src/notifier.rs b/beacon_node/client/src/notifier.rs index 21a5abeb6c..c1d8cae573 100644 --- a/beacon_node/client/src/notifier.rs +++ b/beacon_node/client/src/notifier.rs @@ -1,9 +1,7 @@ use crate::metrics; use beacon_chain::{ BeaconChain, BeaconChainTypes, ExecutionStatus, - bellatrix_readiness::{ - BellatrixReadiness, GenesisExecutionPayloadStatus, MergeConfig, SECONDS_IN_A_WEEK, - }, + bellatrix_readiness::GenesisExecutionPayloadStatus, }; use execution_layer::{ EngineCapabilities, @@ -36,6 +34,7 @@ const SPEEDO_OBSERVATIONS: usize = 4; /// The number of slots between logs that give detail about backfill process. const BACKFILL_LOG_INTERVAL: u64 = 5; +const SECONDS_IN_A_WEEK: u64 = 604800; pub const FORK_READINESS_PREPARATION_SECONDS: u64 = SECONDS_IN_A_WEEK * 2; pub const ENGINE_CAPABILITIES_REFRESH_INTERVAL: u64 = 300; @@ -70,7 +69,6 @@ pub fn spawn_notifier( wait_time = estimated_time_pretty(Some(next_slot.as_secs() as f64)), "Waiting for genesis" ); - bellatrix_readiness_logging(Slot::new(0), &beacon_chain).await; post_bellatrix_readiness_logging(Slot::new(0), &beacon_chain).await; genesis_execution_payload_logging(&beacon_chain).await; sleep(slot_duration).await; @@ -414,7 +412,6 @@ pub fn spawn_notifier( ); } - bellatrix_readiness_logging(current_slot, &beacon_chain).await; post_bellatrix_readiness_logging(current_slot, &beacon_chain).await; } }; @@ -425,88 +422,7 @@ pub fn spawn_notifier( Ok(()) } -/// Provides some helpful logging to users to indicate if their node is ready for the Bellatrix -/// fork and subsequent merge transition. -async fn bellatrix_readiness_logging( - current_slot: Slot, - beacon_chain: &BeaconChain, -) { - // There is no execution payload in gloas blocks, so this will trigger - // bellatrix readiness logging in gloas if we dont skip the check below - if beacon_chain - .spec - .fork_name_at_slot::(current_slot) - .gloas_enabled() - { - return; - } - - let merge_completed = beacon_chain - .canonical_head - .cached_head() - .snapshot - .beacon_block - .message() - .body() - .execution_payload() - .is_ok_and(|payload| payload.parent_hash() != ExecutionBlockHash::zero()); - - let has_execution_layer = beacon_chain.execution_layer.is_some(); - - if merge_completed && has_execution_layer - || !beacon_chain.is_time_to_prepare_for_bellatrix(current_slot) - { - return; - } - - match beacon_chain.check_bellatrix_readiness(current_slot).await { - BellatrixReadiness::Ready { - config, - current_difficulty, - } => match config { - MergeConfig { - terminal_total_difficulty: Some(ttd), - terminal_block_hash: None, - terminal_block_hash_epoch: None, - } => { - info!( - terminal_total_difficulty = %ttd, - current_difficulty = current_difficulty - .map(|d| d.to_string()) - .unwrap_or_else(|| "??".into()), - "Ready for Bellatrix" - ) - } - MergeConfig { - terminal_total_difficulty: _, - terminal_block_hash: Some(terminal_block_hash), - terminal_block_hash_epoch: Some(terminal_block_hash_epoch), - } => { - info!( - info = "you are using override parameters, please ensure that you \ - understand these parameters and their implications.", - ?terminal_block_hash, - ?terminal_block_hash_epoch, - "Ready for Bellatrix" - ) - } - other => error!( - config = ?other, - "Inconsistent merge configuration" - ), - }, - readiness @ BellatrixReadiness::NotSynced => warn!( - info = %readiness, - "Not ready Bellatrix" - ), - readiness @ BellatrixReadiness::NoExecutionEndpoint => warn!( - info = %readiness, - "Not ready for Bellatrix" - ), - } -} - -/// Provides some helpful logging to users to indicate if their node is ready for Capella +/// Provides some helpful logging to users to indicate if their node is ready for upcoming forks async fn post_bellatrix_readiness_logging( current_slot: Slot, beacon_chain: &BeaconChain, diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 157fe152ef..d6796f6a05 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -22,7 +22,6 @@ use eth2::types::{ForkVersionedResponse, builder::SignedBuilderBid}; use fixed_bytes::UintExtended; use fork_choice::ForkchoiceUpdateParameters; use logging::crit; -use lru::LruCache; pub use payload_status::PayloadStatus; use payload_status::process_payload_status; use sensitive_url::SensitiveUrl; @@ -32,7 +31,6 @@ use std::collections::{HashMap, hash_map::Entry}; use std::fmt; use std::future::Future; use std::io::Write; -use std::num::NonZeroUsize; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -45,6 +43,7 @@ use tokio::{ use tokio_stream::wrappers::WatchStream; use tracing::{Instrument, debug, debug_span, error, info, instrument, warn}; use tree_hash::TreeHash; +use types::ExecutionPayloadGloas; use types::builder::BuilderBid; use types::execution::BlockProductionVersion; use types::kzg_ext::KzgCommitments; @@ -57,7 +56,6 @@ use types::{ ExecutionPayloadCapella, ExecutionPayloadElectra, ExecutionPayloadFulu, FullPayload, ProposerPreparationData, Slot, }; -use types::{ExecutionPayloadGloas, new_non_zero_usize}; mod block_hash; mod engine_api; @@ -75,10 +73,6 @@ pub const DEFAULT_EXECUTION_ENDPOINT: &str = "http://localhost:8551/"; /// Name for the default file used for the jwt secret. pub const DEFAULT_JWT_FILE: &str = "jwt.hex"; -/// Each time the `ExecutionLayer` retrieves a block from an execution node, it stores that block -/// in an LRU cache to avoid redundant lookups. This is the size of that cache. -const EXECUTION_BLOCKS_LRU_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(128); - /// A fee recipient address for use during block production. Only used as a very last resort if /// there is no address provided by the user. /// @@ -452,7 +446,6 @@ struct Inner { execution_engine_forkchoice_lock: Mutex<()>, suggested_fee_recipient: Option
, proposer_preparation_data: Mutex>, - execution_blocks: Mutex>, proposers: RwLock>, executor: TaskExecutor, payload_cache: PayloadCache, @@ -563,7 +556,6 @@ impl ExecutionLayer { suggested_fee_recipient, proposer_preparation_data: Mutex::new(HashMap::new()), proposers: RwLock::new(HashMap::new()), - execution_blocks: Mutex::new(LruCache::new(EXECUTION_BLOCKS_LRU_CACHE_SIZE)), executor, payload_cache: PayloadCache::default(), last_new_payload_errored: RwLock::new(false), @@ -655,12 +647,6 @@ impl ExecutionLayer { .ok_or(ApiError::ExecutionHeadBlockNotFound)?; Ok(block.total_difficulty) } - /// Note: this function returns a mutex guard, be careful to avoid deadlocks. - async fn execution_blocks( - &self, - ) -> MutexGuard<'_, LruCache> { - self.inner.execution_blocks.lock().await - } /// Gives access to a channel containing if the last engine state is online or not. /// @@ -1641,208 +1627,6 @@ impl ExecutionLayer { Ok(versions) } - /// Used during block production to determine if the merge has been triggered. - /// - /// ## Specification - /// - /// `get_terminal_pow_block_hash` - /// - /// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md - pub async fn get_terminal_pow_block_hash( - &self, - spec: &ChainSpec, - timestamp: u64, - ) -> Result, Error> { - let _timer = metrics::start_timer_vec( - &metrics::EXECUTION_LAYER_REQUEST_TIMES, - &[metrics::GET_TERMINAL_POW_BLOCK_HASH], - ); - - let hash_opt = self - .engine() - .request(|engine| async move { - let terminal_block_hash = spec.terminal_block_hash; - if terminal_block_hash != ExecutionBlockHash::zero() { - if self - .get_pow_block(engine, terminal_block_hash) - .await? - .is_some() - { - return Ok(Some(terminal_block_hash)); - } else { - return Ok(None); - } - } - - let block = self.get_pow_block_at_total_difficulty(engine, spec).await?; - if let Some(pow_block) = block { - // If `terminal_block.timestamp == transition_block.timestamp`, - // we violate the invariant that a block's timestamp must be - // strictly greater than its parent's timestamp. - // The execution layer will reject a fcu call with such payload - // attributes leading to a missed block. - // Hence, we return `None` in such a case. - if pow_block.timestamp >= timestamp { - return Ok(None); - } - } - Ok(block.map(|b| b.block_hash)) - }) - .await - .map_err(Box::new) - .map_err(Error::EngineError)?; - - if let Some(hash) = &hash_opt { - info!( - terminal_block_hash_override = ?spec.terminal_block_hash, - terminal_total_difficulty = ?spec.terminal_total_difficulty, - block_hash = ?hash, - "Found terminal block hash" - ); - } - - Ok(hash_opt) - } - - /// This function should remain internal. External users should use - /// `self.get_terminal_pow_block` instead, since it checks against the terminal block hash - /// override. - /// - /// ## Specification - /// - /// `get_pow_block_at_terminal_total_difficulty` - /// - /// https://github.com/ethereum/consensus-specs/blob/v1.1.5/specs/merge/validator.md - async fn get_pow_block_at_total_difficulty( - &self, - engine: &Engine, - spec: &ChainSpec, - ) -> Result, ApiError> { - let mut block = engine - .api - .get_block_by_number(BlockByNumberQuery::Tag(LATEST_TAG)) - .await? - .ok_or(ApiError::ExecutionHeadBlockNotFound)?; - - self.execution_blocks().await.put(block.block_hash, block); - - loop { - let block_reached_ttd = - block.terminal_total_difficulty_reached(spec.terminal_total_difficulty); - if block_reached_ttd { - if block.parent_hash == ExecutionBlockHash::zero() { - return Ok(Some(block)); - } - let parent = self - .get_pow_block(engine, block.parent_hash) - .await? - .ok_or(ApiError::ExecutionBlockNotFound(block.parent_hash))?; - let parent_reached_ttd = - parent.terminal_total_difficulty_reached(spec.terminal_total_difficulty); - - if block_reached_ttd && !parent_reached_ttd { - return Ok(Some(block)); - } else { - block = parent; - } - } else { - return Ok(None); - } - } - } - - /// Used during block verification to check that a block correctly triggers the merge. - /// - /// ## Returns - /// - /// - `Some(true)` if the given `block_hash` is the terminal proof-of-work block. - /// - `Some(false)` if the given `block_hash` is certainly *not* the terminal proof-of-work - /// block. - /// - `None` if the `block_hash` or its parent were not present on the execution engine. - /// - `Err(_)` if there was an error connecting to the execution engine. - /// - /// ## Fallback Behaviour - /// - /// The request will be broadcast to all nodes, simultaneously. It will await a response (or - /// failure) from all nodes and then return based on the first of these conditions which - /// returns true: - /// - /// - Terminal, if any node indicates it is terminal. - /// - Not terminal, if any node indicates it is non-terminal. - /// - Block not found, if any node cannot find the block. - /// - An error, if all nodes return an error. - /// - /// ## Specification - /// - /// `is_valid_terminal_pow_block` - /// - /// https://github.com/ethereum/consensus-specs/blob/v1.1.0/specs/merge/fork-choice.md - pub async fn is_valid_terminal_pow_block_hash( - &self, - block_hash: ExecutionBlockHash, - spec: &ChainSpec, - ) -> Result, Error> { - let _timer = metrics::start_timer_vec( - &metrics::EXECUTION_LAYER_REQUEST_TIMES, - &[metrics::IS_VALID_TERMINAL_POW_BLOCK_HASH], - ); - - self.engine() - .request(|engine| async move { - if let Some(pow_block) = self.get_pow_block(engine, block_hash).await? - && let Some(pow_parent) = - self.get_pow_block(engine, pow_block.parent_hash).await? - { - return Ok(Some( - self.is_valid_terminal_pow_block(pow_block, pow_parent, spec), - )); - } - Ok(None) - }) - .await - .map_err(Box::new) - .map_err(Error::EngineError) - } - - /// This function should remain internal. - /// - /// External users should use `self.is_valid_terminal_pow_block_hash`. - fn is_valid_terminal_pow_block( - &self, - block: ExecutionBlock, - parent: ExecutionBlock, - spec: &ChainSpec, - ) -> bool { - let is_total_difficulty_reached = - block.terminal_total_difficulty_reached(spec.terminal_total_difficulty); - let is_parent_total_difficulty_valid = parent - .total_difficulty - .is_some_and(|td| td < spec.terminal_total_difficulty); - is_total_difficulty_reached && is_parent_total_difficulty_valid - } - - /// Maps to the `eth_getBlockByHash` JSON-RPC call. - async fn get_pow_block( - &self, - engine: &Engine, - hash: ExecutionBlockHash, - ) -> Result, ApiError> { - if let Some(cached) = self.execution_blocks().await.get(&hash).copied() { - // The block was in the cache, no need to request it from the execution - // engine. - return Ok(Some(cached)); - } - - // The block was *not* in the cache, request it from the execution - // engine and cache it for future reference. - if let Some(block) = engine.api.get_block_by_hash(hash).await? { - self.execution_blocks().await.put(hash, block); - Ok(Some(block)) - } else { - Ok(None) - } - } - pub async fn get_payload_bodies_by_hash( &self, hashes: Vec, @@ -2330,15 +2114,6 @@ async fn timed_future, T>(metric: &str, future: F) -> (T, (result, duration) } -#[cfg(test)] -/// Returns the duration since the unix epoch. -fn timestamp_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) - .as_secs() -} - fn noop( _: &ExecutionLayer, _: PayloadContentsRefTuple, @@ -2359,7 +2134,6 @@ mod test { async fn produce_three_valid_pos_execution_blocks() { let runtime = TestRuntime::default(); MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_terminal_block() .produce_valid_execution_payload_on_head() .await .produce_valid_execution_payload_on_head() @@ -2388,129 +2162,4 @@ mod test { Some(30_029_266) ); } - - #[tokio::test] - async fn test_forked_terminal_block() { - let runtime = TestRuntime::default(); - let (mock, block_hash) = MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_terminal_block() - .produce_forked_pow_block(); - assert!( - mock.el - .is_valid_terminal_pow_block_hash(block_hash, &mock.spec) - .await - .unwrap() - .unwrap() - ); - } - - #[tokio::test] - async fn finds_valid_terminal_block_hash() { - let runtime = TestRuntime::default(); - MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_block_prior_to_terminal_block() - .with_terminal_block(|spec, el, _| async move { - el.engine().upcheck().await; - assert_eq!( - el.get_terminal_pow_block_hash(&spec, timestamp_now()) - .await - .unwrap(), - None - ) - }) - .await - .move_to_terminal_block() - .with_terminal_block(|spec, el, terminal_block| async move { - assert_eq!( - el.get_terminal_pow_block_hash(&spec, timestamp_now()) - .await - .unwrap(), - Some(terminal_block.unwrap().block_hash) - ) - }) - .await; - } - - #[tokio::test] - async fn rejects_terminal_block_with_equal_timestamp() { - let runtime = TestRuntime::default(); - MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_block_prior_to_terminal_block() - .with_terminal_block(|spec, el, _| async move { - el.engine().upcheck().await; - assert_eq!( - el.get_terminal_pow_block_hash(&spec, timestamp_now()) - .await - .unwrap(), - None - ) - }) - .await - .move_to_terminal_block() - .with_terminal_block(|spec, el, terminal_block| async move { - let timestamp = terminal_block.as_ref().map(|b| b.timestamp).unwrap(); - assert_eq!( - el.get_terminal_pow_block_hash(&spec, timestamp) - .await - .unwrap(), - None - ) - }) - .await; - } - - #[tokio::test] - async fn verifies_valid_terminal_block_hash() { - let runtime = TestRuntime::default(); - MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_terminal_block() - .with_terminal_block(|spec, el, terminal_block| async move { - el.engine().upcheck().await; - assert_eq!( - el.is_valid_terminal_pow_block_hash(terminal_block.unwrap().block_hash, &spec) - .await - .unwrap(), - Some(true) - ) - }) - .await; - } - - #[tokio::test] - async fn rejects_invalid_terminal_block_hash() { - let runtime = TestRuntime::default(); - MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_terminal_block() - .with_terminal_block(|spec, el, terminal_block| async move { - el.engine().upcheck().await; - let invalid_terminal_block = terminal_block.unwrap().parent_hash; - - assert_eq!( - el.is_valid_terminal_pow_block_hash(invalid_terminal_block, &spec) - .await - .unwrap(), - Some(false) - ) - }) - .await; - } - - #[tokio::test] - async fn rejects_unknown_terminal_block_hash() { - let runtime = TestRuntime::default(); - MockExecutionLayer::default_params(runtime.task_executor.clone()) - .move_to_terminal_block() - .with_terminal_block(|spec, el, _| async move { - el.engine().upcheck().await; - let missing_terminal_block = ExecutionBlockHash::repeat_byte(42); - - assert_eq!( - el.is_valid_terminal_pow_block_hash(missing_terminal_block, &spec) - .await - .unwrap(), - None - ) - }) - .await; - } } diff --git a/beacon_node/execution_layer/src/metrics.rs b/beacon_node/execution_layer/src/metrics.rs index 859f33bc81..79bdc37aea 100644 --- a/beacon_node/execution_layer/src/metrics.rs +++ b/beacon_node/execution_layer/src/metrics.rs @@ -10,8 +10,6 @@ pub const GET_BLINDED_PAYLOAD_BUILDER: &str = "get_blinded_payload_builder"; pub const POST_BLINDED_PAYLOAD_BUILDER: &str = "post_blinded_payload_builder"; pub const NEW_PAYLOAD: &str = "new_payload"; pub const FORKCHOICE_UPDATED: &str = "forkchoice_updated"; -pub const GET_TERMINAL_POW_BLOCK_HASH: &str = "get_terminal_pow_block_hash"; -pub const IS_VALID_TERMINAL_POW_BLOCK_HASH: &str = "is_valid_terminal_pow_block_hash"; pub const LOCAL: &str = "local"; pub const BUILDER: &str = "builder"; pub const SUCCESS: &str = "success"; diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index 8591359f15..1743b340ab 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -28,8 +28,6 @@ use types::{ Transactions, Uint256, }; -use super::DEFAULT_TERMINAL_BLOCK; - const TEST_BLOB_BUNDLE: &[u8] = include_bytes!("fixtures/mainnet/test_blobs_bundle.ssz"); const TEST_BLOB_BUNDLE_V2: &[u8] = include_bytes!("fixtures/mainnet/test_blobs_bundle_v2.ssz"); @@ -172,9 +170,6 @@ fn make_rng() -> Arc> { impl ExecutionBlockGenerator { #[allow(clippy::too_many_arguments)] pub fn new( - terminal_total_difficulty: Uint256, - terminal_block_number: u64, - terminal_block_hash: ExecutionBlockHash, shanghai_time: Option, cancun_time: Option, prague_time: Option, @@ -187,9 +182,9 @@ impl ExecutionBlockGenerator { finalized_block_hash: <_>::default(), blocks: <_>::default(), block_hashes: <_>::default(), - terminal_total_difficulty, - terminal_block_number, - terminal_block_hash, + terminal_total_difficulty: Default::default(), + terminal_block_number: 0, + terminal_block_hash: Default::default(), pending_payloads: <_>::default(), next_payload_id: 0, payload_ids: <_>::default(), @@ -293,25 +288,6 @@ impl ExecutionBlockGenerator { .and_then(|block| block.as_execution_payload()) } - pub fn move_to_block_prior_to_terminal_block(&mut self) -> Result<(), String> { - let target_block = self - .terminal_block_number - .checked_sub(1) - .ok_or("terminal pow block is 0")?; - self.move_to_pow_block(target_block) - } - - pub fn move_to_terminal_block(&mut self) -> Result<(), String> { - self.move_to_pow_block(self.terminal_block_number) - } - - pub fn move_to_pow_block(&mut self, target_block: u64) -> Result<(), String> { - let next_block = self.latest_block().unwrap().block_number() + 1; - assert!(target_block >= next_block); - - self.insert_pow_blocks(next_block..=target_block) - } - pub fn drop_all_blocks(&mut self) { self.blocks = <_>::default(); self.block_hashes = <_>::default(); @@ -879,27 +855,22 @@ fn payload_id_from_u64(n: u64) -> PayloadId { n.to_le_bytes() } -pub fn generate_genesis_header( - spec: &ChainSpec, - post_transition_merge: bool, -) -> Option> { +pub fn generate_genesis_header(spec: &ChainSpec) -> Option> { let genesis_fork = spec.fork_name_at_slot::(spec.genesis_slot); - let genesis_block_hash = - generate_genesis_block(spec.terminal_total_difficulty, DEFAULT_TERMINAL_BLOCK) - .ok() - .map(|block| block.block_hash); + let genesis_block_hash = generate_genesis_block(Default::default(), 0) + .ok() + .map(|block| block.block_hash); let empty_transactions_root = Transactions::::empty().tree_hash_root(); match genesis_fork { - ForkName::Base | ForkName::Altair => None, + ForkName::Base | ForkName::Altair => { + // Pre-Bellatrix forks have no execution payload + None + } ForkName::Bellatrix => { - if post_transition_merge { - let mut header = ExecutionPayloadHeader::Bellatrix(<_>::default()); - *header.block_hash_mut() = genesis_block_hash.unwrap_or_default(); - *header.transactions_root_mut() = empty_transactions_root; - Some(header) - } else { - Some(ExecutionPayloadHeader::::Bellatrix(<_>::default())) - } + let mut header = ExecutionPayloadHeader::Bellatrix(<_>::default()); + *header.block_hash_mut() = genesis_block_hash.unwrap_or_default(); + *header.transactions_root_mut() = empty_transactions_root; + Some(header) } ForkName::Capella => { let mut header = ExecutionPayloadHeader::Capella(<_>::default()); @@ -985,70 +956,6 @@ mod test { use kzg::{Bytes48, CellRef, KzgBlobRef, trusted_setup::get_trusted_setup}; use types::{MainnetEthSpec, MinimalEthSpec}; - #[test] - fn pow_chain_only() { - const TERMINAL_DIFFICULTY: u64 = 10; - const TERMINAL_BLOCK: u64 = 10; - const DIFFICULTY_INCREMENT: u64 = 1; - - let mut generator: ExecutionBlockGenerator = ExecutionBlockGenerator::new( - Uint256::from(TERMINAL_DIFFICULTY), - TERMINAL_BLOCK, - ExecutionBlockHash::zero(), - None, - None, - None, - None, - None, - None, - ); - - for i in 0..=TERMINAL_BLOCK { - if i > 0 { - generator.insert_pow_block(i).unwrap(); - } - - /* - * Generate a block, inspect it. - */ - - let block = generator.latest_block().unwrap(); - assert_eq!(block.block_number(), i); - - let expected_parent = i - .checked_sub(1) - .map(|i| generator.block_by_number(i).unwrap().block_hash()) - .unwrap_or_else(ExecutionBlockHash::zero); - assert_eq!(block.parent_hash(), expected_parent); - - assert_eq!( - block.total_difficulty().unwrap(), - Uint256::from(i * DIFFICULTY_INCREMENT) - ); - - assert_eq!(generator.block_by_hash(block.block_hash()).unwrap(), block); - assert_eq!(generator.block_by_number(i).unwrap(), block); - - /* - * Check the parent is accessible. - */ - - if let Some(prev_i) = i.checked_sub(1) { - assert_eq!( - generator.block_by_number(prev_i).unwrap(), - generator.block_by_hash(block.parent_hash()).unwrap() - ); - } - - /* - * Check the next block is inaccessible. - */ - - let next_i = i + 1; - assert!(generator.block_by_number(next_i).is_none()); - } - } - #[test] fn valid_test_blobs_bundle_v1() { assert!( diff --git a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs index c69edb8f39..91966ff65e 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs @@ -1,9 +1,4 @@ -use crate::{ - test_utils::{ - DEFAULT_JWT_SECRET, DEFAULT_TERMINAL_BLOCK, DEFAULT_TERMINAL_DIFFICULTY, MockServer, - }, - *, -}; +use crate::{test_utils::DEFAULT_JWT_SECRET, test_utils::MockServer, *}; use alloy_primitives::B256 as H256; use fixed_bytes::FixedBytesExtended; use kzg::Kzg; @@ -20,12 +15,10 @@ pub struct MockExecutionLayer { impl MockExecutionLayer { pub fn default_params(executor: TaskExecutor) -> Self { let mut spec = MainnetEthSpec::default_spec(); - spec.terminal_total_difficulty = Uint256::from(DEFAULT_TERMINAL_DIFFICULTY); spec.terminal_block_hash = ExecutionBlockHash::zero(); spec.terminal_block_hash_activation_epoch = Epoch::new(0); Self::new( executor, - DEFAULT_TERMINAL_BLOCK, None, None, None, @@ -40,7 +33,6 @@ impl MockExecutionLayer { #[allow(clippy::too_many_arguments)] pub fn new( executor: TaskExecutor, - terminal_block: u64, shanghai_time: Option, cancun_time: Option, prague_time: Option, @@ -56,9 +48,6 @@ impl MockExecutionLayer { let server = MockServer::new( &handle, jwt_key, - spec.terminal_total_difficulty, - terminal_block, - spec.terminal_block_hash, shanghai_time, cancun_time, prague_time, @@ -293,53 +282,4 @@ impl MockExecutionLayer { assert_eq!(head_execution_block.block_hash(), block_hash); assert_eq!(head_execution_block.parent_hash(), parent_hash); } - - pub fn move_to_block_prior_to_terminal_block(self) -> Self { - self.server - .execution_block_generator() - .move_to_block_prior_to_terminal_block() - .unwrap(); - self - } - - pub fn move_to_terminal_block(self) -> Self { - self.server - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - self - } - - pub fn produce_forked_pow_block(self) -> (Self, ExecutionBlockHash) { - let head_block = self - .server - .execution_block_generator() - .latest_block() - .unwrap(); - - let block_hash = self - .server - .execution_block_generator() - .insert_pow_block_by_hash(head_block.parent_hash(), 1) - .unwrap(); - (self, block_hash) - } - - pub async fn with_terminal_block(self, func: U) -> Self - where - U: Fn(Arc, ExecutionLayer, Option) -> V, - V: Future, - { - let terminal_block_number = self - .server - .execution_block_generator() - .terminal_block_number; - let terminal_block = self - .server - .execution_block_generator() - .execution_block_by_number(terminal_block_number); - - func(self.spec.clone(), self.el.clone(), terminal_block).await; - self - } } diff --git a/beacon_node/execution_layer/src/test_utils/mod.rs b/beacon_node/execution_layer/src/test_utils/mod.rs index 2465a41d8b..d8e1e70e49 100644 --- a/beacon_node/execution_layer/src/test_utils/mod.rs +++ b/beacon_node/execution_layer/src/test_utils/mod.rs @@ -35,8 +35,6 @@ pub use hook::Hook; pub use mock_builder::{MockBuilder, Operation, mock_builder_extra_data}; pub use mock_execution_layer::MockExecutionLayer; -pub const DEFAULT_TERMINAL_DIFFICULTY: u64 = 6400; -pub const DEFAULT_TERMINAL_BLOCK: u64 = 64; pub const DEFAULT_JWT_SECRET: [u8; 32] = [42; 32]; pub const DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI: u128 = 10_000_000_000_000_000; pub const DEFAULT_BUILDER_PAYLOAD_VALUE_WEI: u128 = 20_000_000_000_000_000; @@ -79,9 +77,6 @@ mod mock_execution_layer; pub struct MockExecutionConfig { pub server_config: Config, pub jwt_key: JwtKey, - pub terminal_difficulty: Uint256, - pub terminal_block: u64, - pub terminal_block_hash: ExecutionBlockHash, pub shanghai_time: Option, pub cancun_time: Option, pub prague_time: Option, @@ -93,9 +88,6 @@ impl Default for MockExecutionConfig { fn default() -> Self { Self { jwt_key: JwtKey::random(), - terminal_difficulty: Uint256::from(DEFAULT_TERMINAL_DIFFICULTY), - terminal_block: DEFAULT_TERMINAL_BLOCK, - terminal_block_hash: ExecutionBlockHash::zero(), server_config: Config::default(), shanghai_time: None, cancun_time: None, @@ -118,9 +110,6 @@ impl MockServer { Self::new( &runtime::Handle::current(), JwtKey::from_slice(&DEFAULT_JWT_SECRET).unwrap(), - Uint256::from(DEFAULT_TERMINAL_DIFFICULTY), - DEFAULT_TERMINAL_BLOCK, - ExecutionBlockHash::zero(), None, // FIXME(capella): should this be the default? None, // FIXME(deneb): should this be the default? None, // FIXME(electra): should this be the default? @@ -138,9 +127,6 @@ impl MockServer { create_test_tracing_subscriber(); let MockExecutionConfig { jwt_key, - terminal_difficulty, - terminal_block, - terminal_block_hash, server_config, shanghai_time, cancun_time, @@ -151,9 +137,6 @@ impl MockServer { let last_echo_request = Arc::new(RwLock::new(None)); let preloaded_responses = Arc::new(Mutex::new(vec![])); let execution_block_generator = ExecutionBlockGenerator::new( - terminal_difficulty, - terminal_block, - terminal_block_hash, shanghai_time, cancun_time, prague_time, @@ -215,9 +198,6 @@ impl MockServer { pub fn new( handle: &runtime::Handle, jwt_key: JwtKey, - terminal_difficulty: Uint256, - terminal_block: u64, - terminal_block_hash: ExecutionBlockHash, shanghai_time: Option, cancun_time: Option, prague_time: Option, @@ -230,9 +210,6 @@ impl MockServer { MockExecutionConfig { server_config: Config::default(), jwt_key, - terminal_difficulty, - terminal_block, - terminal_block_hash, shanghai_time, cancun_time, prague_time, diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 92a1ad934d..74710c4ed2 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -93,7 +93,7 @@ use tokio_stream::{ use tracing::{debug, info, warn}; use types::{ BeaconStateError, Checkpoint, ConfigAndPreset, Epoch, EthSpec, ForkName, Hash256, - SignedBlindedBeaconBlock, Slot, + SignedBlindedBeaconBlock, }; use validator::execution_payload_envelope::get_validator_execution_payload_envelope; use version::{ @@ -3126,25 +3126,6 @@ pub fn serve( }, ); - // GET lighthouse/merge_readiness - let get_lighthouse_merge_readiness = warp::path("lighthouse") - .and(warp::path("merge_readiness")) - .and(warp::path::end()) - .and(task_spawner_filter.clone()) - .and(chain_filter.clone()) - .then( - |task_spawner: TaskSpawner, chain: Arc>| { - task_spawner.spawn_async_with_rejection(Priority::P1, async move { - let current_slot = chain.slot_clock.now_or_genesis().unwrap_or(Slot::new(0)); - let merge_readiness = chain.check_bellatrix_readiness(current_slot).await; - Ok::<_, warp::reject::Rejection>( - warp::reply::json(&api_types::GenericResponse::from(merge_readiness)) - .into_response(), - ) - }) - }, - ); - let get_events = eth_v1 .clone() .and(warp::path("events")) @@ -3388,7 +3369,6 @@ pub fn serve( .uor(get_beacon_light_client_bootstrap) .uor(get_beacon_light_client_updates) .uor(get_lighthouse_block_packing_efficiency) - .uor(get_lighthouse_merge_readiness) .uor(get_events) .uor(get_expected_withdrawals) .uor(lighthouse_log_events.boxed()) diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index ef5c508595..a380f62ecf 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -85,14 +85,18 @@ pub async fn gossip_invalid() { /* mandated by Beacon API spec */ assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); + // The error depends on whether blobs exist (which affects validation order): + // - Pre-Deneb (no blobs): block validation runs first -> NotFinalizedDescendant + // - Deneb/Electra (blobs): blob validation runs first -> ParentUnknown + // - Fulu+ (columns): block validation runs first -> NotFinalizedDescendant let pre_finalized_block_root = Hash256::zero(); - let expected_error_msg = if tester.harness.spec.is_fulu_scheduled() { + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { format!( "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" ) } else { - // Since Deneb, the invalidity of the blobs will be detected prior to the invalidity of the - // block. format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") }; @@ -283,13 +287,13 @@ pub async fn consensus_invalid() { assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); let pre_finalized_block_root = Hash256::zero(); - let expected_error_msg = if tester.harness.spec.is_fulu_scheduled() { + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { format!( "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" ) } else { - // Since Deneb, the invalidity of the blobs will be detected prior to the invalidity of the - // block. format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") }; @@ -520,13 +524,13 @@ pub async fn equivocation_invalid() { assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); let pre_finalized_block_root = Hash256::zero(); - let expected_error_msg = if tester.harness.spec.is_fulu_scheduled() { + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { format!( "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" ) } else { - // Since Deneb, the invalidity of the blobs will be detected prior to the invalidity of the - // block. format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") }; @@ -845,16 +849,17 @@ pub async fn blinded_gossip_invalid() { assert!(response.is_err()); let error_response: eth2::Error = response.err().unwrap(); + /* mandated by Beacon API spec */ assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); let pre_finalized_block_root = Hash256::zero(); - let expected_error_msg = if tester.harness.spec.is_fulu_scheduled() { + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { format!( "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" ) } else { - // Since Deneb, the invalidity of the blobs will be detected prior to the invalidity of the - // block. format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") }; @@ -1070,10 +1075,16 @@ pub async fn blinded_consensus_invalid() { ); } else { assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); - assert_server_message_error( - error_response, - format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}"), - ); + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { + format!( + "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" + ) + } else { + format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") + }; + assert_server_message_error(error_response, expected_error_msg); } } @@ -1253,10 +1264,16 @@ pub async fn blinded_equivocation_invalid() { ); } else { assert_eq!(error_response.status(), Some(StatusCode::BAD_REQUEST)); - assert_server_message_error( - error_response, - format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}"), - ); + let expected_error_msg = if tester.harness.spec.deneb_fork_epoch.is_none() + || tester.harness.spec.is_fulu_scheduled() + { + format!( + "BAD_REQUEST: NotFinalizedDescendant {{ block_parent_root: {pre_finalized_block_root:?} }}" + ) + } else { + format!("BAD_REQUEST: ParentUnknown {{ parent_root: {pre_finalized_block_root:?} }}") + }; + assert_server_message_error(error_response, expected_error_msg); } } @@ -1957,6 +1974,13 @@ pub async fn duplicate_block_status_code() { let validator_count = 64; let num_initial: u64 = 31; let duplicate_block_status_code = StatusCode::IM_A_TEAPOT; + + // Check if deneb is enabled, which is required for blobs. + let spec = test_spec::(); + if !spec.fork_name_at_slot::(Slot::new(0)).deneb_enabled() { + return; + } + let tester = InteractiveTester::::new_with_initializer_and_mutator( None, validator_count, diff --git a/beacon_node/http_api/tests/fork_tests.rs b/beacon_node/http_api/tests/fork_tests.rs index b96c8bd112..4ba35c238c 100644 --- a/beacon_node/http_api/tests/fork_tests.rs +++ b/beacon_node/http_api/tests/fork_tests.rs @@ -404,7 +404,7 @@ async fn bls_to_execution_changes_update_all_around_capella_fork() { bls_withdrawal_credentials(&keypair.pk, spec) } - let header = generate_genesis_header(&spec, true); + let header = generate_genesis_header(&spec); let genesis_state = InteropGenesisBuilder::new() .set_opt_execution_payload_header(header) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 21458057c4..a18dd10464 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -450,13 +450,7 @@ pub async fn proposer_boost_re_org_test( let execution_ctx = mock_el.server.ctx.clone(); let slot_clock = &harness.chain.slot_clock; - // Move to terminal block. mock_el.server.all_payloads_valid(); - execution_ctx - .execution_block_generator - .write() - .move_to_terminal_block() - .unwrap(); // Send proposer preparation data for all validators. let proposer_preparation_data = all_validators diff --git a/beacon_node/http_api/tests/status_tests.rs b/beacon_node/http_api/tests/status_tests.rs index 6bca9e51f6..791e643ec4 100644 --- a/beacon_node/http_api/tests/status_tests.rs +++ b/beacon_node/http_api/tests/status_tests.rs @@ -21,15 +21,8 @@ async fn post_merge_tester(chain_depth: u64, validator_count: u64) -> Interactiv let tester = InteractiveTester::::new(Some(spec), validator_count as usize).await; let harness = &tester.harness; let mock_el = harness.mock_execution_layer.as_ref().unwrap(); - let execution_ctx = mock_el.server.ctx.clone(); - // Move to terminal block. mock_el.server.all_payloads_valid(); - execution_ctx - .execution_block_generator - .write() - .move_to_terminal_block() - .unwrap(); // Create some chain depth. harness.advance_slot(); diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 7e3eb8b980..6696e109a5 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -147,15 +147,6 @@ impl ApiTester { .node_custody_type(config.node_custody_type) .build(); - harness - .mock_execution_layer - .as_ref() - .unwrap() - .server - .execution_block_generator() - .move_to_terminal_block() - .unwrap(); - harness.advance_slot(); for _ in 0..CHAIN_LENGTH { diff --git a/beacon_node/operation_pool/src/lib.rs b/beacon_node/operation_pool/src/lib.rs index 81423d6abd..b3bd091691 100644 --- a/beacon_node/operation_pool/src/lib.rs +++ b/beacon_node/operation_pool/src/lib.rs @@ -1851,8 +1851,11 @@ mod release_tests { let mut spec = E::default_spec(); // Give some room to sign surround slashings. - spec.altair_fork_epoch = Some(Epoch::new(3)); - spec.bellatrix_fork_epoch = Some(Epoch::new(6)); + spec.altair_fork_epoch = Some(Epoch::new(0)); + spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + spec.capella_fork_epoch = Some(Epoch::new(0)); + spec.deneb_fork_epoch = Some(Epoch::new(2)); + spec.electra_fork_epoch = Some(Epoch::new(4)); // To make exits immediately valid. spec.shard_committee_period = 0; @@ -1860,185 +1863,114 @@ mod release_tests { let num_validators = 32; let harness = get_harness::(num_validators, Some(spec.clone())); + if let Some(mock_el) = harness.mock_execution_layer.as_ref() { + mock_el.server.all_payloads_valid(); + } (harness, spec) } - /// Test several cross-fork voluntary exits: - /// - /// - phase0 exit (not valid after Bellatrix) - /// - phase0 exit signed with Altair fork version (only valid after Bellatrix) - #[tokio::test] - async fn cross_fork_exits() { - let (harness, spec) = cross_fork_harness::(); - let altair_fork_epoch = spec.altair_fork_epoch.unwrap(); - let bellatrix_fork_epoch = spec.bellatrix_fork_epoch.unwrap(); - let slots_per_epoch = MainnetEthSpec::slots_per_epoch(); - - let op_pool = OperationPool::::new(); - - // Sign an exit in phase0 with a phase0 epoch. - let exit1 = harness.make_voluntary_exit(0, Epoch::new(0)); - - // Advance to Altair. - harness - .extend_to_slot(altair_fork_epoch.start_slot(slots_per_epoch)) - .await; - let altair_head = harness.chain.canonical_head.cached_head().snapshot; - assert_eq!(altair_head.beacon_state.current_epoch(), altair_fork_epoch); - - // Add exit 1 to the op pool during Altair. It's still valid at this point and should be - // returned. - let verified_exit1 = exit1 - .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) - .unwrap(); - op_pool.insert_voluntary_exit(verified_exit1); - let exits = - op_pool.get_voluntary_exits(&altair_head.beacon_state, |_| true, &harness.chain.spec); - assert!(exits.contains(&exit1)); - assert_eq!(exits.len(), 1); - - // Advance to Bellatrix. - harness - .extend_to_slot(bellatrix_fork_epoch.start_slot(slots_per_epoch)) - .await; - let bellatrix_head = harness.chain.canonical_head.cached_head().snapshot; - assert_eq!( - bellatrix_head.beacon_state.current_epoch(), - bellatrix_fork_epoch - ); - - // Sign an exit with the Altair domain and a phase0 epoch. This is a weird type of exit - // that is valid because after the Bellatrix fork we'll use the Altair fork domain to verify - // all prior epochs. - let unsigned_exit = VoluntaryExit { - epoch: Epoch::new(0), - validator_index: 2, - }; - let exit2 = SignedVoluntaryExit { - message: unsigned_exit.clone(), - signature: harness.validator_keypairs[2] - .sk - .sign(unsigned_exit.signing_root(spec.compute_domain( - Domain::VoluntaryExit, - harness.spec.altair_fork_version, - harness.chain.genesis_validators_root, - ))), - }; - - let verified_exit2 = exit2 - .clone() - .validate(&bellatrix_head.beacon_state, &harness.chain.spec) - .unwrap(); - op_pool.insert_voluntary_exit(verified_exit2); - - // Attempting to fetch exit1 now should fail, despite it still being in the pool. - // exit2 should still be valid, because it was signed with the Altair fork domain. - assert_eq!(op_pool.voluntary_exits.read().len(), 2); - let exits = - op_pool.get_voluntary_exits(&bellatrix_head.beacon_state, |_| true, &harness.spec); - assert_eq!(&exits, &[exit2]); - } + // Voluntary exits signed post-Capella are perpetually valid across forks, so no + // cross-fork test is required here. /// Test several cross-fork proposer slashings: /// - /// - phase0 slashing (not valid after Bellatrix) - /// - Bellatrix signed with Altair fork version (not valid after Bellatrix) - /// - phase0 exit signed with Altair fork version (only valid after Bellatrix) + /// - Capella slashing (not valid after Electra) + /// - Electra signed with Deneb fork version (not valid after Electra) + /// - Capella exit signed with Deneb fork version (only valid after Electra) #[tokio::test] async fn cross_fork_proposer_slashings() { let (harness, spec) = cross_fork_harness::(); let slots_per_epoch = MainnetEthSpec::slots_per_epoch(); - let altair_fork_epoch = spec.altair_fork_epoch.unwrap(); - let bellatrix_fork_epoch = spec.bellatrix_fork_epoch.unwrap(); - let bellatrix_fork_slot = bellatrix_fork_epoch.start_slot(slots_per_epoch); + let deneb_fork_epoch = spec.deneb_fork_epoch.unwrap(); + let electra_fork_epoch = spec.electra_fork_epoch.unwrap(); + let electra_fork_slot = electra_fork_epoch.start_slot(slots_per_epoch); let op_pool = OperationPool::::new(); - // Sign a proposer slashing in phase0 with a phase0 epoch. + // Sign a proposer slashing in Capella with a Capella slot. let slashing1 = harness.make_proposer_slashing_at_slot(0, Some(Slot::new(1))); - // Advance to Altair. + // Advance to Deneb. harness - .extend_to_slot(altair_fork_epoch.start_slot(slots_per_epoch)) + .extend_to_slot(deneb_fork_epoch.start_slot(slots_per_epoch)) .await; - let altair_head = harness.chain.canonical_head.cached_head().snapshot; - assert_eq!(altair_head.beacon_state.current_epoch(), altair_fork_epoch); + let deneb_head = harness.chain.canonical_head.cached_head().snapshot; + assert_eq!(deneb_head.beacon_state.current_epoch(), deneb_fork_epoch); - // Add slashing1 to the op pool during Altair. It's still valid at this point and should be + // Add slashing1 to the op pool during Deneb. It's still valid at this point and should be // returned. let verified_slashing1 = slashing1 .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) + .validate(&deneb_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_proposer_slashing(verified_slashing1); let (proposer_slashings, _, _) = - op_pool.get_slashings_and_exits(&altair_head.beacon_state, &harness.chain.spec); + op_pool.get_slashings_and_exits(&deneb_head.beacon_state, &harness.chain.spec); assert!(proposer_slashings.contains(&slashing1)); assert_eq!(proposer_slashings.len(), 1); - // Sign a proposer slashing with a Bellatrix slot using the Altair fork domain. + // Sign a proposer slashing with a Electra slot using the Deneb fork domain. // - // This slashing is valid only before the Bellatrix fork epoch. - let slashing2 = harness.make_proposer_slashing_at_slot(1, Some(bellatrix_fork_slot)); + // This slashing is valid only before the Electra fork epoch. + let slashing2 = harness.make_proposer_slashing_at_slot(1, Some(electra_fork_slot)); let verified_slashing2 = slashing2 .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) + .validate(&deneb_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_proposer_slashing(verified_slashing2); let (proposer_slashings, _, _) = - op_pool.get_slashings_and_exits(&altair_head.beacon_state, &harness.chain.spec); + op_pool.get_slashings_and_exits(&deneb_head.beacon_state, &harness.chain.spec); assert!(proposer_slashings.contains(&slashing1)); assert!(proposer_slashings.contains(&slashing2)); assert_eq!(proposer_slashings.len(), 2); - // Advance to Bellatrix. - harness.extend_to_slot(bellatrix_fork_slot).await; - let bellatrix_head = harness.chain.canonical_head.cached_head().snapshot; + // Advance to Electra. + harness.extend_to_slot(electra_fork_slot).await; + let electra_head = harness.chain.canonical_head.cached_head().snapshot; assert_eq!( - bellatrix_head.beacon_state.current_epoch(), - bellatrix_fork_epoch + electra_head.beacon_state.current_epoch(), + electra_fork_epoch ); - // Sign a proposer slashing with the Altair domain and a phase0 slot. This is a weird type - // of slashing that is only valid after the Bellatrix fork because we'll use the Altair fork + // Sign a proposer slashing with the Deneb domain and a Capella slot. This is a weird type + // of slashing that is only valid after the Electra fork because we'll use the Deneb fork // domain to verify all prior epochs. let slashing3 = harness.make_proposer_slashing_at_slot(2, Some(Slot::new(1))); let verified_slashing3 = slashing3 .clone() - .validate(&bellatrix_head.beacon_state, &harness.chain.spec) + .validate(&electra_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_proposer_slashing(verified_slashing3); // Attempting to fetch slashing1 now should fail, despite it still being in the pool. // Likewise slashing2 is also invalid now because it should be signed with the - // Bellatrix fork version. - // slashing3 should still be valid, because it was signed with the Altair fork domain. + // Electra fork version. + // slashing3 should still be valid, because it was signed with the Deneb fork domain. assert_eq!(op_pool.proposer_slashings.read().len(), 3); let (proposer_slashings, _, _) = - op_pool.get_slashings_and_exits(&bellatrix_head.beacon_state, &harness.spec); + op_pool.get_slashings_and_exits(&electra_head.beacon_state, &harness.spec); assert!(proposer_slashings.contains(&slashing3)); assert_eq!(proposer_slashings.len(), 1); } /// Test several cross-fork attester slashings: /// - /// - both target epochs in phase0 (not valid after Bellatrix) - /// - both target epochs in Bellatrix but signed with Altair domain (not valid after Bellatrix) - /// - Altair attestation that surrounds a phase0 attestation (not valid after Bellatrix) - /// - both target epochs in phase0 but signed with Altair domain (only valid after Bellatrix) + /// - both target epochs in Capella (not valid after Electra) + /// - both target epochs in Electra but signed with Deneb domain (not valid after Electra) + /// - Deneb attestation that surrounds a Capella attestation (not valid after Electra) + /// - both target epochs in Capella but signed with Deneb domain (only valid after Electra) #[tokio::test] async fn cross_fork_attester_slashings() { let (harness, spec) = cross_fork_harness::(); let slots_per_epoch = MainnetEthSpec::slots_per_epoch(); let zero_epoch = Epoch::new(0); - let altair_fork_epoch = spec.altair_fork_epoch.unwrap(); - let bellatrix_fork_epoch = spec.bellatrix_fork_epoch.unwrap(); - let bellatrix_fork_slot = bellatrix_fork_epoch.start_slot(slots_per_epoch); + let deneb_fork_epoch = spec.deneb_fork_epoch.unwrap(); + let electra_fork_epoch = spec.electra_fork_epoch.unwrap(); + let electra_fork_slot = electra_fork_epoch.start_slot(slots_per_epoch); let op_pool = OperationPool::::new(); - // Sign an attester slashing with the phase0 fork version, with both target epochs in phase0. + // Sign an attester slashing with the Capella fork version, with both target epochs in Capella. let slashing1 = harness.make_attester_slashing_with_epochs( vec![0], None, @@ -2047,55 +1979,55 @@ mod release_tests { Some(zero_epoch), ); - // Advance to Altair. + // Advance to Deneb. harness - .extend_to_slot(altair_fork_epoch.start_slot(slots_per_epoch)) + .extend_to_slot(deneb_fork_epoch.start_slot(slots_per_epoch)) .await; - let altair_head = harness.chain.canonical_head.cached_head().snapshot; - assert_eq!(altair_head.beacon_state.current_epoch(), altair_fork_epoch); + let deneb_head = harness.chain.canonical_head.cached_head().snapshot; + assert_eq!(deneb_head.beacon_state.current_epoch(), deneb_fork_epoch); - // Add slashing1 to the op pool during Altair. It's still valid at this point and should be + // Add slashing1 to the op pool during Deneb. It's still valid at this point and should be // returned. let verified_slashing1 = slashing1 .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) + .validate(&deneb_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_attester_slashing(verified_slashing1); - // Sign an attester slashing with two Bellatrix epochs using the Altair fork domain. + // Sign an attester slashing with two Electra epochs using the Deneb fork domain. // - // This slashing is valid only before the Bellatrix fork epoch. + // This slashing is valid only before the Electra fork epoch. let slashing2 = harness.make_attester_slashing_with_epochs( vec![1], None, - Some(bellatrix_fork_epoch), + Some(electra_fork_epoch), None, - Some(bellatrix_fork_epoch), + Some(electra_fork_epoch), ); let verified_slashing2 = slashing2 .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) + .validate(&deneb_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_attester_slashing(verified_slashing2); let (_, attester_slashings, _) = - op_pool.get_slashings_and_exits(&altair_head.beacon_state, &harness.chain.spec); + op_pool.get_slashings_and_exits(&deneb_head.beacon_state, &harness.chain.spec); assert!(attester_slashings.contains(&slashing1)); assert!(attester_slashings.contains(&slashing2)); assert_eq!(attester_slashings.len(), 2); - // Sign an attester slashing where an Altair attestation surrounds a phase0 one. + // Sign an attester slashing where a Deneb attestation surrounds a Capella one. // - // This slashing is valid only before the Bellatrix fork epoch. + // This slashing is valid only before the Electra fork epoch. let slashing3 = harness.make_attester_slashing_with_epochs( vec![2], Some(Epoch::new(0)), - Some(altair_fork_epoch), + Some(deneb_fork_epoch), Some(Epoch::new(1)), - Some(altair_fork_epoch - 1), + Some(deneb_fork_epoch - 1), ); let verified_slashing3 = slashing3 .clone() - .validate(&altair_head.beacon_state, &harness.chain.spec) + .validate(&deneb_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_attester_slashing(verified_slashing3); @@ -2104,44 +2036,43 @@ mod release_tests { // slashed. let mut to_be_slashed = hashset! {0}; let attester_slashings = - op_pool.get_attester_slashings(&altair_head.beacon_state, &mut to_be_slashed); + op_pool.get_attester_slashings(&deneb_head.beacon_state, &mut to_be_slashed); assert!(attester_slashings.contains(&slashing2)); assert!(attester_slashings.contains(&slashing3)); assert_eq!(attester_slashings.len(), 2); - // Advance to Bellatrix. - harness.extend_to_slot(bellatrix_fork_slot).await; - let bellatrix_head = harness.chain.canonical_head.cached_head().snapshot; + // Advance to Electra + harness.extend_to_slot(electra_fork_slot).await; + let electra_head = harness.chain.canonical_head.cached_head().snapshot; assert_eq!( - bellatrix_head.beacon_state.current_epoch(), - bellatrix_fork_epoch + electra_head.beacon_state.current_epoch(), + electra_fork_epoch ); - // Sign an attester slashing with the Altair domain and phase0 epochs. This is a weird type - // of slashing that is only valid after the Bellatrix fork because we'll use the Altair fork - // domain to verify all prior epochs. + // Sign an attester slashing with the Deneb domain and Capella epochs. This is only valid + // after the Electra fork. let slashing4 = harness.make_attester_slashing_with_epochs( vec![3], Some(Epoch::new(0)), - Some(altair_fork_epoch - 1), + Some(deneb_fork_epoch - 1), Some(Epoch::new(0)), - Some(altair_fork_epoch - 1), + Some(deneb_fork_epoch - 1), ); let verified_slashing4 = slashing4 .clone() - .validate(&bellatrix_head.beacon_state, &harness.chain.spec) + .validate(&electra_head.beacon_state, &harness.chain.spec) .unwrap(); op_pool.insert_attester_slashing(verified_slashing4); // All slashings except slashing4 are now invalid (despite being present in the pool). assert_eq!(op_pool.attester_slashings.read().len(), 4); let (_, attester_slashings, _) = - op_pool.get_slashings_and_exits(&bellatrix_head.beacon_state, &harness.spec); + op_pool.get_slashings_and_exits(&electra_head.beacon_state, &harness.spec); assert!(attester_slashings.contains(&slashing4)); assert_eq!(attester_slashings.len(), 1); // Pruning the attester slashings should remove all but slashing4. - op_pool.prune_attester_slashings(&bellatrix_head.beacon_state); + op_pool.prune_attester_slashings(&electra_head.beacon_state); assert_eq!(op_pool.attester_slashings.read().len(), 1); } } diff --git a/consensus/state_processing/src/per_block_processing/tests.rs b/consensus/state_processing/src/per_block_processing/tests.rs index 739717b33f..96610c2010 100644 --- a/consensus/state_processing/src/per_block_processing/tests.rs +++ b/consensus/state_processing/src/per_block_processing/tests.rs @@ -8,7 +8,7 @@ use crate::per_block_processing::errors::{ use crate::{BlockReplayError, BlockReplayer, per_block_processing}; use crate::{ BlockSignatureStrategy, ConsensusContext, VerifyBlockRoot, VerifySignatures, - per_block_processing::{process_operations, verify_exit::verify_exit}, + per_block_processing::process_operations, }; use beacon_chain::test_utils::{BeaconChainHarness, EphemeralHarnessType}; use bls::{AggregateSignature, Keypair, PublicKeyBytes, Signature, SignatureBytes}; @@ -39,10 +39,13 @@ async fn get_harness( // Set the state and block to be in the last slot of the `epoch_offset`th epoch. let last_slot_of_epoch = (MainnetEthSpec::genesis_epoch() + epoch_offset).end_slot(E::slots_per_epoch()); + // Use Electra spec to ensure blocks are created at the same fork as the state + let spec = Arc::new(ForkName::Electra.make_genesis_spec(E::default_spec())); let harness = BeaconChainHarness::>::builder(E::default()) - .default_spec() + .spec(spec.clone()) .keypairs(KEYPAIRS[0..num_validators].to_vec()) .fresh_ephemeral_store() + .mock_execution_layer() .build(); let state = harness.get_current_state(); if last_slot_of_epoch > Slot::new(0) { @@ -63,8 +66,8 @@ async fn get_harness( #[tokio::test] async fn valid_block_ok() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let state = harness.get_current_state(); let slot = state.slot(); @@ -87,8 +90,8 @@ async fn valid_block_ok() { #[tokio::test] async fn invalid_block_header_state_slot() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let state = harness.get_current_state(); let slot = state.slot() + Slot::new(1); @@ -107,18 +110,18 @@ async fn invalid_block_header_state_slot() { &spec, ); - assert_eq!( + assert!(matches!( result, Err(BlockProcessingError::HeaderInvalid { - reason: HeaderInvalid::StateSlotMismatch + reason: HeaderInvalid::StateSlotMismatch, }) - ); + )); } #[tokio::test] async fn invalid_parent_block_root() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let state = harness.get_current_state(); let slot = state.slot(); @@ -139,21 +142,18 @@ async fn invalid_parent_block_root() { &spec, ); - assert_eq!( + assert!(matches!( result, Err(BlockProcessingError::HeaderInvalid { - reason: HeaderInvalid::ParentBlockRootMismatch { - state: state.latest_block_header().canonical_root(), - block: Hash256::from([0xAA; 32]) - } + reason: HeaderInvalid::ParentBlockRootMismatch { .. }, }) - ); + )); } #[tokio::test] async fn invalid_block_signature() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let state = harness.get_current_state(); let slot = state.slot(); @@ -172,19 +172,18 @@ async fn invalid_block_signature() { &spec, ); - // should get a BadSignature error - assert_eq!( + assert!(matches!( result, Err(BlockProcessingError::HeaderInvalid { - reason: HeaderInvalid::ProposalSignatureInvalid + reason: HeaderInvalid::ProposalSignatureInvalid, }) - ); + )); } #[tokio::test] async fn invalid_randao_reveal_signature() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let state = harness.get_current_state(); let slot = state.slot(); @@ -211,8 +210,8 @@ async fn invalid_randao_reveal_signature() { #[tokio::test] async fn valid_4_deposits() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = harness.make_deposits(&mut state, 4, None, None); @@ -235,8 +234,8 @@ async fn valid_4_deposits() { #[tokio::test] async fn invalid_deposit_deposit_count_too_big() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = harness.make_deposits(&mut state, 1, None, None); @@ -267,8 +266,8 @@ async fn invalid_deposit_deposit_count_too_big() { #[tokio::test] async fn invalid_deposit_count_too_small() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = harness.make_deposits(&mut state, 1, None, None); @@ -299,8 +298,8 @@ async fn invalid_deposit_count_too_small() { #[tokio::test] async fn invalid_deposit_bad_merkle_proof() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = harness.make_deposits(&mut state, 1, None, None); @@ -333,8 +332,8 @@ async fn invalid_deposit_bad_merkle_proof() { #[tokio::test] async fn invalid_deposit_wrong_sig() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = @@ -357,8 +356,8 @@ async fn invalid_deposit_wrong_sig() { #[tokio::test] async fn invalid_deposit_invalid_pub_key() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let (deposits, state) = @@ -382,8 +381,8 @@ async fn invalid_deposit_invalid_pub_key() { #[tokio::test] async fn invalid_attestation_no_committee_for_index() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -422,8 +421,8 @@ async fn invalid_attestation_no_committee_for_index() { #[tokio::test] async fn invalid_attestation_wrong_justified_checkpoint() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -477,8 +476,8 @@ async fn invalid_attestation_wrong_justified_checkpoint() { #[tokio::test] async fn invalid_attestation_bad_aggregation_bitfield_len() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -488,13 +487,14 @@ async fn invalid_attestation_bad_aggregation_bitfield_len() { .clone() .deconstruct() .0; + // Use Electra method since harness runs at Electra fork *head_block .to_mut() .body_mut() .attestations_mut() .next() .unwrap() - .aggregation_bits_base_mut() + .aggregation_bits_electra_mut() .unwrap() = Bitfield::with_capacity(spec.target_committee_size).unwrap(); let mut ctxt = ConsensusContext::new(state.slot()); @@ -506,19 +506,20 @@ async fn invalid_attestation_bad_aggregation_bitfield_len() { &spec, ); - // Expecting InvalidBitfield because the size of the aggregation_bitfield is bigger than the committee size. + // In Electra, setting wrong aggregation_bits capacity causes EmptyCommittee error + // (validation order changed - committee check happens before bitfield check) assert_eq!( result, Err(BlockProcessingError::BeaconStateError( - BeaconStateError::InvalidBitfield + BeaconStateError::EmptyCommittee )) ); } #[tokio::test] async fn invalid_attestation_bad_signature() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, 97).await; // minimal number of required validators for this test + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -558,8 +559,8 @@ async fn invalid_attestation_bad_signature() { #[tokio::test] async fn invalid_attestation_included_too_early() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -603,56 +604,15 @@ async fn invalid_attestation_included_too_early() { ); } -#[tokio::test] -async fn invalid_attestation_included_too_late() { - let spec = MainnetEthSpec::default_spec(); - // note to maintainer: might need to increase validator count if we get NoCommittee - let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; - - let mut state = harness.get_current_state(); - let mut head_block = harness - .chain - .head_beacon_block() - .as_ref() - .clone() - .deconstruct() - .0; - let new_attesation_slot = head_block.body().attestations().next().unwrap().data().slot - - Slot::new(MainnetEthSpec::slots_per_epoch()); - head_block - .to_mut() - .body_mut() - .attestations_mut() - .next() - .unwrap() - .data_mut() - .slot = new_attesation_slot; - - let mut ctxt = ConsensusContext::new(state.slot()); - let result = process_operations::process_attestations( - &mut state, - head_block.body(), - VerifySignatures::True, - &mut ctxt, - &spec, - ); - assert_eq!( - result, - Err(BlockProcessingError::AttestationInvalid { - index: 0, - reason: AttestationInvalid::IncludedTooLate { - state: state.slot(), - attestation: new_attesation_slot, - } - }) - ); -} +// Note: `invalid_attestation_included_too_late` test removed. +// The `IncludedTooLate` check was removed in Deneb (EIP7045), so this test is no longer +// applicable when running with Electra spec (which the harness uses by default). #[tokio::test] async fn invalid_attestation_target_epoch_slot_mismatch() { - let spec = MainnetEthSpec::default_spec(); // note to maintainer: might need to increase validator count if we get NoCommittee let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut state = harness.get_current_state(); let mut head_block = harness @@ -694,8 +654,8 @@ async fn invalid_attestation_target_epoch_slot_mismatch() { #[tokio::test] async fn valid_insert_attester_slashing() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let attester_slashing = harness.make_attester_slashing(vec![1, 2]); @@ -715,8 +675,8 @@ async fn valid_insert_attester_slashing() { #[tokio::test] async fn invalid_attester_slashing_not_slashable() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut attester_slashing = harness.make_attester_slashing(vec![1, 2]); match &mut attester_slashing { @@ -750,8 +710,8 @@ async fn invalid_attester_slashing_not_slashable() { #[tokio::test] async fn invalid_attester_slashing_1_invalid() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut attester_slashing = harness.make_attester_slashing(vec![1, 2]); match &mut attester_slashing { @@ -790,8 +750,8 @@ async fn invalid_attester_slashing_1_invalid() { #[tokio::test] async fn invalid_attester_slashing_2_invalid() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut attester_slashing = harness.make_attester_slashing(vec![1, 2]); match &mut attester_slashing { @@ -830,8 +790,8 @@ async fn invalid_attester_slashing_2_invalid() { #[tokio::test] async fn valid_insert_proposer_slashing() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let proposer_slashing = harness.make_proposer_slashing(1); let mut state = harness.get_current_state(); let mut ctxt = ConsensusContext::new(state.slot()); @@ -848,8 +808,8 @@ async fn valid_insert_proposer_slashing() { #[tokio::test] async fn invalid_proposer_slashing_proposals_identical() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut proposer_slashing = harness.make_proposer_slashing(1); proposer_slashing.signed_header_1.message = proposer_slashing.signed_header_2.message.clone(); @@ -876,8 +836,8 @@ async fn invalid_proposer_slashing_proposals_identical() { #[tokio::test] async fn invalid_proposer_slashing_proposer_unknown() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut proposer_slashing = harness.make_proposer_slashing(1); proposer_slashing.signed_header_1.message.proposer_index = 3_141_592; @@ -905,8 +865,8 @@ async fn invalid_proposer_slashing_proposer_unknown() { #[tokio::test] async fn invalid_proposer_slashing_duplicate_slashing() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let proposer_slashing = harness.make_proposer_slashing(1); let mut state = harness.get_current_state(); @@ -939,8 +899,8 @@ async fn invalid_proposer_slashing_duplicate_slashing() { #[tokio::test] async fn invalid_bad_proposal_1_signature() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut proposer_slashing = harness.make_proposer_slashing(1); proposer_slashing.signed_header_1.signature = Signature::empty(); let mut state = harness.get_current_state(); @@ -965,8 +925,8 @@ async fn invalid_bad_proposal_1_signature() { #[tokio::test] async fn invalid_bad_proposal_2_signature() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut proposer_slashing = harness.make_proposer_slashing(1); proposer_slashing.signed_header_2.signature = Signature::empty(); let mut state = harness.get_current_state(); @@ -991,8 +951,8 @@ async fn invalid_bad_proposal_2_signature() { #[tokio::test] async fn invalid_proposer_slashing_proposal_epoch_mismatch() { - let spec = MainnetEthSpec::default_spec(); let harness = get_harness::(EPOCH_OFFSET, VALIDATOR_COUNT).await; + let spec = harness.spec.clone(); let mut proposer_slashing = harness.make_proposer_slashing(1); proposer_slashing.signed_header_1.message.slot = Slot::new(0); proposer_slashing.signed_header_2.message.slot = Slot::new(128); @@ -1019,92 +979,6 @@ async fn invalid_proposer_slashing_proposal_epoch_mismatch() { ); } -#[tokio::test] -async fn fork_spanning_exit() { - let mut spec = MainnetEthSpec::default_spec(); - let slots_per_epoch = MainnetEthSpec::slots_per_epoch(); - - spec.altair_fork_epoch = Some(Epoch::new(2)); - spec.bellatrix_fork_epoch = Some(Epoch::new(4)); - spec.shard_committee_period = 0; - let spec = Arc::new(spec); - - let harness = BeaconChainHarness::builder(MainnetEthSpec) - .spec(spec.clone()) - .deterministic_keypairs(VALIDATOR_COUNT) - .mock_execution_layer() - .fresh_ephemeral_store() - .build(); - - harness.extend_to_slot(slots_per_epoch.into()).await; - - /* - * Produce an exit *before* Altair. - */ - - let signed_exit = harness.make_voluntary_exit(0, Epoch::new(1)); - assert!(signed_exit.message.epoch < spec.altair_fork_epoch.unwrap()); - - /* - * Ensure the exit verifies before Altair. - */ - - let head = harness.chain.canonical_head.cached_head(); - let head_state = &head.snapshot.beacon_state; - assert!(head_state.current_epoch() < spec.altair_fork_epoch.unwrap()); - verify_exit( - head_state, - None, - &signed_exit, - VerifySignatures::True, - &spec, - ) - .expect("phase0 exit verifies against phase0 state"); - - /* - * Ensure the exit verifies after Altair. - */ - - harness - .extend_to_slot(spec.altair_fork_epoch.unwrap().start_slot(slots_per_epoch)) - .await; - let head = harness.chain.canonical_head.cached_head(); - let head_state = &head.snapshot.beacon_state; - assert!(head_state.current_epoch() >= spec.altair_fork_epoch.unwrap()); - assert!(head_state.current_epoch() < spec.bellatrix_fork_epoch.unwrap()); - verify_exit( - head_state, - None, - &signed_exit, - VerifySignatures::True, - &spec, - ) - .expect("phase0 exit verifies against altair state"); - - /* - * Ensure the exit no longer verifies after Bellatrix. - */ - - harness - .extend_to_slot( - spec.bellatrix_fork_epoch - .unwrap() - .start_slot(slots_per_epoch), - ) - .await; - let head = harness.chain.canonical_head.cached_head(); - let head_state = &head.snapshot.beacon_state; - assert!(head_state.current_epoch() >= spec.bellatrix_fork_epoch.unwrap()); - verify_exit( - head_state, - None, - &signed_exit, - VerifySignatures::True, - &spec, - ) - .expect_err("phase0 exit does not verify against bellatrix state"); -} - /// Check that the block replayer does not consume state roots unnecessarily. #[tokio::test] async fn block_replayer_peeking_state_roots() { diff --git a/consensus/state_processing/src/per_epoch_processing/tests.rs b/consensus/state_processing/src/per_epoch_processing/tests.rs index f042e8766c..c04b7f843d 100644 --- a/consensus/state_processing/src/per_epoch_processing/tests.rs +++ b/consensus/state_processing/src/per_epoch_processing/tests.rs @@ -11,10 +11,10 @@ async fn runs_without_error() { .default_spec() .deterministic_keypairs(8) .fresh_ephemeral_store() + .mock_execution_layer() .build(); harness.advance_slot(); - let spec = MinimalEthSpec::default_spec(); let target_slot = (MinimalEthSpec::genesis_epoch() + 4).end_slot(MinimalEthSpec::slots_per_epoch()); @@ -32,7 +32,7 @@ async fn runs_without_error() { .await; let mut new_head_state = harness.get_current_state(); - process_epoch(&mut new_head_state, &spec).unwrap(); + process_epoch(&mut new_head_state, &harness.spec).unwrap(); } #[cfg(not(debug_assertions))] diff --git a/consensus/types/tests/committee_cache.rs b/consensus/types/tests/committee_cache.rs index 0bb8aa1da2..5c1962276f 100644 --- a/consensus/types/tests/committee_cache.rs +++ b/consensus/types/tests/committee_cache.rs @@ -21,6 +21,7 @@ fn get_harness(validator_count: usize) -> BeaconChainHarness( .default_spec() .keypairs(KEYPAIRS[0..validator_count].to_vec()) .fresh_ephemeral_store() + .mock_execution_layer() .build(); let skip_to_slot = slot - SLOT_OFFSET; diff --git a/lcli/src/mock_el.rs b/lcli/src/mock_el.rs index 544010b6a2..6086067a47 100644 --- a/lcli/src/mock_el.rs +++ b/lcli/src/mock_el.rs @@ -3,13 +3,10 @@ use clap_utils::{parse_optional, parse_required}; use environment::Environment; use execution_layer::{ auth::{JwtKey, strip_prefix}, - test_utils::{ - Config, DEFAULT_JWT_SECRET, DEFAULT_TERMINAL_BLOCK, MockExecutionConfig, MockServer, - }, + test_utils::{Config, DEFAULT_JWT_SECRET, MockExecutionConfig, MockServer}, }; use std::net::Ipv4Addr; use std::path::PathBuf; -use std::sync::Arc; use types::*; pub fn run(mut env: Environment, matches: &ArgMatches) -> Result<(), String> { @@ -25,7 +22,6 @@ pub fn run(mut env: Environment, matches: &ArgMatches) -> Result< let amsterdam_time = parse_optional(matches, "amsterdam-time")?; let handle = env.core_context().executor.handle().unwrap(); - let spec = Arc::new(E::default_spec()); let jwt_key = if let Some(secret_path) = jwt_secret_path { let hex_str = std::fs::read_to_string(&secret_path) @@ -50,9 +46,6 @@ pub fn run(mut env: Environment, matches: &ArgMatches) -> Result< listen_port, }, jwt_key, - terminal_difficulty: spec.terminal_total_difficulty, - terminal_block: DEFAULT_TERMINAL_BLOCK, - terminal_block_hash: spec.terminal_block_hash, shanghai_time: Some(shanghai_time), cancun_time, prague_time, diff --git a/testing/execution_engine_integration/src/test_rig.rs b/testing/execution_engine_integration/src/test_rig.rs index 5c3061166e..6bf4a1aa52 100644 --- a/testing/execution_engine_integration/src/test_rig.rs +++ b/testing/execution_engine_integration/src/test_rig.rs @@ -9,8 +9,8 @@ use alloy_signer_local::PrivateKeySigner; use bls::PublicKeyBytes; use execution_layer::test_utils::DEFAULT_GAS_LIMIT; use execution_layer::{ - BlockProposalContentsType, BuilderParams, ChainHealth, ExecutionLayer, PayloadAttributes, - PayloadParameters, PayloadStatus, + BlockByNumberQuery, BlockProposalContentsType, BuilderParams, ChainHealth, ExecutionLayer, + LATEST_TAG, PayloadAttributes, PayloadParameters, PayloadStatus, }; use fixed_bytes::FixedBytesExtended; use fork_choice::ForkchoiceUpdateParameters; @@ -210,25 +210,29 @@ impl TestRig { let account2 = AlloyAddress::from_slice(&hex::decode(ACCOUNT2).unwrap()); /* - * Read the terminal block hash from both pairs, check it's equal. + * Read the genesis block hash from both pairs, check it's equal. + * Since TTD=0, the genesis block is the terminal PoW block. */ - let terminal_pow_block_hash = self + let genesis_block = self .ee_a .execution_layer - .get_terminal_pow_block_hash(&self.spec, timestamp_now()) + .get_block_by_number(BlockByNumberQuery::Tag(LATEST_TAG)) .await .unwrap() - .unwrap(); + .expect("should have genesis block"); + + let terminal_pow_block_hash = genesis_block.block_hash; assert_eq!( terminal_pow_block_hash, self.ee_b .execution_layer - .get_terminal_pow_block_hash(&self.spec, timestamp_now()) + .get_block_by_number(BlockByNumberQuery::Tag(LATEST_TAG)) .await .unwrap() - .unwrap() + .expect("should have genesis block") + .block_hash ); // Submit transactions before getting payload diff --git a/testing/state_transition_vectors/src/exit.rs b/testing/state_transition_vectors/src/exit.rs index f8ece0218f..3b0fe7d8ec 100644 --- a/testing/state_transition_vectors/src/exit.rs +++ b/testing/state_transition_vectors/src/exit.rs @@ -1,4 +1,5 @@ use super::*; +use beacon_chain::test_utils::test_spec; use state_processing::{ BlockProcessingError, BlockSignatureStrategy, ConsensusContext, VerifyBlockRoot, per_block_processing, per_block_processing::errors::ExitInvalid, @@ -70,13 +71,13 @@ impl ExitTest { BlockSignatureStrategy::VerifyIndividual, VerifyBlockRoot::True, &mut ctxt, - &E::default_spec(), + &test_spec::(), ) } #[cfg(all(test, not(debug_assertions)))] async fn run(self) -> BeaconState { - let spec = &E::default_spec(); + let spec = &test_spec::(); let expected = self.expected.clone(); assert_eq!(STATE_EPOCH, spec.shard_committee_period); diff --git a/testing/state_transition_vectors/src/main.rs b/testing/state_transition_vectors/src/main.rs index 80c30489b7..6a212f034d 100644 --- a/testing/state_transition_vectors/src/main.rs +++ b/testing/state_transition_vectors/src/main.rs @@ -57,6 +57,7 @@ async fn get_harness( .default_spec() .keypairs(KEYPAIRS[0..validator_count].to_vec()) .fresh_ephemeral_store() + .mock_execution_layer() .build(); let skip_to_slot = slot - SLOT_OFFSET; if skip_to_slot > Slot::new(0) { diff --git a/validator_manager/src/exit_validators.rs b/validator_manager/src/exit_validators.rs index 8ddcc7e419..00bcb36e80 100644 --- a/validator_manager/src/exit_validators.rs +++ b/validator_manager/src/exit_validators.rs @@ -322,7 +322,7 @@ mod test { let mut spec = ChainSpec::mainnet(); spec.shard_committee_period = 1; spec.altair_fork_epoch = Some(Epoch::new(0)); - spec.bellatrix_fork_epoch = Some(Epoch::new(1)); + spec.bellatrix_fork_epoch = Some(Epoch::new(0)); spec.capella_fork_epoch = Some(Epoch::new(2)); spec.deneb_fork_epoch = Some(Epoch::new(3)); @@ -330,15 +330,8 @@ mod test { let harness = &beacon_node.harness; let mock_el = harness.mock_execution_layer.as_ref().unwrap(); - let execution_ctx = mock_el.server.ctx.clone(); - // Move to terminal block. mock_el.server.all_payloads_valid(); - execution_ctx - .execution_block_generator - .write() - .move_to_terminal_block() - .unwrap(); Self { exit_config: None, From a1ef265c9e612f15060f23f43a4832032e4589dd Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 25 Feb 2026 23:17:49 +1100 Subject: [PATCH 041/189] Add getBlobsV1 and getBlobsV2 support to mock EL server (#8870) Co-Authored-By: Jimmy Chen --- .../test_utils/execution_block_generator.rs | 38 ++++++++++++++++++- .../src/test_utils/handle_rpc.rs | 29 ++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index 1743b340ab..62a46246da 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -1,7 +1,8 @@ use crate::engine_api::{ ExecutionBlock, PayloadAttributes, PayloadId, PayloadStatusV1, PayloadStatusV1Status, json_structures::{ - JsonForkchoiceUpdatedV1Response, JsonPayloadStatusV1, JsonPayloadStatusV1Status, + BlobAndProof, BlobAndProofV1, BlobAndProofV2, JsonForkchoiceUpdatedV1Response, + JsonPayloadStatusV1, JsonPayloadStatusV1Status, }, }; use crate::engines::ForkchoiceState; @@ -15,6 +16,7 @@ use rand::{Rng, SeedableRng, rngs::StdRng}; use serde::{Deserialize, Serialize}; use ssz::Decode; use ssz_types::VariableList; +use state_processing::per_block_processing::deneb::kzg_commitment_to_versioned_hash; use std::cmp::max; use std::collections::HashMap; use std::sync::Arc; @@ -456,6 +458,40 @@ impl ExecutionBlockGenerator { self.blobs_bundles.get(id).cloned() } + /// Look up a blob and proof by versioned hash across all stored bundles. + pub fn get_blob_and_proof(&self, versioned_hash: &Hash256) -> Option> { + self.blobs_bundles + .iter() + .find_map(|(payload_id, blobs_bundle)| { + let (blob_idx, _) = + blobs_bundle + .commitments + .iter() + .enumerate() + .find(|(_, commitment)| { + &kzg_commitment_to_versioned_hash(commitment) == versioned_hash + })?; + let is_fulu = self.payload_ids.get(payload_id)?.fork_name().fulu_enabled(); + let blob = blobs_bundle.blobs.get(blob_idx)?.clone(); + if is_fulu { + let start = blob_idx * E::cells_per_ext_blob(); + let end = start + E::cells_per_ext_blob(); + let proofs = blobs_bundle + .proofs + .get(start..end)? + .to_vec() + .try_into() + .ok()?; + Some(BlobAndProof::V2(BlobAndProofV2 { blob, proofs })) + } else { + Some(BlobAndProof::V1(BlobAndProofV1 { + blob, + proof: *blobs_bundle.proofs.get(blob_idx)?, + })) + } + }) + } + pub fn new_payload(&mut self, payload: ExecutionPayload) -> PayloadStatusV1 { let Some(parent) = self.blocks.get(&payload.parent_hash()) else { return PayloadStatusV1 { diff --git a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs index 53eb3b5166..7a81017b3f 100644 --- a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs +++ b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs @@ -468,6 +468,35 @@ pub async fn handle_rpc( _ => unreachable!(), } } + ENGINE_GET_BLOBS_V1 => { + let versioned_hashes = + get_param::>(params, 0).map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?; + let generator = ctx.execution_block_generator.read(); + // V1: per-element nullable array, positionally matching the request. + let response: Vec>> = versioned_hashes + .iter() + .map(|hash| match generator.get_blob_and_proof(hash) { + Some(BlobAndProof::V1(v1)) => Some(v1), + _ => None, + }) + .collect(); + Ok(serde_json::to_value(response).unwrap()) + } + ENGINE_GET_BLOBS_V2 => { + let versioned_hashes = + get_param::>(params, 0).map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?; + let generator = ctx.execution_block_generator.read(); + // V2: all-or-nothing — null if any blob is missing. + let results: Vec>> = versioned_hashes + .iter() + .map(|hash| match generator.get_blob_and_proof(hash) { + Some(BlobAndProof::V2(v2)) => Some(v2), + _ => None, + }) + .collect(); + let response: Option>> = results.into_iter().collect(); + Ok(serde_json::to_value(response).unwrap()) + } ENGINE_FORKCHOICE_UPDATED_V1 | ENGINE_FORKCHOICE_UPDATED_V2 | ENGINE_FORKCHOICE_UPDATED_V3 => { From 2f43d234d88a9c43518f032d5e4b34fe1c889068 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Fri, 27 Feb 2026 08:05:57 +1100 Subject: [PATCH 042/189] Add CI job to check for deleted files (#8727) Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul --- .github/CODEOWNERS | 1 + .github/forbidden-files.txt | 7 +++++++ .github/workflows/test-suite.yml | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 .github/forbidden-files.txt diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cdec442276..e9ec8740a0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,4 @@ /beacon_node/network/ @jxs /beacon_node/lighthouse_network/ @jxs /beacon_node/store/ @michaelsproul +/.github/forbidden-files.txt @michaelsproul diff --git a/.github/forbidden-files.txt b/.github/forbidden-files.txt new file mode 100644 index 0000000000..ec89bd2e4b --- /dev/null +++ b/.github/forbidden-files.txt @@ -0,0 +1,7 @@ +# Files that have been intentionally deleted and should not be re-added. +# This prevents accidentally reviving files during botched merges. +# Add one file path per line (relative to repo root). + +beacon_node/beacon_chain/src/otb_verification_service.rs +beacon_node/store/src/partial_beacon_state.rs +beacon_node/store/src/consensus_context.rs diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 72ea9d41ae..d9efbfc148 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -72,6 +72,27 @@ jobs: steps: - name: Check that the pull request is not targeting the stable branch run: test ${{ github.base_ref }} != "stable" + + forbidden-files-check: + name: forbidden-files-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check for forbidden files + run: | + if [ -f .github/forbidden-files.txt ]; then + status=0 + while IFS= read -r file || [ -n "$file" ]; do + # Skip comments and empty lines + [[ "$file" =~ ^#.*$ || -z "$file" ]] && continue + if [ -f "$file" ]; then + echo "::error::Forbidden file exists: $file" + status=1 + fi + done < .github/forbidden-files.txt + exit $status + fi + release-tests-ubuntu: name: release-tests-ubuntu needs: [check-labels] @@ -430,6 +451,7 @@ jobs: needs: [ 'check-labels', 'target-branch-check', + 'forbidden-files-check', 'release-tests-ubuntu', 'beacon-chain-tests', 'op-pool-tests', From 8cf6ffac4b1954bfc61939e116afd5e2ab349dbb Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 25 Feb 2026 23:10:41 +1100 Subject: [PATCH 043/189] Update yanked keccak 0.1.5 to 0.1.6 (#8900) Co-Authored-By: Jimmy Chen --- Cargo.lock | 7 +++---- Cargo.toml | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d75f5c197..dd1637045b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4832,9 +4832,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ "cpufeatures", ] @@ -10607,8 +10607,7 @@ dependencies = [ [[package]] name = "yamux" version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deab71f2e20691b4728b349c6cee8fc7223880fa67b6b4f92225ec32225447e5" +source = "git+https://github.com/sigp/rust-yamux?rev=575b17c0f44f4253079a6bafaa2de74ca1d6dfaa#575b17c0f44f4253079a6bafaa2de74ca1d6dfaa" dependencies = [ "futures", "log", diff --git a/Cargo.toml b/Cargo.toml index aac26e060b..61caacf5df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -303,3 +303,4 @@ debug = true [patch.crates-io] quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "681f413312404ab6e51f0b46f39b0075c6f4ebfd" } +yamux = { git = "https://github.com/sigp/rust-yamux", rev = "575b17c0f44f4253079a6bafaa2de74ca1d6dfaa" } From 95f12d0927831971ca9e1acf7ca7e87fdda56f4a Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Fri, 27 Feb 2026 16:48:56 +1100 Subject: [PATCH 044/189] Bump version to v8.1.1 (#8853) --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd1637045b..40c550f4c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "account_manager" -version = "8.1.0" +version = "8.1.1" dependencies = [ "account_utils", "bls", @@ -1276,7 +1276,7 @@ dependencies = [ [[package]] name = "beacon_node" -version = "8.1.0" +version = "8.1.1" dependencies = [ "account_utils", "beacon_chain", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "boot_node" -version = "8.1.0" +version = "8.1.1" dependencies = [ "beacon_node", "bytes", @@ -4897,7 +4897,7 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "lcli" -version = "8.1.0" +version = "8.1.1" dependencies = [ "account_utils", "beacon_chain", @@ -5383,7 +5383,7 @@ dependencies = [ [[package]] name = "lighthouse" -version = "8.1.0" +version = "8.1.1" dependencies = [ "account_manager", "account_utils", @@ -5515,7 +5515,7 @@ dependencies = [ [[package]] name = "lighthouse_version" -version = "8.1.0" +version = "8.1.1" dependencies = [ "regex", ] @@ -9622,7 +9622,7 @@ dependencies = [ [[package]] name = "validator_client" -version = "8.1.0" +version = "8.1.1" dependencies = [ "account_utils", "beacon_node_fallback", diff --git a/Cargo.toml b/Cargo.toml index 61caacf5df..5f6f43d2f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ resolver = "2" [workspace.package] edition = "2024" -version = "8.1.0" +version = "8.1.1" [workspace.dependencies] account_utils = { path = "common/account_utils" } From 6194dddc5b9ea176fc38796fb5de6c7fac8a8143 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 2 Mar 2026 17:43:51 +1100 Subject: [PATCH 045/189] Persist custody context more readily (#8921) We received a bug report of a node restarting custody backfill unnecessarily after upgrading to Lighthouse v8.1.1. What happened is: - User started LH v8.0.1 many months ago, CGC updated 0 -> N but the CGC was not eagerly persisted. - LH experienced an unclean shutdown (not sure of what type). - Upon restarting (still running v8.0.1), the custody context read from disk contains CGC=0: `DEBUG Loaded persisted custody context custody_context: CustodyContext { validator_custody_count: 0, ...`). - CGC updates again to N, retriggering custody backfill: `DEBUG Validator count at head updated old_count: 0, new_count: N`. - Custody backfill does a bunch of downloading for no gain: `DEBUG Imported historical data columns epoch: Epoch(428433), total_imported: 0` - While custody backfill is running user updated to v8.1.1, and we see logs for the CGC=N being peristed upon clean shutdown, and then correctly read on startup with v8.1.1. - Custody backfill keeps running and downloading due to the CGC change still being considered in progress. - Call `persist_custody_context` inside the `register_validators` handler so that it is written to disk eagerly whenever it changes. The performance impact of this should be minimal as the amount of data is very small and this call can only happen at most ~128 times (once for each change) in the entire life of a beacon node. - Call `persist_custody_context` inside `BeaconChainBuilder::build` so that changes caused by CLI flags are persisted (otherwise starting a node with `--semi-supernode` and no validators, then shutting it down uncleanly would cause use to forget the CGC). These changes greatly reduce the timespan during which an unclean shutdown can create inconsistency. In the worst case, we only lose backfill progress that runs concurrently with the `register_validators` handler (should be extremely minimal, nigh impossible). Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/beacon_chain.rs | 13 ++++++++++++- beacon_node/beacon_chain/src/builder.rs | 5 +++++ beacon_node/http_api/src/validator/mod.rs | 12 ++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 9d204ac7f2..703ed24420 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -662,7 +662,18 @@ impl BeaconChain { .custody_context() .as_ref() .into(); - debug!(?custody_context, "Persisting custody context to store"); + + // Pattern match to avoid accidentally missing fields and to ignore deprecated fields. + let CustodyContextSsz { + validator_custody_at_head, + epoch_validator_custody_requirements, + persisted_is_supernode: _, + } = &custody_context; + debug!( + validator_custody_at_head, + ?epoch_validator_custody_requirements, + "Persisting custody context to store" + ); persist_custody_context::( self.store.clone(), diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 2c1dae9215..66a54d46e8 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -1083,6 +1083,11 @@ where let cgc_change_effective_slot = cgc_changed.effective_epoch.start_slot(E::slots_per_epoch()); beacon_chain.update_data_column_custody_info(Some(cgc_change_effective_slot)); + + // Persist change to disk. + beacon_chain + .persist_custody_context() + .map_err(|e| format!("Failed writing updated CGC: {e:?}"))?; } info!( diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index df237d9f9b..a9082df715 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -727,6 +727,18 @@ pub fn post_validator_prepare_beacon_proposer( debug!(error = %e, "Could not send message to the network service. \ Likely shutdown") }); + + // Write the updated custody context to disk. This happens at most 128 + // times ever, so the I/O burden should be extremely minimal. Without a + // write here we risk forgetting custody backfill progress upon an + // unclean shutdown. The custody context is otherwise only persisted in + // `BeaconChain::drop`. + if let Err(error) = chain.persist_custody_context() { + error!( + ?error, + "Failed to persist custody context after CGC update" + ); + } } } From f4b5b033a227fcacdb8e8514bbca6cf6702f3a24 Mon Sep 17 00:00:00 2001 From: Mac L Date: Mon, 2 Mar 2026 09:19:41 +0200 Subject: [PATCH 046/189] Add `testing` feature to validator_client/http_api (#8909) Create a `testing` feature which we can use to gate off `test_utils.rs` and its associated dependencies from the rest of the crate. Co-Authored-By: Mac L --- validator_client/http_api/Cargo.toml | 12 +++++++++--- validator_client/http_api/src/lib.rs | 4 +++- validator_manager/Cargo.toml | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/validator_client/http_api/Cargo.toml b/validator_client/http_api/Cargo.toml index 2bd57867ac..e334ab9db0 100644 --- a/validator_client/http_api/Cargo.toml +++ b/validator_client/http_api/Cargo.toml @@ -8,14 +8,17 @@ authors = ["Sigma Prime "] name = "validator_http_api" path = "src/lib.rs" +[features] +testing = ["dep:deposit_contract", "dep:doppelganger_service", "dep:tempfile"] + [dependencies] account_utils = { workspace = true } beacon_node_fallback = { workspace = true } bls = { workspace = true } -deposit_contract = { workspace = true } +deposit_contract = { workspace = true, optional = true } directory = { workspace = true } dirs = { workspace = true } -doppelganger_service = { workspace = true } +doppelganger_service = { workspace = true, optional = true } eth2 = { workspace = true, features = ["lighthouse"] } eth2_keystore = { workspace = true } ethereum_serde_utils = { workspace = true } @@ -38,7 +41,7 @@ slot_clock = { workspace = true } sysinfo = { workspace = true } system_health = { workspace = true } task_executor = { workspace = true } -tempfile = { workspace = true } +tempfile = { workspace = true, optional = true } tokio = { workspace = true } tokio-stream = { workspace = true } tracing = { workspace = true } @@ -53,7 +56,10 @@ warp_utils = { workspace = true } zeroize = { workspace = true } [dev-dependencies] +deposit_contract = { workspace = true } +doppelganger_service = { workspace = true } futures = { workspace = true } itertools = { workspace = true } rand = { workspace = true, features = ["small_rng"] } ssz_types = { workspace = true } +tempfile = { workspace = true } diff --git a/validator_client/http_api/src/lib.rs b/validator_client/http_api/src/lib.rs index a35b4ec6c6..8e9c077e57 100644 --- a/validator_client/http_api/src/lib.rs +++ b/validator_client/http_api/src/lib.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "testing")] +pub mod test_utils; + mod api_secret; mod create_signed_voluntary_exit; mod create_validator; @@ -6,7 +9,6 @@ mod keystores; mod remotekeys; mod tests; -pub mod test_utils; pub use api_secret::PK_FILENAME; use graffiti::{delete_graffiti, get_graffiti, set_graffiti}; diff --git a/validator_manager/Cargo.toml b/validator_manager/Cargo.toml index 16ce1e023f..d0155698b4 100644 --- a/validator_manager/Cargo.toml +++ b/validator_manager/Cargo.toml @@ -29,4 +29,4 @@ beacon_chain = { workspace = true } http_api = { workspace = true } regex = { workspace = true } tempfile = { workspace = true } -validator_http_api = { workspace = true } +validator_http_api = { workspace = true, features = ["testing"] } From 9dccfb540f3cd9c5a0f0f9ac18a007f39b3a3b89 Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:48:30 +0800 Subject: [PATCH 047/189] update cargo-sort (#8933) Co-Authored-By: Tan Chee Keong --- Cargo.toml | 29 +++-------------------------- common/logging/Cargo.toml | 2 +- common/malloc_utils/Cargo.toml | 5 +---- 3 files changed, 5 insertions(+), 31 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 667ba1f803..222392bcb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,20 +166,7 @@ initialized_validators = { path = "validator_client/initialized_validators" } int_to_bytes = { path = "consensus/int_to_bytes" } itertools = "0.14" kzg = { path = "crypto/kzg" } -libp2p = { git = "https://github.com/libp2p/rust-libp2p.git", default-features = false, features = [ - "identify", - "yamux", - "noise", - "dns", - "tcp", - "tokio", - "secp256k1", - "macros", - "metrics", - "quic", - "upnp", - "gossipsub", -] } +libp2p = { git = "https://github.com/libp2p/rust-libp2p.git", default-features = false, features = ["identify", "yamux", "noise", "dns", "tcp", "tokio", "secp256k1", "macros", "metrics", "quic", "upnp", "gossipsub"] } libsecp256k1 = "0.7" lighthouse_network = { path = "beacon_node/lighthouse_network" } lighthouse_validator_store = { path = "validator_client/lighthouse_validator_store" } @@ -219,12 +206,7 @@ r2d2 = "0.8" rand = "0.9.0" rayon = "1.7" regex = "1" -reqwest = { version = "0.12", default-features = false, features = [ - "blocking", - "json", - "stream", - "rustls-tls", -] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "stream", "rustls-tls"] } ring = "0.17" rpds = "0.11" rusqlite = { version = "0.38", features = ["bundled"] } @@ -253,12 +235,7 @@ sysinfo = "0.26" system_health = { path = "common/system_health" } task_executor = { path = "common/task_executor" } tempfile = "3" -tokio = { version = "1", features = [ - "rt-multi-thread", - "sync", - "signal", - "macros", -] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "signal", "macros"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", features = ["codec", "compat", "time"] } tracing = "0.1.40" diff --git a/common/logging/Cargo.toml b/common/logging/Cargo.toml index 41c82dbd61..cbebd1a501 100644 --- a/common/logging/Cargo.toml +++ b/common/logging/Cargo.toml @@ -13,7 +13,7 @@ logroller = { workspace = true } metrics = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tokio = { workspace = true, features = [ "time" ] } +tokio = { workspace = true, features = ["time"] } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-core = { workspace = true } diff --git a/common/malloc_utils/Cargo.toml b/common/malloc_utils/Cargo.toml index 1052128852..e90490bf09 100644 --- a/common/malloc_utils/Cargo.toml +++ b/common/malloc_utils/Cargo.toml @@ -35,7 +35,4 @@ tikv-jemallocator = { version = "0.6.0", optional = true, features = ["stats"] } # Jemalloc's background_threads feature requires Linux (pthreads). [target.'cfg(target_os = "linux")'.dependencies] -tikv-jemallocator = { version = "0.6.0", optional = true, features = [ - "stats", - "background_threads", -] } +tikv-jemallocator = { version = "0.6.0", optional = true, features = ["stats", "background_threads"] } From 9c4715c251ea19b2cc4c7688916b5cddfa2b1778 Mon Sep 17 00:00:00 2001 From: Mac L Date: Fri, 6 Mar 2026 09:54:43 +0200 Subject: [PATCH 048/189] Fix lints for Rust v1.94.0 (#8939) Following the release of Rust v1.94.0 there are new Clippy lints which do not pass and are blocking CI (which pulls in the latest version of Rust) This is pretty much the minimum just to get CI running again. Most of the errors involve error types being too large. For now I've added allows but later it might be worth doing a refactor to `Box` or otherwise remove the problematic error types. Co-Authored-By: Mac L --- beacon_node/beacon_chain/tests/attestation_verification.rs | 1 + beacon_node/beacon_chain/tests/payload_invalidation.rs | 1 + beacon_node/beacon_chain/tests/store_tests.rs | 1 + beacon_node/execution_layer/src/lib.rs | 2 +- beacon_node/http_api/src/lib.rs | 1 + slasher/service/src/lib.rs | 1 + 6 files changed, 6 insertions(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index e8ee628f28..acf326430b 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -1,4 +1,5 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::attestation_verification::{ Error, batch_verify_aggregated_attestations, batch_verify_unaggregated_attestations, diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index b282adecd5..bcc50990ec 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1,4 +1,5 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::block_verification_types::RpcBlock; use beacon_chain::{ diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index cfc53c8ce0..b6d729cc61 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -1,4 +1,5 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::attestation_verification::Error as AttnError; use beacon_chain::block_verification_types::RpcBlock; diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index d6796f6a05..90968fa213 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -2048,7 +2048,7 @@ fn verify_builder_bid( .cloned() .map(|withdrawals| { Withdrawals::::try_from(withdrawals) - .map_err(InvalidBuilderPayload::SszTypesError) + .map_err(|e| Box::new(InvalidBuilderPayload::SszTypesError(e))) .map(|w| w.tree_hash_root()) }) .transpose()?; diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 74710c4ed2..e9dfa2876a 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::result_large_err)] //! This crate contains a HTTP server which serves the endpoints listed here: //! //! https://github.com/ethereum/beacon-APIs diff --git a/slasher/service/src/lib.rs b/slasher/service/src/lib.rs index ac15b49ee9..69ec59aa2c 100644 --- a/slasher/service/src/lib.rs +++ b/slasher/service/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::result_large_err)] mod service; pub use service::SlasherService; From dbfb6fd9231f5a7c74667e5adbdaddacf4f1b768 Mon Sep 17 00:00:00 2001 From: Mac L Date: Sat, 7 Mar 2026 01:09:31 +0200 Subject: [PATCH 049/189] Remove `arbitrary-fuzz` (#8936) We have duplicated features which enable `arbitrary` throughout the codebase. These are `arbitrary` and `arbitrary-fuzz`. I think historically these were supposed to be distinct however in practice these function identically and so we can unify them into a single feature to avoid confusion. Co-Authored-By: Mac L --- Makefile | 4 ++-- common/eip_3076/Cargo.toml | 2 +- common/eip_3076/src/lib.rs | 10 +++++----- consensus/state_processing/Cargo.toml | 4 ++-- consensus/state_processing/src/envelope_processing.rs | 2 +- consensus/state_processing/src/per_block_processing.rs | 8 ++++---- .../per_epoch_processing/base/validator_statuses.rs | 10 +++++----- consensus/state_processing/src/verify_operation.rs | 8 ++++---- consensus/types/Cargo.toml | 1 - validator_client/slashing_protection/Cargo.toml | 2 +- .../slashing_protection/src/interchange_test.rs | 8 ++++---- 11 files changed, 29 insertions(+), 30 deletions(-) diff --git a/Makefile b/Makefile index 9786c17cc9..ad1bbbb8e8 100644 --- a/Makefile +++ b/Makefile @@ -321,8 +321,8 @@ make-ef-tests-nightly: # Verifies that crates compile with fuzzing features enabled arbitrary-fuzz: - cargo check -p state_processing --features arbitrary-fuzz,$(TEST_FEATURES) - cargo check -p slashing_protection --features arbitrary-fuzz,$(TEST_FEATURES) + cargo check -p state_processing --features arbitrary,$(TEST_FEATURES) + cargo check -p slashing_protection --features arbitrary,$(TEST_FEATURES) # Runs cargo audit (Audit Cargo.lock files for crates with security vulnerabilities reported to the RustSec Advisory Database) audit: install-audit audit-CI diff --git a/common/eip_3076/Cargo.toml b/common/eip_3076/Cargo.toml index 058e1fd1a0..157fe12cb3 100644 --- a/common/eip_3076/Cargo.toml +++ b/common/eip_3076/Cargo.toml @@ -6,7 +6,7 @@ edition = { workspace = true } [features] default = [] -arbitrary-fuzz = ["dep:arbitrary", "types/arbitrary"] +arbitrary = ["dep:arbitrary", "types/arbitrary"] json = ["dep:serde_json"] [dependencies] diff --git a/common/eip_3076/src/lib.rs b/common/eip_3076/src/lib.rs index cdd05d7b1e..0bf1a94d0e 100644 --- a/common/eip_3076/src/lib.rs +++ b/common/eip_3076/src/lib.rs @@ -13,7 +13,7 @@ pub enum Error { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct InterchangeMetadata { #[serde(with = "serde_utils::quoted_u64::require_quotes")] pub interchange_format_version: u64, @@ -22,7 +22,7 @@ pub struct InterchangeMetadata { #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct InterchangeData { pub pubkey: PublicKeyBytes, pub signed_blocks: Vec, @@ -31,7 +31,7 @@ pub struct InterchangeData { #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SignedBlock { #[serde(with = "serde_utils::quoted_u64::require_quotes")] pub slot: Slot, @@ -41,7 +41,7 @@ pub struct SignedBlock { #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SignedAttestation { #[serde(with = "serde_utils::quoted_u64::require_quotes")] pub source_epoch: Epoch, @@ -52,7 +52,7 @@ pub struct SignedAttestation { } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Interchange { pub metadata: InterchangeMetadata, pub data: Vec, diff --git a/consensus/state_processing/Cargo.toml b/consensus/state_processing/Cargo.toml index 7426995439..ae0af03231 100644 --- a/consensus/state_processing/Cargo.toml +++ b/consensus/state_processing/Cargo.toml @@ -7,10 +7,10 @@ edition = { workspace = true } [features] default = [] fake_crypto = ["bls/fake_crypto"] -arbitrary-fuzz = [ +arbitrary = [ "dep:arbitrary", "smallvec/arbitrary", - "types/arbitrary-fuzz", + "types/arbitrary", "merkle_proof/arbitrary", "ethereum_ssz/arbitrary", "ssz_types/arbitrary", diff --git a/consensus/state_processing/src/envelope_processing.rs b/consensus/state_processing/src/envelope_processing.rs index c2cfeae5d3..be6b7c1b29 100644 --- a/consensus/state_processing/src/envelope_processing.rs +++ b/consensus/state_processing/src/envelope_processing.rs @@ -21,7 +21,7 @@ macro_rules! envelope_verify { } /// The strategy to be used when validating the payloads state root. -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(PartialEq, Clone, Copy)] pub enum VerifyStateRoot { /// Validate state root. diff --git a/consensus/state_processing/src/per_block_processing.rs b/consensus/state_processing/src/per_block_processing.rs index 037e1c7cc7..5aa610e98e 100644 --- a/consensus/state_processing/src/per_block_processing.rs +++ b/consensus/state_processing/src/per_block_processing.rs @@ -55,12 +55,12 @@ use crate::common::update_progressive_balances_cache::{ initialize_progressive_balances_cache, update_progressive_balances_metrics, }; use crate::epoch_cache::initialize_epoch_cache; -#[cfg(feature = "arbitrary-fuzz")] +#[cfg(feature = "arbitrary")] use arbitrary::Arbitrary; use tracing::instrument; /// The strategy to be used when validating the block's signatures. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(PartialEq, Clone, Copy, Debug)] pub enum BlockSignatureStrategy { /// Do not validate any signature. Use with caution. @@ -74,7 +74,7 @@ pub enum BlockSignatureStrategy { } /// The strategy to be used when validating the block's signatures. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(PartialEq, Clone, Copy)] pub enum VerifySignatures { /// Validate all signatures encountered. @@ -90,7 +90,7 @@ impl VerifySignatures { } /// Control verification of the latest block header. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(PartialEq, Clone, Copy)] pub enum VerifyBlockRoot { True, diff --git a/consensus/state_processing/src/per_epoch_processing/base/validator_statuses.rs b/consensus/state_processing/src/per_epoch_processing/base/validator_statuses.rs index c5ec80b92a..3e4f7e8189 100644 --- a/consensus/state_processing/src/per_epoch_processing/base/validator_statuses.rs +++ b/consensus/state_processing/src/per_epoch_processing/base/validator_statuses.rs @@ -2,7 +2,7 @@ use crate::common::attesting_indices_base::get_attesting_indices; use safe_arith::SafeArith; use types::{BeaconState, BeaconStateError, ChainSpec, Epoch, EthSpec, PendingAttestation}; -#[cfg(feature = "arbitrary-fuzz")] +#[cfg(feature = "arbitrary")] use arbitrary::Arbitrary; /// Sets the boolean `var` on `self` to be true if it is true on `other`. Otherwise leaves `self` @@ -16,7 +16,7 @@ macro_rules! set_self_if_other_is_true { } /// The information required to reward a block producer for including an attestation in a block. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(Debug, Clone, Copy, PartialEq)] pub struct InclusionInfo { /// The distance between the attestation slot and the slot that attestation was included in a @@ -48,7 +48,7 @@ impl InclusionInfo { } /// Information required to reward some validator during the current and previous epoch. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(Debug, Default, Clone, PartialEq)] pub struct ValidatorStatus { /// True if the validator has been slashed, ever. @@ -118,7 +118,7 @@ impl ValidatorStatus { /// epochs. #[derive(Clone, Debug, PartialEq)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] pub struct TotalBalances { /// The effective balance increment from the spec. effective_balance_increment: u64, @@ -175,7 +175,7 @@ impl TotalBalances { /// Summarised information about validator participation in the _previous and _current_ epochs of /// some `BeaconState`. -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[derive(Debug, Clone)] pub struct ValidatorStatuses { /// Information about each individual validator from the state's validator registry. diff --git a/consensus/state_processing/src/verify_operation.rs b/consensus/state_processing/src/verify_operation.rs index a13786f9f6..1e9c3d5fe3 100644 --- a/consensus/state_processing/src/verify_operation.rs +++ b/consensus/state_processing/src/verify_operation.rs @@ -7,7 +7,7 @@ use crate::per_block_processing::{ verify_attester_slashing, verify_bls_to_execution_change, verify_exit, verify_proposer_slashing, }; -#[cfg(feature = "arbitrary-fuzz")] +#[cfg(feature = "arbitrary")] use arbitrary::Arbitrary; use educe::Educe; use smallvec::{SmallVec, smallvec}; @@ -41,14 +41,14 @@ pub trait TransformPersist { /// The inner `op` field is private, meaning instances of this type can only be constructed /// by calling `validate`. #[derive(Educe, Debug, Clone)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] #[educe( PartialEq, Eq, Hash(bound(T: TransformPersist + std::hash::Hash, E: EthSpec)) )] #[cfg_attr( - feature = "arbitrary-fuzz", + feature = "arbitrary", arbitrary(bound = "T: TransformPersist + Arbitrary<'arbitrary>, E: EthSpec") )] pub struct SigVerifiedOp { @@ -139,7 +139,7 @@ struct SigVerifiedOpDecode { /// We need to store multiple `ForkVersion`s because attester slashings contain two indexed /// attestations which may be signed using different versions. #[derive(Debug, PartialEq, Eq, Clone, Hash, Encode, Decode, TestRandom)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(Arbitrary))] pub struct VerifiedAgainst { fork_versions: SmallVec<[ForkVersion; MAX_FORKS_VERIFIED_AGAINST]>, } diff --git a/consensus/types/Cargo.toml b/consensus/types/Cargo.toml index e7e382714b..c5ced83320 100644 --- a/consensus/types/Cargo.toml +++ b/consensus/types/Cargo.toml @@ -22,7 +22,6 @@ arbitrary = [ "ssz_types/arbitrary", "swap_or_not_shuffle/arbitrary", ] -arbitrary-fuzz = ["arbitrary"] portable = ["bls/supranational-portable"] [dependencies] diff --git a/validator_client/slashing_protection/Cargo.toml b/validator_client/slashing_protection/Cargo.toml index 695a693385..8017941ca6 100644 --- a/validator_client/slashing_protection/Cargo.toml +++ b/validator_client/slashing_protection/Cargo.toml @@ -6,7 +6,7 @@ edition = { workspace = true } autotests = false [features] -arbitrary-fuzz = ["dep:arbitrary", "types/arbitrary-fuzz", "eip_3076/arbitrary-fuzz"] +arbitrary = ["dep:arbitrary", "types/arbitrary", "eip_3076/arbitrary"] portable = ["types/portable"] [dependencies] diff --git a/validator_client/slashing_protection/src/interchange_test.rs b/validator_client/slashing_protection/src/interchange_test.rs index c5c3df7ea4..996116dd1c 100644 --- a/validator_client/slashing_protection/src/interchange_test.rs +++ b/validator_client/slashing_protection/src/interchange_test.rs @@ -11,7 +11,7 @@ use tempfile::tempdir; use types::{Epoch, Hash256, Slot}; #[derive(Debug, Clone, Deserialize, Serialize)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MultiTestCase { pub name: String, pub genesis_validators_root: Hash256, @@ -19,7 +19,7 @@ pub struct MultiTestCase { } #[derive(Debug, Clone, Deserialize, Serialize)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct TestCase { pub should_succeed: bool, pub contains_slashable_data: bool, @@ -29,7 +29,7 @@ pub struct TestCase { } #[derive(Debug, Clone, Deserialize, Serialize)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct TestBlock { pub pubkey: PublicKeyBytes, pub slot: Slot, @@ -39,7 +39,7 @@ pub struct TestBlock { } #[derive(Debug, Clone, Deserialize, Serialize)] -#[cfg_attr(feature = "arbitrary-fuzz", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct TestAttestation { pub pubkey: PublicKeyBytes, pub source_epoch: Epoch, From efe43f769967a971fa3006ec764e622109b04e6d Mon Sep 17 00:00:00 2001 From: Akihito Nakano Date: Sat, 7 Mar 2026 08:09:33 +0900 Subject: [PATCH 050/189] Fix cargo-sort errors (#8945) The `cargo-sort` job in CI is [failing](https://github.com/sigp/lighthouse/actions/runs/22781651620/job/66088700318?pr=8932) since [cargo-sort v2.1.1](https://github.com/DevinR528/cargo-sort/releases/tag/v2.1.1) has been released, which reports new errors for our Cargo.toml files. Ran `cargo-sort` formatter locally with the new version. Co-Authored-By: ackintosh --- account_manager/Cargo.toml | 5 +---- beacon_node/Cargo.toml | 13 +++++-------- beacon_node/beacon_chain/Cargo.toml | 9 ++++++--- common/logging/Cargo.toml | 3 ++- consensus/types/Cargo.toml | 5 +---- 5 files changed, 15 insertions(+), 20 deletions(-) diff --git a/account_manager/Cargo.toml b/account_manager/Cargo.toml index 8dd50cbc6e..05e6f12554 100644 --- a/account_manager/Cargo.toml +++ b/account_manager/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "account_manager" version = { workspace = true } -authors = [ - "Paul Hauner ", - "Luke Anderson ", -] +authors = ["Paul Hauner ", "Luke Anderson "] edition = { workspace = true } [dependencies] diff --git a/beacon_node/Cargo.toml b/beacon_node/Cargo.toml index 5352814dd5..ebefa6a451 100644 --- a/beacon_node/Cargo.toml +++ b/beacon_node/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "beacon_node" version = { workspace = true } -authors = [ - "Paul Hauner ", - "Age Manning ", "Age Manning "] edition = { workspace = true } [features] -test_logger = [] # Print log output to stderr when running tests instead of dropping it +# Print log output to stderr when running tests instead of dropping it. +test_logger = [] [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } diff --git a/consensus/types/Cargo.toml b/consensus/types/Cargo.toml index c5ced83320..c09e3d6931 100644 --- a/consensus/types/Cargo.toml +++ b/consensus/types/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "types" version = "0.2.1" -authors = [ - "Paul Hauner ", - "Age Manning ", -] +authors = ["Paul Hauner ", "Age Manning "] edition = { workspace = true } [features] From 537c2ba8b3e49dd9e93dd5df5b67003f5bb91f42 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 9 Mar 2026 11:35:52 +1100 Subject: [PATCH 051/189] Remove `/lighthouse/analysis/block_rewards` APIs (#8935) Mark pointed out that these APIs will require updates for Gloas, so I figured we may as well get rid of them. As far as I know, blockprint was the only use case and it is now defunct. The consensus block value is included in getBlock API responses, so there's no reason for VCs to use the `POST` API, and there is now a standard API for the rewards of canonical blocks. The SSE event was non-standard, and likely only used by blockprint as well. Co-Authored-By: Michael Sproul --- .github/forbidden-files.txt | 3 + beacon_node/beacon_chain/src/block_reward.rs | 140 -------------- .../beacon_chain/src/block_verification.rs | 18 -- beacon_node/beacon_chain/src/events.rs | 15 -- beacon_node/beacon_chain/src/lib.rs | 1 - beacon_node/http_api/src/block_rewards.rs | 178 ------------------ beacon_node/http_api/src/lib.rs | 34 ---- book/src/api_lighthouse.md | 54 ------ common/eth2/src/lighthouse.rs | 25 +-- common/eth2/src/lighthouse/block_rewards.rs | 60 ------ common/eth2/src/types.rs | 17 -- 11 files changed, 4 insertions(+), 541 deletions(-) delete mode 100644 beacon_node/beacon_chain/src/block_reward.rs delete mode 100644 beacon_node/http_api/src/block_rewards.rs delete mode 100644 common/eth2/src/lighthouse/block_rewards.rs diff --git a/.github/forbidden-files.txt b/.github/forbidden-files.txt index ec89bd2e4b..a08a6b4e98 100644 --- a/.github/forbidden-files.txt +++ b/.github/forbidden-files.txt @@ -5,3 +5,6 @@ beacon_node/beacon_chain/src/otb_verification_service.rs beacon_node/store/src/partial_beacon_state.rs beacon_node/store/src/consensus_context.rs +beacon_node/beacon_chain/src/block_reward.rs +beacon_node/http_api/src/block_rewards.rs +common/eth2/src/lighthouse/block_rewards.rs diff --git a/beacon_node/beacon_chain/src/block_reward.rs b/beacon_node/beacon_chain/src/block_reward.rs deleted file mode 100644 index f3924bb473..0000000000 --- a/beacon_node/beacon_chain/src/block_reward.rs +++ /dev/null @@ -1,140 +0,0 @@ -use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; -use eth2::lighthouse::{AttestationRewards, BlockReward, BlockRewardMeta}; -use operation_pool::{ - AttMaxCover, MaxCover, PROPOSER_REWARD_DENOMINATOR, RewardCache, SplitAttestation, -}; -use state_processing::{ - common::get_attesting_indices_from_state, - per_block_processing::altair::sync_committee::compute_sync_aggregate_rewards, -}; -use types::{AbstractExecPayload, BeaconBlockRef, BeaconState, EthSpec, Hash256}; - -impl BeaconChain { - pub fn compute_block_reward>( - &self, - block: BeaconBlockRef<'_, T::EthSpec, Payload>, - block_root: Hash256, - state: &BeaconState, - reward_cache: &mut RewardCache, - include_attestations: bool, - ) -> Result { - if block.slot() != state.slot() { - return Err(BeaconChainError::BlockRewardSlotError); - } - - reward_cache.update(state)?; - - let total_active_balance = state.get_total_active_balance()?; - - let split_attestations = block - .body() - .attestations() - .map(|att| { - let attesting_indices = get_attesting_indices_from_state(state, att)?; - Ok(SplitAttestation::new( - att.clone_as_attestation(), - attesting_indices, - )) - }) - .collect::, BeaconChainError>>()?; - - let mut per_attestation_rewards = split_attestations - .iter() - .map(|att| { - AttMaxCover::new( - att.as_ref(), - state, - reward_cache, - total_active_balance, - &self.spec, - ) - .ok_or(BeaconChainError::BlockRewardAttestationError) - }) - .collect::, _>>()?; - - // Update the attestation rewards for each previous attestation included. - // This is O(n^2) in the number of attestations n. - for i in 0..per_attestation_rewards.len() { - let (updated, to_update) = per_attestation_rewards.split_at_mut(i + 1); - let latest_att = &updated[i]; - - for att in to_update { - att.update_covering_set(latest_att.intermediate(), latest_att.covering_set()); - } - } - - let mut prev_epoch_total = 0; - let mut curr_epoch_total = 0; - - for cover in &per_attestation_rewards { - if cover.att.data.slot.epoch(T::EthSpec::slots_per_epoch()) == state.current_epoch() { - curr_epoch_total += cover.score() as u64; - } else { - prev_epoch_total += cover.score() as u64; - } - } - - let attestation_total = prev_epoch_total + curr_epoch_total; - - // Drop the covers. - let per_attestation_rewards = per_attestation_rewards - .into_iter() - .map(|cover| { - // Divide each reward numerator by the denominator. This can lead to the total being - // less than the sum of the individual rewards due to the fact that integer division - // does not distribute over addition. - let mut rewards = cover.fresh_validators_rewards; - rewards - .values_mut() - .for_each(|reward| *reward /= PROPOSER_REWARD_DENOMINATOR); - rewards - }) - .collect(); - - // Add the attestation data if desired. - let attestations = if include_attestations { - block - .body() - .attestations() - .map(|a| a.data().clone()) - .collect() - } else { - vec![] - }; - - let attestation_rewards = AttestationRewards { - total: attestation_total, - prev_epoch_total, - curr_epoch_total, - per_attestation_rewards, - attestations, - }; - - // Sync committee rewards. - let sync_committee_rewards = if let Ok(sync_aggregate) = block.body().sync_aggregate() { - let (_, proposer_reward_per_bit) = compute_sync_aggregate_rewards(state, &self.spec) - .map_err(|_| BeaconChainError::BlockRewardSyncError)?; - sync_aggregate.sync_committee_bits.num_set_bits() as u64 * proposer_reward_per_bit - } else { - 0 - }; - - // Total, metadata - let total = attestation_total + sync_committee_rewards; - - let meta = BlockRewardMeta { - slot: block.slot(), - parent_slot: state.latest_block_header().slot, - proposer_index: block.proposer_index(), - graffiti: block.body().graffiti().as_utf8_lossy(), - }; - - Ok(BlockReward { - total, - block_root, - meta, - attestation_rewards, - sync_committee_rewards, - }) - } -} diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index d126c3af00..2021b0d952 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -1571,24 +1571,6 @@ impl ExecutionPendingBlock { metrics::stop_timer(committee_timer); - /* - * If we have block reward listeners, compute the block reward and push it to the - * event handler. - */ - if let Some(ref event_handler) = chain.event_handler - && event_handler.has_block_reward_subscribers() - { - let mut reward_cache = Default::default(); - let block_reward = chain.compute_block_reward( - block.message(), - block_root, - &state, - &mut reward_cache, - true, - )?; - event_handler.register(EventKind::BlockReward(block_reward)); - } - /* * Perform `per_block_processing` on the block and state, returning early if the block is * invalid. diff --git a/beacon_node/beacon_chain/src/events.rs b/beacon_node/beacon_chain/src/events.rs index 63be944eea..276edc3fe6 100644 --- a/beacon_node/beacon_chain/src/events.rs +++ b/beacon_node/beacon_chain/src/events.rs @@ -21,7 +21,6 @@ pub struct ServerSentEventHandler { late_head: Sender>, light_client_finality_update_tx: Sender>, light_client_optimistic_update_tx: Sender>, - block_reward_tx: Sender>, proposer_slashing_tx: Sender>, attester_slashing_tx: Sender>, bls_to_execution_change_tx: Sender>, @@ -48,7 +47,6 @@ impl ServerSentEventHandler { let (late_head, _) = broadcast::channel(capacity); let (light_client_finality_update_tx, _) = broadcast::channel(capacity); let (light_client_optimistic_update_tx, _) = broadcast::channel(capacity); - let (block_reward_tx, _) = broadcast::channel(capacity); let (proposer_slashing_tx, _) = broadcast::channel(capacity); let (attester_slashing_tx, _) = broadcast::channel(capacity); let (bls_to_execution_change_tx, _) = broadcast::channel(capacity); @@ -69,7 +67,6 @@ impl ServerSentEventHandler { late_head, light_client_finality_update_tx, light_client_optimistic_update_tx, - block_reward_tx, proposer_slashing_tx, attester_slashing_tx, bls_to_execution_change_tx, @@ -142,10 +139,6 @@ impl ServerSentEventHandler { .light_client_optimistic_update_tx .send(kind) .map(|count| log_count("light client optimistic update", count)), - EventKind::BlockReward(_) => self - .block_reward_tx - .send(kind) - .map(|count| log_count("block reward", count)), EventKind::ProposerSlashing(_) => self .proposer_slashing_tx .send(kind) @@ -224,10 +217,6 @@ impl ServerSentEventHandler { self.light_client_optimistic_update_tx.subscribe() } - pub fn subscribe_block_reward(&self) -> Receiver> { - self.block_reward_tx.subscribe() - } - pub fn subscribe_attester_slashing(&self) -> Receiver> { self.attester_slashing_tx.subscribe() } @@ -292,10 +281,6 @@ impl ServerSentEventHandler { self.late_head.receiver_count() > 0 } - pub fn has_block_reward_subscribers(&self) -> bool { - self.block_reward_tx.receiver_count() > 0 - } - pub fn has_proposer_slashing_subscribers(&self) -> bool { self.proposer_slashing_tx.receiver_count() > 0 } diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index e1a190ffb3..4d3c3e193e 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -10,7 +10,6 @@ mod beacon_snapshot; pub mod bellatrix_readiness; pub mod blob_verification; mod block_production; -pub mod block_reward; mod block_times_cache; mod block_verification; pub mod block_verification_types; diff --git a/beacon_node/http_api/src/block_rewards.rs b/beacon_node/http_api/src/block_rewards.rs deleted file mode 100644 index 891f024bf9..0000000000 --- a/beacon_node/http_api/src/block_rewards.rs +++ /dev/null @@ -1,178 +0,0 @@ -use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, WhenSlotSkipped}; -use eth2::lighthouse::{BlockReward, BlockRewardsQuery}; -use lru::LruCache; -use state_processing::BlockReplayer; -use std::num::NonZeroUsize; -use std::sync::Arc; -use tracing::{debug, warn}; -use types::block::BlindedBeaconBlock; -use types::new_non_zero_usize; -use warp_utils::reject::{beacon_state_error, custom_bad_request, unhandled_error}; - -const STATE_CACHE_SIZE: NonZeroUsize = new_non_zero_usize(2); - -/// Fetch block rewards for blocks from the canonical chain. -pub fn get_block_rewards( - query: BlockRewardsQuery, - chain: Arc>, -) -> Result, warp::Rejection> { - let start_slot = query.start_slot; - let end_slot = query.end_slot; - let prior_slot = start_slot - 1; - - if start_slot > end_slot || start_slot == 0 { - return Err(custom_bad_request(format!( - "invalid start and end: {}, {}", - start_slot, end_slot - ))); - } - - let end_block_root = chain - .block_root_at_slot(end_slot, WhenSlotSkipped::Prev) - .map_err(unhandled_error)? - .ok_or_else(|| custom_bad_request(format!("block at end slot {} unknown", end_slot)))?; - - let blocks = chain - .store - .load_blocks_to_replay(start_slot, end_slot, end_block_root) - .map_err(|e| unhandled_error(BeaconChainError::from(e)))?; - - let state_root = chain - .state_root_at_slot(prior_slot) - .map_err(unhandled_error)? - .ok_or_else(|| custom_bad_request(format!("prior state at slot {} unknown", prior_slot)))?; - - // This branch is reached from the HTTP API. We assume the user wants - // to cache states so that future calls are faster. - let mut state = chain - .get_state(&state_root, Some(prior_slot), true) - .and_then(|maybe_state| maybe_state.ok_or(BeaconChainError::MissingBeaconState(state_root))) - .map_err(unhandled_error)?; - - state - .build_caches(&chain.spec) - .map_err(beacon_state_error)?; - - let mut reward_cache = Default::default(); - let mut block_rewards = Vec::with_capacity(blocks.len()); - - let block_replayer = BlockReplayer::new(state, &chain.spec) - .pre_block_hook(Box::new(|state, block| { - state.build_all_committee_caches(&chain.spec)?; - - // Compute block reward. - let block_reward = chain.compute_block_reward( - block.message(), - block.canonical_root(), - state, - &mut reward_cache, - query.include_attestations, - )?; - block_rewards.push(block_reward); - Ok(()) - })) - .state_root_iter( - chain - .forwards_iter_state_roots_until(prior_slot, end_slot) - .map_err(unhandled_error)?, - ) - .no_signature_verification() - .minimal_block_root_verification() - .apply_blocks(blocks, None) - .map_err(unhandled_error)?; - - if block_replayer.state_root_miss() { - warn!(%start_slot, %end_slot, "Block reward state root miss"); - } - - drop(block_replayer); - - Ok(block_rewards) -} - -/// Compute block rewards for blocks passed in as input. -pub fn compute_block_rewards( - blocks: Vec>, - chain: Arc>, -) -> Result, warp::Rejection> { - let mut block_rewards = Vec::with_capacity(blocks.len()); - let mut state_cache = LruCache::new(STATE_CACHE_SIZE); - let mut reward_cache = Default::default(); - - for block in blocks { - let parent_root = block.parent_root(); - - // Check LRU cache for a constructed state from a previous iteration. - let state = if let Some(state) = state_cache.get(&(parent_root, block.slot())) { - debug!( - ?parent_root, - slot = %block.slot(), - "Re-using cached state for block rewards" - ); - state - } else { - debug!( - ?parent_root, - slot = %block.slot(), - "Fetching state for block rewards" - ); - let parent_block = chain - .get_blinded_block(&parent_root) - .map_err(unhandled_error)? - .ok_or_else(|| { - custom_bad_request(format!( - "parent block not known or not canonical: {:?}", - parent_root - )) - })?; - - // This branch is reached from the HTTP API. We assume the user wants - // to cache states so that future calls are faster. - let parent_state = chain - .get_state(&parent_block.state_root(), Some(parent_block.slot()), true) - .map_err(unhandled_error)? - .ok_or_else(|| { - custom_bad_request(format!( - "no state known for parent block: {:?}", - parent_root - )) - })?; - - let block_replayer = BlockReplayer::new(parent_state, &chain.spec) - .no_signature_verification() - .state_root_iter([Ok((parent_block.state_root(), parent_block.slot()))].into_iter()) - .minimal_block_root_verification() - .apply_blocks(vec![], Some(block.slot())) - .map_err(unhandled_error::)?; - - if block_replayer.state_root_miss() { - warn!( - parent_slot = %parent_block.slot(), - slot = %block.slot(), - "Block reward state root miss" - ); - } - - let mut state = block_replayer.into_state(); - state - .build_all_committee_caches(&chain.spec) - .map_err(beacon_state_error)?; - - state_cache.get_or_insert((parent_root, block.slot()), || state) - }; - - // Compute block reward. - let block_reward = chain - .compute_block_reward( - block.to_ref(), - block.canonical_root(), - state, - &mut reward_cache, - true, - ) - .map_err(unhandled_error)?; - block_rewards.push(block_reward); - } - - Ok(block_rewards) -} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index e9dfa2876a..0a0ae683ca 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -12,7 +12,6 @@ mod attester_duties; mod beacon; mod block_id; mod block_packing_efficiency; -mod block_rewards; mod build_block_contents; mod builder_states; mod custody; @@ -3066,34 +3065,6 @@ pub fn serve( }, ); - // GET lighthouse/analysis/block_rewards - let get_lighthouse_block_rewards = warp::path("lighthouse") - .and(warp::path("analysis")) - .and(warp::path("block_rewards")) - .and(warp::query::()) - .and(warp::path::end()) - .and(task_spawner_filter.clone()) - .and(chain_filter.clone()) - .then(|query, task_spawner: TaskSpawner, chain| { - task_spawner.blocking_json_task(Priority::P1, move || { - block_rewards::get_block_rewards(query, chain) - }) - }); - - // POST lighthouse/analysis/block_rewards - let post_lighthouse_block_rewards = warp::path("lighthouse") - .and(warp::path("analysis")) - .and(warp::path("block_rewards")) - .and(warp_utils::json::json()) - .and(warp::path::end()) - .and(task_spawner_filter.clone()) - .and(chain_filter.clone()) - .then(|blocks, task_spawner: TaskSpawner, chain| { - task_spawner.blocking_json_task(Priority::P1, move || { - block_rewards::compute_block_rewards(blocks, chain) - }) - }); - // GET lighthouse/analysis/attestation_performance/{index} let get_lighthouse_attestation_performance = warp::path("lighthouse") .and(warp::path("analysis")) @@ -3184,9 +3155,6 @@ pub fn serve( api_types::EventTopic::LightClientOptimisticUpdate => { event_handler.subscribe_light_client_optimistic_update() } - api_types::EventTopic::BlockReward => { - event_handler.subscribe_block_reward() - } api_types::EventTopic::AttesterSlashing => { event_handler.subscribe_attester_slashing() } @@ -3363,7 +3331,6 @@ pub fn serve( .uor(get_lighthouse_staking) .uor(get_lighthouse_database_info) .uor(get_lighthouse_custody_info) - .uor(get_lighthouse_block_rewards) .uor(get_lighthouse_attestation_performance) .uor(get_beacon_light_client_optimistic_update) .uor(get_beacon_light_client_finality_update) @@ -3414,7 +3381,6 @@ pub fn serve( .uor(post_validator_liveness_epoch) .uor(post_lighthouse_liveness) .uor(post_lighthouse_database_reconstruct) - .uor(post_lighthouse_block_rewards) .uor(post_lighthouse_ui_validator_metrics) .uor(post_lighthouse_ui_validator_info) .uor(post_lighthouse_finalize) diff --git a/book/src/api_lighthouse.md b/book/src/api_lighthouse.md index 0442bf4ec0..2fd7290cb2 100644 --- a/book/src/api_lighthouse.md +++ b/book/src/api_lighthouse.md @@ -590,60 +590,6 @@ Caveats: This is because the state *prior* to the `start_epoch` needs to be loaded from the database, and loading a state on a boundary is most efficient. -## `/lighthouse/analysis/block_rewards` - -Fetch information about the block rewards paid to proposers for a range of consecutive blocks. - -Two query parameters are required: - -- `start_slot` (inclusive): the slot of the first block to compute rewards for. -- `end_slot` (inclusive): the slot of the last block to compute rewards for. - -Example: - -```bash -curl -X GET "http://localhost:5052/lighthouse/analysis/block_rewards?start_slot=1&end_slot=1" | jq -``` - -The first few lines of the response would look like: - -```json -[ - { - "total": 637260, - "block_root": "0x4a089c5e390bb98e66b27358f157df825128ea953cee9d191229c0bcf423a4f6", - "meta": { - "slot": "1", - "parent_slot": "0", - "proposer_index": 93, - "graffiti": "EF #vm-eth2-raw-iron-101" - }, - "attestation_rewards": { - "total": 637260, - "prev_epoch_total": 0, - "curr_epoch_total": 637260, - "per_attestation_rewards": [ - { - "50102": 780, - } - ] - } - } -] -``` - -Caveats: - -- Presently only attestation and sync committee rewards are computed. -- The output format is verbose and subject to change. Please see [`BlockReward`][block_reward_src] - in the source. -- For maximum efficiency the `start_slot` should satisfy `start_slot % slots_per_restore_point == 1`. - This is because the state *prior* to the `start_slot` needs to be loaded from the database, and - loading a state on a boundary is most efficient. - -[block_reward_src]: -https://github.com/sigp/lighthouse/tree/unstable/common/eth2/src/lighthouse/block_rewards.rs - ## `/lighthouse/analysis/block_packing` Fetch information about the block packing efficiency of blocks for a range of consecutive diff --git a/common/eth2/src/lighthouse.rs b/common/eth2/src/lighthouse.rs index 993c263cbf..3c039b16b3 100644 --- a/common/eth2/src/lighthouse.rs +++ b/common/eth2/src/lighthouse.rs @@ -2,12 +2,11 @@ mod attestation_performance; mod block_packing_efficiency; -mod block_rewards; mod custody; pub mod sync_state; use crate::{ - BeaconNodeHttpClient, DepositData, Error, Hash256, Slot, + BeaconNodeHttpClient, DepositData, Error, Hash256, lighthouse::sync_state::SyncState, types::{AdminPeer, Epoch, GenericResponse, ValidatorId}, }; @@ -22,7 +21,6 @@ pub use attestation_performance::{ pub use block_packing_efficiency::{ BlockPackingEfficiency, BlockPackingEfficiencyQuery, ProposerInfo, UniqueAttestation, }; -pub use block_rewards::{AttestationRewards, BlockReward, BlockRewardMeta, BlockRewardsQuery}; pub use custody::CustodyInfo; // Define "legacy" implementations of `Option` which use four bytes for encoding the union @@ -317,27 +315,6 @@ impl BeaconNodeHttpClient { Analysis endpoints. */ - /// `GET` lighthouse/analysis/block_rewards?start_slot,end_slot - pub async fn get_lighthouse_analysis_block_rewards( - &self, - start_slot: Slot, - end_slot: Slot, - ) -> Result, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("lighthouse") - .push("analysis") - .push("block_rewards"); - - path.query_pairs_mut() - .append_pair("start_slot", &start_slot.to_string()) - .append_pair("end_slot", &end_slot.to_string()); - - self.get(path).await - } - /// `GET` lighthouse/analysis/block_packing?start_epoch,end_epoch pub async fn get_lighthouse_analysis_block_packing( &self, diff --git a/common/eth2/src/lighthouse/block_rewards.rs b/common/eth2/src/lighthouse/block_rewards.rs deleted file mode 100644 index 38070f3539..0000000000 --- a/common/eth2/src/lighthouse/block_rewards.rs +++ /dev/null @@ -1,60 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use types::{AttestationData, Hash256, Slot}; - -/// Details about the rewards paid to a block proposer for proposing a block. -/// -/// All rewards in GWei. -/// -/// Presently this only counts attestation rewards, but in future should be expanded -/// to include information on slashings and sync committee aggregates too. -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct BlockReward { - /// Sum of all reward components. - pub total: u64, - /// Block root of the block that these rewards are for. - pub block_root: Hash256, - /// Metadata about the block, particularly reward-relevant metadata. - pub meta: BlockRewardMeta, - /// Rewards due to attestations. - pub attestation_rewards: AttestationRewards, - /// Sum of rewards due to sync committee signatures. - pub sync_committee_rewards: u64, -} - -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct BlockRewardMeta { - pub slot: Slot, - pub parent_slot: Slot, - pub proposer_index: u64, - pub graffiti: String, -} - -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct AttestationRewards { - /// Total block reward from attestations included. - pub total: u64, - /// Total rewards from previous epoch attestations. - pub prev_epoch_total: u64, - /// Total rewards from current epoch attestations. - pub curr_epoch_total: u64, - /// Vec of attestation rewards for each attestation included. - /// - /// Each element of the vec is a map from validator index to reward. - pub per_attestation_rewards: Vec>, - /// The attestations themselves (optional). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attestations: Vec, -} - -/// Query parameters for the `/lighthouse/block_rewards` endpoint. -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct BlockRewardsQuery { - /// Lower slot limit for block rewards returned (inclusive). - pub start_slot: Slot, - /// Upper slot limit for block rewards returned (inclusive). - pub end_slot: Slot, - /// Include the full attestations themselves? - #[serde(default)] - pub include_attestations: bool, -} diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index f8376d430c..2f86170812 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -37,9 +37,6 @@ pub mod beacon_response { pub use crate::beacon_response::*; } -#[cfg(feature = "lighthouse")] -use crate::lighthouse::BlockReward; - // Re-export error types from the unified error module pub use crate::error::{ErrorMessage, Failure, IndexedErrorMessage, ResponseError as Error}; @@ -1199,8 +1196,6 @@ pub enum EventKind { LateHead(SseLateHead), LightClientFinalityUpdate(Box>>), LightClientOptimisticUpdate(Box>>), - #[cfg(feature = "lighthouse")] - BlockReward(BlockReward), PayloadAttributes(VersionedSsePayloadAttributes), ProposerSlashing(Box), AttesterSlashing(Box>), @@ -1225,8 +1220,6 @@ impl EventKind { EventKind::LateHead(_) => "late_head", EventKind::LightClientFinalityUpdate(_) => "light_client_finality_update", EventKind::LightClientOptimisticUpdate(_) => "light_client_optimistic_update", - #[cfg(feature = "lighthouse")] - EventKind::BlockReward(_) => "block_reward", EventKind::ProposerSlashing(_) => "proposer_slashing", EventKind::AttesterSlashing(_) => "attester_slashing", EventKind::BlsToExecutionChange(_) => "bls_to_execution_change", @@ -1302,10 +1295,6 @@ impl EventKind { })?), ))) } - #[cfg(feature = "lighthouse")] - "block_reward" => Ok(EventKind::BlockReward(serde_json::from_str(data).map_err( - |e| ServerError::InvalidServerSentEvent(format!("Block Reward: {:?}", e)), - )?)), "attester_slashing" => Ok(EventKind::AttesterSlashing( serde_json::from_str(data).map_err(|e| { ServerError::InvalidServerSentEvent(format!("Attester Slashing: {:?}", e)) @@ -1355,8 +1344,6 @@ pub enum EventTopic { PayloadAttributes, LightClientFinalityUpdate, LightClientOptimisticUpdate, - #[cfg(feature = "lighthouse")] - BlockReward, AttesterSlashing, ProposerSlashing, BlsToExecutionChange, @@ -1382,8 +1369,6 @@ impl FromStr for EventTopic { "late_head" => Ok(EventTopic::LateHead), "light_client_finality_update" => Ok(EventTopic::LightClientFinalityUpdate), "light_client_optimistic_update" => Ok(EventTopic::LightClientOptimisticUpdate), - #[cfg(feature = "lighthouse")] - "block_reward" => Ok(EventTopic::BlockReward), "attester_slashing" => Ok(EventTopic::AttesterSlashing), "proposer_slashing" => Ok(EventTopic::ProposerSlashing), "bls_to_execution_change" => Ok(EventTopic::BlsToExecutionChange), @@ -1410,8 +1395,6 @@ impl fmt::Display for EventTopic { EventTopic::LateHead => write!(f, "late_head"), EventTopic::LightClientFinalityUpdate => write!(f, "light_client_finality_update"), EventTopic::LightClientOptimisticUpdate => write!(f, "light_client_optimistic_update"), - #[cfg(feature = "lighthouse")] - EventTopic::BlockReward => write!(f, "block_reward"), EventTopic::AttesterSlashing => write!(f, "attester_slashing"), EventTopic::ProposerSlashing => write!(f, "proposer_slashing"), EventTopic::BlsToExecutionChange => write!(f, "bls_to_execution_change"), From 7dab32dd16c997f592836f9728b2a2b4bf3ba756 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Mon, 9 Mar 2026 14:23:34 +0900 Subject: [PATCH 052/189] Gloas payload envelope processing (#8806) Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Jimmy Chen Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/beacon_chain.rs | 106 +---- .../beacon_chain/src/beacon_proposer_cache.rs | 79 +++- .../beacon_chain/src/block_verification.rs | 5 +- beacon_node/beacon_chain/src/builder.rs | 1 + .../beacon_chain/src/envelope_times_cache.rs | 197 ++++++++ .../beacon_chain/src/execution_payload.rs | 37 +- .../beacon_chain/src/historical_blocks.rs | 9 +- beacon_node/beacon_chain/src/lib.rs | 2 + beacon_node/beacon_chain/src/metrics.rs | 28 ++ .../execution_pending_envelope.rs | 105 +++++ .../gossip_verified_envelope.rs | 445 ++++++++++++++++++ .../payload_envelope_verification/import.rs | 354 ++++++++++++++ .../src/payload_envelope_verification/mod.rs | 285 +++++++++++ .../payload_notifier.rs | 94 ++++ .../beacon_chain/tests/block_verification.rs | 2 +- beacon_node/network/src/metrics.rs | 10 + .../gossip_methods.rs | 172 ++++++- .../src/network_beacon_processor/mod.rs | 8 +- beacon_node/network/src/router.rs | 1 + 19 files changed, 1813 insertions(+), 127 deletions(-) create mode 100644 beacon_node/beacon_chain/src/envelope_times_cache.rs create mode 100644 beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs create mode 100644 beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs create mode 100644 beacon_node/beacon_chain/src/payload_envelope_verification/import.rs create mode 100644 beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs create mode 100644 beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 703ed24420..07f3bb01fa 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4,9 +4,7 @@ use crate::attestation_verification::{ batch_verify_unaggregated_attestations, }; use crate::beacon_block_streamer::{BeaconBlockStreamer, CheckCaches}; -use crate::beacon_proposer_cache::{ - BeaconProposerCache, EpochBlockProposers, ensure_state_can_determine_proposers_for_epoch, -}; +use crate::beacon_proposer_cache::{BeaconProposerCache, EpochBlockProposers}; use crate::blob_verification::{GossipBlobError, GossipVerifiedBlob}; use crate::block_times_cache::BlockTimesCache; use crate::block_verification::{ @@ -26,6 +24,7 @@ use crate::data_availability_checker::{ }; use crate::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; use crate::early_attester_cache::EarlyAttesterCache; +use crate::envelope_times_cache::EnvelopeTimesCache; use crate::errors::{BeaconChainError as Error, BlockProductionError}; use crate::events::ServerSentEventHandler; use crate::execution_payload::{NotifyExecutionLayer, PreparePayloadHandle, get_execution_payload}; @@ -66,7 +65,6 @@ use crate::sync_committee_verification::{ }; use crate::validator_monitor::{ HISTORIC_EPOCHS as VALIDATOR_MONITOR_HISTORIC_EPOCHS, ValidatorMonitor, get_slot_delay_ms, - timestamp_now, }; use crate::validator_pubkey_cache::ValidatorPubkeyCache; use crate::{ @@ -462,6 +460,8 @@ pub struct BeaconChain { pub early_attester_cache: EarlyAttesterCache, /// A cache used to keep track of various block timings. pub block_times_cache: Arc>, + /// A cache used to keep track of various envelope timings. + pub envelope_times_cache: Arc>, /// A cache used to track pre-finalization block roots for quick rejection. pub pre_finalization_block_cache: PreFinalizationBlockCache, /// A cache used to produce light_client server messages @@ -4042,23 +4042,10 @@ impl BeaconChain { // See https://github.com/sigp/lighthouse/issues/2028 let (_, signed_block, block_data) = signed_block.deconstruct(); - match self.get_blobs_or_columns_store_op(block_root, signed_block.slot(), block_data) { - Ok(Some(blobs_or_columns_store_op)) => { - ops.push(blobs_or_columns_store_op); - } - Ok(None) => {} - Err(e) => { - error!( - msg = "Restoring fork choice from disk", - error = &e, - ?block_root, - "Failed to store data columns into the database" - ); - return Err(self - .handle_import_block_db_write_error(fork_choice) - .err() - .unwrap_or(BlockError::InternalError(e))); - } + if let Some(blobs_or_columns_store_op) = + self.get_blobs_or_columns_store_op(block_root, signed_block.slot(), block_data) + { + ops.push(blobs_or_columns_store_op); } let block = signed_block.message(); @@ -4088,7 +4075,7 @@ impl BeaconChain { // We're declaring the block "imported" at this point, since fork choice and the DB know // about it. - let block_time_imported = timestamp_now(); + let block_time_imported = self.slot_clock.now_duration().unwrap_or(Duration::MAX); // compute state proofs for light client updates before inserting the state into the // snapshot cache. @@ -4157,7 +4144,7 @@ impl BeaconChain { } /// Check block's consistentency with any configured weak subjectivity checkpoint. - fn check_block_against_weak_subjectivity_checkpoint( + pub(crate) fn check_block_against_weak_subjectivity_checkpoint( &self, block: BeaconBlockRef, block_root: Hash256, @@ -6407,6 +6394,7 @@ impl BeaconChain { // sync anyway). self.naive_aggregation_pool.write().prune(slot); self.block_times_cache.write().prune(slot); + self.envelope_times_cache.write().prune(slot); // Don't run heavy-weight tasks during sync. if self.best_slot() + MAX_PER_SLOT_FORK_CHOICE_DISTANCE < slot { @@ -6466,62 +6454,14 @@ impl BeaconChain { accessor: impl Fn(&EpochBlockProposers) -> Result, state_provider: impl FnOnce() -> Result<(Hash256, BeaconState), E>, ) -> Result { - let cache_entry = self - .beacon_proposer_cache - .lock() - .get_or_insert_key(proposal_epoch, shuffling_decision_block); - - // If the cache entry is not initialised, run the code to initialise it inside a OnceCell. - // This prevents duplication of work across multiple threads. - // - // If it is already initialised, then `get_or_try_init` will return immediately without - // executing the initialisation code at all. - let epoch_block_proposers = cache_entry.get_or_try_init(|| { - // Fetch the state on-demand if the required epoch was missing from the cache. - // If the caller wants to not compute the state they must return an error here and then - // catch it at the call site. - let (state_root, mut state) = state_provider()?; - - // Ensure the state can compute proposer duties for `epoch`. - ensure_state_can_determine_proposers_for_epoch( - &mut state, - state_root, - proposal_epoch, - &self.spec, - )?; - - // Sanity check the state. - let latest_block_root = state.get_latest_block_root(state_root); - let state_decision_block_root = state.proposer_shuffling_decision_root_at_epoch( - proposal_epoch, - latest_block_root, - &self.spec, - )?; - if state_decision_block_root != shuffling_decision_block { - return Err(Error::ProposerCacheIncorrectState { - state_decision_block_root, - requested_decision_block_root: shuffling_decision_block, - } - .into()); - } - - let proposers = state.get_beacon_proposer_indices(proposal_epoch, &self.spec)?; - - // Use fork_at_epoch rather than the state's fork, because post-Fulu we may not have - // advanced the state completely into the new epoch. - let fork = self.spec.fork_at_epoch(proposal_epoch); - - debug!( - ?shuffling_decision_block, - epoch = %proposal_epoch, - "Priming proposer shuffling cache" - ); - - Ok::<_, E>(EpochBlockProposers::new(proposal_epoch, fork, proposers)) - })?; - - // Run the accessor function on the computed epoch proposers. - accessor(epoch_block_proposers).map_err(Into::into) + crate::beacon_proposer_cache::with_proposer_cache( + &self.beacon_proposer_cache, + shuffling_decision_block, + proposal_epoch, + accessor, + state_provider, + &self.spec, + ) } /// Runs the `map_fn` with the committee cache for `shuffling_epoch` from the chain with head @@ -7197,16 +7137,16 @@ impl BeaconChain { block_root: Hash256, block_slot: Slot, block_data: AvailableBlockData, - ) -> Result>, String> { + ) -> Option> { match block_data { - AvailableBlockData::NoData => Ok(None), + AvailableBlockData::NoData => None, AvailableBlockData::Blobs(blobs) => { debug!( %block_root, count = blobs.len(), "Writing blobs to store" ); - Ok(Some(StoreOp::PutBlobs(block_root, blobs))) + Some(StoreOp::PutBlobs(block_root, blobs)) } AvailableBlockData::DataColumns(mut data_columns) => { let columns_to_custody = self.custody_columns_for_epoch(Some( @@ -7222,7 +7162,7 @@ impl BeaconChain { count = data_columns.len(), "Writing data columns to store" ); - Ok(Some(StoreOp::PutDataColumns(block_root, data_columns))) + Some(StoreOp::PutDataColumns(block_root, data_columns)) } } } diff --git a/beacon_node/beacon_chain/src/beacon_proposer_cache.rs b/beacon_node/beacon_chain/src/beacon_proposer_cache.rs index 912f7f3bad..b258d7471f 100644 --- a/beacon_node/beacon_chain/src/beacon_proposer_cache.rs +++ b/beacon_node/beacon_chain/src/beacon_proposer_cache.rs @@ -12,12 +12,13 @@ use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; use fork_choice::ExecutionStatus; use lru::LruCache; use once_cell::sync::OnceCell; +use parking_lot::Mutex; use safe_arith::SafeArith; use smallvec::SmallVec; use state_processing::state_advance::partial_state_advance; use std::num::NonZeroUsize; use std::sync::Arc; -use tracing::instrument; +use tracing::{debug, instrument}; use typenum::Unsigned; use types::new_non_zero_usize; use types::{BeaconState, BeaconStateError, ChainSpec, Epoch, EthSpec, Fork, Hash256, Slot}; @@ -164,6 +165,82 @@ impl BeaconProposerCache { } } +/// Access the proposer cache, computing and caching the proposers if necessary. +/// +/// This is a free function that operates on references to the cache and spec, decoupled from +/// `BeaconChain`. The `accessor` is called with the cached `EpochBlockProposers` for the given +/// `(proposal_epoch, shuffling_decision_block)` key. If the cache entry is missing, the +/// `state_provider` closure is called to produce a state which is then used to compute and +/// cache the proposers. +pub fn with_proposer_cache( + beacon_proposer_cache: &Mutex, + shuffling_decision_block: Hash256, + proposal_epoch: Epoch, + accessor: impl Fn(&EpochBlockProposers) -> Result, + state_provider: impl FnOnce() -> Result<(Hash256, BeaconState), Err>, + spec: &ChainSpec, +) -> Result +where + Spec: EthSpec, + Err: From + From, +{ + let cache_entry = beacon_proposer_cache + .lock() + .get_or_insert_key(proposal_epoch, shuffling_decision_block); + + // If the cache entry is not initialised, run the code to initialise it inside a OnceCell. + // This prevents duplication of work across multiple threads. + // + // If it is already initialised, then `get_or_try_init` will return immediately without + // executing the initialisation code at all. + let epoch_block_proposers = cache_entry.get_or_try_init(|| { + // Fetch the state on-demand if the required epoch was missing from the cache. + // If the caller wants to not compute the state they must return an error here and then + // catch it at the call site. + let (state_root, mut state) = state_provider()?; + + // Ensure the state can compute proposer duties for `epoch`. + ensure_state_can_determine_proposers_for_epoch( + &mut state, + state_root, + proposal_epoch, + spec, + )?; + + // Sanity check the state. + let latest_block_root = state.get_latest_block_root(state_root); + let state_decision_block_root = state.proposer_shuffling_decision_root_at_epoch( + proposal_epoch, + latest_block_root, + spec, + )?; + if state_decision_block_root != shuffling_decision_block { + return Err(BeaconChainError::ProposerCacheIncorrectState { + state_decision_block_root, + requested_decision_block_root: shuffling_decision_block, + } + .into()); + } + + let proposers = state.get_beacon_proposer_indices(proposal_epoch, spec)?; + + // Use fork_at_epoch rather than the state's fork, because post-Fulu we may not have + // advanced the state completely into the new epoch. + let fork = spec.fork_at_epoch(proposal_epoch); + + debug!( + ?shuffling_decision_block, + epoch = %proposal_epoch, + "Priming proposer shuffling cache" + ); + + Ok::<_, Err>(EpochBlockProposers::new(proposal_epoch, fork, proposers)) + })?; + + // Run the accessor function on the computed epoch proposers. + accessor(epoch_block_proposers).map_err(Into::into) +} + /// Compute the proposer duties using the head state without cache. /// /// Return: diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 2021b0d952..b748bf5c6c 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -681,7 +681,8 @@ pub struct SignatureVerifiedBlock { } /// Used to await the result of executing payload with an EE. -type PayloadVerificationHandle = JoinHandle>>; +pub type PayloadVerificationHandle = + JoinHandle>>; /// A wrapper around a `SignedBeaconBlock` that indicates that this block is fully verified and /// ready to import into the `BeaconChain`. The validation includes: @@ -1357,7 +1358,7 @@ impl ExecutionPendingBlock { /// verification must be done upstream (e.g., via a `SignatureVerifiedBlock` /// /// Returns an error if the block is invalid, or if the block was unable to be verified. - #[instrument(skip_all, level = "debug")] + #[instrument(skip_all, level = "debug", fields(?block_root))] pub fn from_signature_verified_components( block: MaybeAvailableBlock, block_root: Hash256, diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 66a54d46e8..d5935b492a 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -1023,6 +1023,7 @@ where )), beacon_proposer_cache, block_times_cache: <_>::default(), + envelope_times_cache: <_>::default(), pre_finalization_block_cache: <_>::default(), validator_pubkey_cache: RwLock::new(validator_pubkey_cache), early_attester_cache: <_>::default(), diff --git a/beacon_node/beacon_chain/src/envelope_times_cache.rs b/beacon_node/beacon_chain/src/envelope_times_cache.rs new file mode 100644 index 0000000000..84c936c210 --- /dev/null +++ b/beacon_node/beacon_chain/src/envelope_times_cache.rs @@ -0,0 +1,197 @@ +//! This module provides the `EnvelopeTimesCache` which contains information regarding payload +//! envelope timings. +//! +//! This provides `BeaconChain` and associated functions with access to the timestamps of when a +//! payload envelope was observed, verified, executed, and imported. +//! This allows for better traceability and allows us to determine the root cause for why an +//! envelope was imported late. +//! This allows us to distinguish between the following scenarios: +//! - The envelope was observed late. +//! - Consensus verification was slow. +//! - Execution verification was slow. +//! - The DB write was slow. + +use eth2::types::{Hash256, Slot}; +use std::collections::HashMap; +use std::time::Duration; + +type BlockRoot = Hash256; + +#[derive(Clone, Default)] +pub struct EnvelopeTimestamps { + /// When the envelope was first observed (gossip or RPC). + pub observed: Option, + /// When consensus verification (state transition) completed. + pub consensus_verified: Option, + /// When execution layer verification started. + pub started_execution: Option, + /// When execution layer verification completed. + pub executed: Option, + /// When the envelope was imported into the DB. + pub imported: Option, +} + +/// Delay data for envelope processing, computed relative to the slot start time. +#[derive(Debug, Default)] +pub struct EnvelopeDelays { + /// Time after start of slot we saw the envelope. + pub observed: Option, + /// The time it took to complete consensus verification of the envelope. + pub consensus_verification_time: Option, + /// The time it took to complete execution verification of the envelope. + pub execution_time: Option, + /// Time after execution until the envelope was imported. + pub imported: Option, +} + +impl EnvelopeDelays { + fn new(times: EnvelopeTimestamps, slot_start_time: Duration) -> EnvelopeDelays { + let observed = times + .observed + .and_then(|observed_time| observed_time.checked_sub(slot_start_time)); + let consensus_verification_time = times + .consensus_verified + .and_then(|consensus_verified| consensus_verified.checked_sub(times.observed?)); + let execution_time = times + .executed + .and_then(|executed| executed.checked_sub(times.started_execution?)); + let imported = times + .imported + .and_then(|imported_time| imported_time.checked_sub(times.executed?)); + EnvelopeDelays { + observed, + consensus_verification_time, + execution_time, + imported, + } + } +} + +pub struct EnvelopeTimesCacheValue { + pub slot: Slot, + pub timestamps: EnvelopeTimestamps, + pub peer_id: Option, +} + +impl EnvelopeTimesCacheValue { + fn new(slot: Slot) -> Self { + EnvelopeTimesCacheValue { + slot, + timestamps: Default::default(), + peer_id: None, + } + } +} + +#[derive(Default)] +pub struct EnvelopeTimesCache { + pub cache: HashMap, +} + +impl EnvelopeTimesCache { + /// Set the observation time for `block_root` to `timestamp` if `timestamp` is less than + /// any previous timestamp at which this envelope was observed. + pub fn set_time_observed( + &mut self, + block_root: BlockRoot, + slot: Slot, + timestamp: Duration, + peer_id: Option, + ) { + let entry = self + .cache + .entry(block_root) + .or_insert_with(|| EnvelopeTimesCacheValue::new(slot)); + match entry.timestamps.observed { + Some(existing) if existing <= timestamp => { + // Existing timestamp is earlier, do nothing. + } + _ => { + entry.timestamps.observed = Some(timestamp); + entry.peer_id = peer_id; + } + } + } + + /// Set the timestamp for `field` if that timestamp is less than any previously known value. + fn set_time_if_less( + &mut self, + block_root: BlockRoot, + slot: Slot, + field: impl Fn(&mut EnvelopeTimestamps) -> &mut Option, + timestamp: Duration, + ) { + let entry = self + .cache + .entry(block_root) + .or_insert_with(|| EnvelopeTimesCacheValue::new(slot)); + let existing_timestamp = field(&mut entry.timestamps); + if existing_timestamp.is_none_or(|prev| timestamp < prev) { + *existing_timestamp = Some(timestamp); + } + } + + pub fn set_time_consensus_verified( + &mut self, + block_root: BlockRoot, + slot: Slot, + timestamp: Duration, + ) { + self.set_time_if_less( + block_root, + slot, + |timestamps| &mut timestamps.consensus_verified, + timestamp, + ) + } + + pub fn set_time_started_execution( + &mut self, + block_root: BlockRoot, + slot: Slot, + timestamp: Duration, + ) { + self.set_time_if_less( + block_root, + slot, + |timestamps| &mut timestamps.started_execution, + timestamp, + ) + } + + pub fn set_time_executed(&mut self, block_root: BlockRoot, slot: Slot, timestamp: Duration) { + self.set_time_if_less( + block_root, + slot, + |timestamps| &mut timestamps.executed, + timestamp, + ) + } + + pub fn set_time_imported(&mut self, block_root: BlockRoot, slot: Slot, timestamp: Duration) { + self.set_time_if_less( + block_root, + slot, + |timestamps| &mut timestamps.imported, + timestamp, + ) + } + + pub fn get_envelope_delays( + &self, + block_root: BlockRoot, + slot_start_time: Duration, + ) -> EnvelopeDelays { + if let Some(entry) = self.cache.get(&block_root) { + EnvelopeDelays::new(entry.timestamps.clone(), slot_start_time) + } else { + EnvelopeDelays::default() + } + } + + /// Prune the cache to only store the most recent 2 epochs. + pub fn prune(&mut self, current_slot: Slot) { + self.cache + .retain(|_, entry| entry.slot > current_slot.saturating_sub(64_u64)); + } +} diff --git a/beacon_node/beacon_chain/src/execution_payload.rs b/beacon_node/beacon_chain/src/execution_payload.rs index a2ebed32ee..2b03a095f1 100644 --- a/beacon_node/beacon_chain/src/execution_payload.rs +++ b/beacon_node/beacon_chain/src/execution_payload.rs @@ -25,7 +25,6 @@ use state_processing::per_block_processing::{ use std::sync::Arc; use tokio::task::JoinHandle; use tracing::{Instrument, debug_span, warn}; -use tree_hash::TreeHash; use types::execution::BlockProductionVersion; use types::*; @@ -109,12 +108,18 @@ impl PayloadNotifier { if let Some(precomputed_status) = self.payload_verification_status { Ok(precomputed_status) } else { - notify_new_payload(&self.chain, self.block.message()).await + notify_new_payload( + &self.chain, + self.block.message().slot(), + self.block.message().parent_root(), + self.block.message().try_into()?, + ) + .await } } } -/// Verify that `execution_payload` contained by `block` is considered valid by an execution +/// Verify that `execution_payload` is considered valid by an execution /// engine. /// /// ## Specification @@ -123,17 +128,21 @@ impl PayloadNotifier { /// contains a few extra checks by running `partially_verify_execution_payload` first: /// /// https://github.com/ethereum/consensus-specs/blob/v1.1.9/specs/bellatrix/beacon-chain.md#notify_new_payload -async fn notify_new_payload( +pub async fn notify_new_payload( chain: &Arc>, - block: BeaconBlockRef<'_, T::EthSpec>, + slot: Slot, + parent_beacon_block_root: Hash256, + new_payload_request: NewPayloadRequest<'_, T::EthSpec>, ) -> Result { let execution_layer = chain .execution_layer .as_ref() .ok_or(ExecutionPayloadError::NoExecutionConnection)?; - let execution_block_hash = block.execution_payload()?.block_hash(); - let new_payload_response = execution_layer.notify_new_payload(block.try_into()?).await; + let execution_block_hash = new_payload_request.execution_payload_ref().block_hash(); + let new_payload_response = execution_layer + .notify_new_payload(new_payload_request.clone()) + .await; match new_payload_response { Ok(status) => match status { @@ -149,10 +158,7 @@ async fn notify_new_payload( ?validation_error, ?latest_valid_hash, ?execution_block_hash, - root = ?block.tree_hash_root(), - graffiti = block.body().graffiti().as_utf8_lossy(), - proposer_index = block.proposer_index(), - slot = %block.slot(), + %slot, method = "new_payload", "Invalid execution payload" ); @@ -175,11 +181,9 @@ async fn notify_new_payload( { // This block has not yet been applied to fork choice, so the latest block that was // imported to fork choice was the parent. - let latest_root = block.parent_root(); - chain .process_invalid_execution_payload(&InvalidationOperation::InvalidateMany { - head_block_root: latest_root, + head_block_root: parent_beacon_block_root, always_invalidate_head: false, latest_valid_ancestor: latest_valid_hash, }) @@ -194,10 +198,7 @@ async fn notify_new_payload( warn!( ?validation_error, ?execution_block_hash, - root = ?block.tree_hash_root(), - graffiti = block.body().graffiti().as_utf8_lossy(), - proposer_index = block.proposer_index(), - slot = %block.slot(), + %slot, method = "new_payload", "Invalid execution payload block hash" ); diff --git a/beacon_node/beacon_chain/src/historical_blocks.rs b/beacon_node/beacon_chain/src/historical_blocks.rs index 1dae2258f6..bfda52558e 100644 --- a/beacon_node/beacon_chain/src/historical_blocks.rs +++ b/beacon_node/beacon_chain/src/historical_blocks.rs @@ -165,13 +165,8 @@ impl BeaconChain { } // Store the blobs or data columns too - if let Some(op) = self - .get_blobs_or_columns_store_op(block_root, block.slot(), block_data) - .map_err(|e| { - HistoricalBlockError::StoreError(StoreError::DBError { - message: format!("get_blobs_or_columns_store_op error {e:?}"), - }) - })? + if let Some(op) = + self.get_blobs_or_columns_store_op(block_root, block.slot(), block_data) { blob_batch.extend(self.store.convert_to_kv_batch(vec![op])?); } diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index 4d3c3e193e..4efd90bd22 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -20,6 +20,7 @@ pub mod custody_context; pub mod data_availability_checker; pub mod data_column_verification; mod early_attester_cache; +pub mod envelope_times_cache; mod errors; pub mod events; pub mod execution_payload; @@ -41,6 +42,7 @@ pub mod observed_block_producers; pub mod observed_data_sidecars; pub mod observed_operations; mod observed_slashable; +pub mod payload_envelope_verification; pub mod pending_payload_envelopes; pub mod persisted_beacon_chain; pub mod persisted_custody; diff --git a/beacon_node/beacon_chain/src/metrics.rs b/beacon_node/beacon_chain/src/metrics.rs index 9de67ca93f..786daa09da 100644 --- a/beacon_node/beacon_chain/src/metrics.rs +++ b/beacon_node/beacon_chain/src/metrics.rs @@ -21,6 +21,34 @@ pub const VALIDATOR_MONITOR_ATTESTATION_SIMULATOR_SOURCE_ATTESTER_HIT_TOTAL: &st pub const VALIDATOR_MONITOR_ATTESTATION_SIMULATOR_SOURCE_ATTESTER_MISS_TOTAL: &str = "validator_monitor_attestation_simulator_source_attester_miss_total"; +/* +* Execution Payload Envelope Processing +*/ + +pub static ENVELOPE_PROCESSING_REQUESTS: LazyLock> = LazyLock::new(|| { + try_create_int_counter( + "payload_envelope_processing_requests_total", + "Count of payload envelopes submitted for processing", + ) +}); +pub static ENVELOPE_PROCESSING_SUCCESSES: LazyLock> = LazyLock::new(|| { + try_create_int_counter( + "payload_envelope_processing_successes_total", + "Count of payload envelopes processed without error", + ) +}); +pub static ENVELOPE_PROCESSING_TIMES: LazyLock> = LazyLock::new(|| { + try_create_histogram( + "payload_envelope_processing_seconds", + "Full runtime of payload envelope processing", + ) +}); +pub static ENVELOPE_PROCESSING_DB_WRITE: LazyLock> = LazyLock::new(|| { + try_create_histogram( + "payload_envelope_processing_db_write_seconds", + "Time spent writing a newly processed payload envelope and state to DB", + ) +}); /* * Block Processing */ diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs new file mode 100644 index 0000000000..86f9293c8f --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs @@ -0,0 +1,105 @@ +use std::sync::Arc; + +use slot_clock::SlotClock; +use state_processing::{ + VerifySignatures, + envelope_processing::{VerifyStateRoot, process_execution_payload_envelope}, +}; +use types::EthSpec; + +use crate::{ + BeaconChain, BeaconChainError, BeaconChainTypes, NotifyExecutionLayer, + PayloadVerificationOutcome, + block_verification::PayloadVerificationHandle, + payload_envelope_verification::{ + EnvelopeError, EnvelopeImportData, MaybeAvailableEnvelope, + gossip_verified_envelope::GossipVerifiedEnvelope, load_snapshot_from_state_root, + payload_notifier::PayloadNotifier, + }, +}; + +pub struct ExecutionPendingEnvelope { + pub signed_envelope: MaybeAvailableEnvelope, + pub import_data: EnvelopeImportData, + pub payload_verification_handle: PayloadVerificationHandle, +} + +impl GossipVerifiedEnvelope { + pub fn into_execution_pending_envelope( + self, + chain: &Arc>, + notify_execution_layer: NotifyExecutionLayer, + ) -> Result, EnvelopeError> { + let signed_envelope = self.signed_envelope; + let envelope = &signed_envelope.message; + let payload = &envelope.payload; + + // Define a future that will verify the execution payload with an execution engine. + // + // We do this as early as possible so that later parts of this function can run in parallel + // with the payload verification. + let payload_notifier = PayloadNotifier::new( + chain.clone(), + signed_envelope.clone(), + self.block.clone(), + notify_execution_layer, + )?; + let block_root = envelope.beacon_block_root; + let slot = self.block.slot(); + + let payload_verification_future = async move { + let chain = payload_notifier.chain.clone(); + if let Some(started_execution) = chain.slot_clock.now_duration() { + chain + .envelope_times_cache + .write() + .set_time_started_execution(block_root, slot, started_execution); + } + + let payload_verification_status = payload_notifier.notify_new_payload().await?; + Ok(PayloadVerificationOutcome { + payload_verification_status, + }) + }; + // Spawn the payload verification future as a new task, but don't wait for it to complete. + // The `payload_verification_future` will be awaited later to ensure verification completed + // successfully. + let payload_verification_handle = chain + .task_executor + .spawn_handle( + payload_verification_future, + "execution_payload_verification", + ) + .ok_or(BeaconChainError::RuntimeShutdown)?; + + let snapshot = if let Some(snapshot) = self.snapshot { + *snapshot + } else { + load_snapshot_from_state_root::(block_root, self.block.state_root(), &chain.store)? + }; + let mut state = snapshot.pre_state; + + // All the state modifications are done in envelope_processing + process_execution_payload_envelope( + &mut state, + Some(snapshot.state_root), + &signed_envelope, + // verify signature already done for GossipVerifiedEnvelope + VerifySignatures::False, + VerifyStateRoot::True, + &chain.spec, + )?; + + Ok(ExecutionPendingEnvelope { + signed_envelope: MaybeAvailableEnvelope::AvailabilityPending { + block_hash: payload.block_hash, + envelope: signed_envelope, + }, + import_data: EnvelopeImportData { + block_root, + post_state: Box::new(state), + }, + payload_verification_handle, + }) + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs new file mode 100644 index 0000000000..03a3a91ac5 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs @@ -0,0 +1,445 @@ +use std::sync::Arc; + +use educe::Educe; +use parking_lot::{Mutex, RwLock}; +use store::DatabaseBlock; +use tracing::{Span, debug}; +use types::{ + ChainSpec, EthSpec, ExecutionPayloadBid, ExecutionPayloadEnvelope, Hash256, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, Slot, consts::gloas::BUILDER_INDEX_SELF_BUILD, +}; + +use crate::{ + BeaconChain, BeaconChainError, BeaconChainTypes, BeaconStore, + beacon_proposer_cache::{self, BeaconProposerCache}, + canonical_head::CanonicalHead, + payload_envelope_verification::{ + EnvelopeError, EnvelopeProcessingSnapshot, load_snapshot_from_state_root, + }, + validator_pubkey_cache::ValidatorPubkeyCache, +}; + +/// Bundles only the dependencies needed for gossip verification of execution payload envelopes, +/// decoupling `GossipVerifiedEnvelope::new` from the full `BeaconChain`. +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + pub canonical_head: &'a CanonicalHead, + pub store: &'a BeaconStore, + pub spec: &'a ChainSpec, + pub beacon_proposer_cache: &'a Mutex, + pub validator_pubkey_cache: &'a RwLock>, + pub genesis_validators_root: Hash256, +} + +/// Verify that an execution payload envelope is consistent with its beacon block +/// and execution bid. +pub(crate) fn verify_envelope_consistency( + envelope: &ExecutionPayloadEnvelope, + block: &SignedBeaconBlock, + execution_bid: &ExecutionPayloadBid, + latest_finalized_slot: Slot, +) -> Result<(), EnvelopeError> { + // Check that the envelope's slot isn't from a slot prior + // to the latest finalized slot. + if envelope.slot < latest_finalized_slot { + return Err(EnvelopeError::PriorToFinalization { + payload_slot: envelope.slot, + latest_finalized_slot, + }); + } + + // Check that the slot of the envelope matches the slot of the block. + if envelope.slot != block.slot() { + return Err(EnvelopeError::SlotMismatch { + block: block.slot(), + envelope: envelope.slot, + }); + } + + // Builder index matches committed bid. + if envelope.builder_index != execution_bid.builder_index { + return Err(EnvelopeError::BuilderIndexMismatch { + committed_bid: execution_bid.builder_index, + envelope: envelope.builder_index, + }); + } + + // The block hash should match the block hash of the execution bid. + if envelope.payload.block_hash != execution_bid.block_hash { + return Err(EnvelopeError::BlockHashMismatch { + committed_bid: execution_bid.block_hash, + envelope: envelope.payload.block_hash, + }); + } + + Ok(()) +} + +/// A wrapper around a `SignedExecutionPayloadEnvelope` that indicates it has been approved for re-gossiping on +/// the p2p network. +#[derive(Educe)] +#[educe(Debug(bound = "T: BeaconChainTypes"))] +pub struct GossipVerifiedEnvelope { + pub signed_envelope: Arc>, + pub block: Arc>, + pub snapshot: Option>>, +} + +impl GossipVerifiedEnvelope { + pub fn new( + signed_envelope: Arc>, + ctx: &GossipVerificationContext<'_, T>, + ) -> Result { + let envelope = &signed_envelope.message; + let beacon_block_root = envelope.beacon_block_root; + + // Check that we've seen the beacon block for this envelope and that it passes validation. + // TODO(EIP-7732): We might need some type of status table in order to differentiate between: + // If we have a block_processing_table, we could have a Processed(Bid, bool) state that is only + // entered post adding to fork choice. That way, we could potentially need only a single call to make + // sure the block is valid and to do all consequent checks with the bid + // + // 1. Blocks we haven't seen (IGNORE), and + // 2. Blocks we've seen that are invalid (REJECT). + // + // Presently these two cases are conflated. + let fork_choice_read_lock = ctx.canonical_head.fork_choice_read_lock(); + let Some(proto_block) = fork_choice_read_lock.get_block(&beacon_block_root) else { + return Err(EnvelopeError::BlockRootUnknown { + block_root: beacon_block_root, + }); + }; + + drop(fork_choice_read_lock); + + let latest_finalized_slot = ctx + .canonical_head + .cached_head() + .finalized_checkpoint() + .epoch + .start_slot(T::EthSpec::slots_per_epoch()); + + // TODO(EIP-7732): check that we haven't seen another valid `SignedExecutionPayloadEnvelope` + // for this block root from this builder - envelope status table check + let block = match ctx.store.try_get_full_block(&beacon_block_root)? { + Some(DatabaseBlock::Full(block)) => Arc::new(block), + Some(DatabaseBlock::Blinded(_)) | None => { + return Err(EnvelopeError::from(BeaconChainError::MissingBeaconBlock( + beacon_block_root, + ))); + } + }; + let execution_bid = &block + .message() + .body() + .signed_execution_payload_bid()? + .message; + + verify_envelope_consistency(envelope, &block, execution_bid, latest_finalized_slot)?; + + // Verify the envelope signature. + // + // For self-built envelopes, we can use the proposer cache for the fork and the + // validator pubkey cache for the proposer's pubkey, avoiding a state load from disk. + // For external builder envelopes, we must load the state to access the builder registry. + let builder_index = envelope.builder_index; + let block_slot = envelope.slot; + let envelope_epoch = block_slot.epoch(T::EthSpec::slots_per_epoch()); + // Since the payload's block is already guaranteed to be imported, the associated `proto_block.current_epoch_shuffling_id` + // already carries the correct `shuffling_decision_block`. + let proposer_shuffling_decision_block = proto_block + .current_epoch_shuffling_id + .shuffling_decision_block; + + let (signature_is_valid, opt_snapshot) = if builder_index == BUILDER_INDEX_SELF_BUILD { + // Fast path: self-built envelopes can be verified without loading the state. + let mut opt_snapshot = None; + let proposer = beacon_proposer_cache::with_proposer_cache( + ctx.beacon_proposer_cache, + proposer_shuffling_decision_block, + envelope_epoch, + |proposers| proposers.get_slot::(block_slot), + || { + debug!( + %beacon_block_root, + "Proposer shuffling cache miss for envelope verification" + ); + let snapshot = load_snapshot_from_state_root::( + beacon_block_root, + proto_block.state_root, + ctx.store, + )?; + opt_snapshot = Some(Box::new(snapshot.clone())); + Ok::<_, EnvelopeError>((snapshot.state_root, snapshot.pre_state)) + }, + ctx.spec, + )?; + let expected_proposer = proposer.index; + let fork = proposer.fork; + + if block.message().proposer_index() != expected_proposer as u64 { + return Err(EnvelopeError::IncorrectBlockProposer { + proposer_index: block.message().proposer_index(), + local_shuffling: expected_proposer as u64, + }); + } + + let pubkey_cache = ctx.validator_pubkey_cache.read(); + let pubkey = pubkey_cache + .get(block.message().proposer_index() as usize) + .ok_or_else(|| EnvelopeError::UnknownValidator { + proposer_index: block.message().proposer_index(), + })?; + let is_valid = signed_envelope.verify_signature( + pubkey, + &fork, + ctx.genesis_validators_root, + ctx.spec, + ); + (is_valid, opt_snapshot) + } else { + // TODO(gloas) if we implement a builder pubkey cache, we'll need to use it here. + // External builder: must load the state to get the builder pubkey. + let snapshot = load_snapshot_from_state_root::( + beacon_block_root, + proto_block.state_root, + ctx.store, + )?; + let is_valid = + signed_envelope.verify_signature_with_state(&snapshot.pre_state, ctx.spec)?; + (is_valid, Some(Box::new(snapshot))) + }; + + if !signature_is_valid { + return Err(EnvelopeError::BadSignature); + } + + Ok(Self { + signed_envelope, + block, + snapshot: opt_snapshot, + }) + } + + pub fn envelope_cloned(&self) -> Arc> { + self.signed_envelope.clone() + } +} + +impl BeaconChain { + /// Build a `GossipVerificationContext` from this `BeaconChain`. + pub fn gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + store: &self.store, + spec: &self.spec, + beacon_proposer_cache: &self.beacon_proposer_cache, + validator_pubkey_cache: &self.validator_pubkey_cache, + genesis_validators_root: self.genesis_validators_root, + } + } + + /// Returns `Ok(GossipVerifiedEnvelope)` if the supplied `envelope` should be forwarded onto the + /// gossip network. The envelope is not imported into the chain, it is just partially verified. + /// + /// The returned `GossipVerifiedEnvelope` should be provided to `Self::process_execution_payload_envelope` immediately + /// after it is returned, unless some other circumstance decides it should not be imported at + /// all. + /// + /// ## Errors + /// + /// Returns an `Err` if the given envelope was invalid, or an error was encountered during verification. + pub async fn verify_envelope_for_gossip( + self: &Arc, + envelope: Arc>, + ) -> Result, EnvelopeError> { + let chain = self.clone(); + let span = Span::current(); + self.task_executor + .clone() + .spawn_blocking_handle( + move || { + let _guard = span.enter(); + let slot = envelope.slot(); + let beacon_block_root = envelope.message.beacon_block_root; + + let ctx = chain.gossip_verification_context(); + match GossipVerifiedEnvelope::new(envelope, &ctx) { + Ok(verified) => { + debug!( + %slot, + ?beacon_block_root, + "Successfully verified gossip envelope" + ); + + Ok(verified) + } + Err(e) => { + debug!( + error = e.to_string(), + ?beacon_block_root, + %slot, + "Rejected gossip envelope" + ); + + Err(e) + } + } + }, + "gossip_envelope_verification_handle", + ) + .ok_or(BeaconChainError::RuntimeShutdown)? + .await + .map_err(BeaconChainError::TokioJoin)? + } +} + +#[cfg(test)] +mod tests { + use std::marker::PhantomData; + + use bls::Signature; + use ssz_types::VariableList; + use types::{ + BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, Eth1Data, ExecutionBlockHash, + ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, + Graffiti, Hash256, MinimalEthSpec, SignedBeaconBlock, SignedExecutionPayloadBid, Slot, + SyncAggregate, + }; + + use super::verify_envelope_consistency; + use crate::payload_envelope_verification::EnvelopeError; + + type E = MinimalEthSpec; + + fn make_envelope( + slot: Slot, + builder_index: u64, + block_hash: ExecutionBlockHash, + ) -> ExecutionPayloadEnvelope { + ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas { + block_hash, + ..ExecutionPayloadGloas::default() + }, + execution_requests: ExecutionRequests::default(), + builder_index, + beacon_block_root: Hash256::ZERO, + slot, + state_root: Hash256::ZERO, + } + } + + fn make_block(slot: Slot) -> SignedBeaconBlock { + let block = BeaconBlock::Gloas(BeaconBlockGloas { + slot, + proposer_index: 0, + parent_root: Hash256::ZERO, + state_root: Hash256::ZERO, + body: BeaconBlockBodyGloas { + randao_reveal: Signature::empty(), + eth1_data: Eth1Data { + deposit_root: Hash256::ZERO, + block_hash: Hash256::ZERO, + deposit_count: 0, + }, + graffiti: Graffiti::default(), + proposer_slashings: VariableList::empty(), + attester_slashings: VariableList::empty(), + attestations: VariableList::empty(), + deposits: VariableList::empty(), + voluntary_exits: VariableList::empty(), + sync_aggregate: SyncAggregate::empty(), + bls_to_execution_changes: VariableList::empty(), + signed_execution_payload_bid: SignedExecutionPayloadBid::empty(), + payload_attestations: VariableList::empty(), + _phantom: PhantomData, + }, + }); + SignedBeaconBlock::from_block(block, Signature::empty()) + } + + fn make_bid(builder_index: u64, block_hash: ExecutionBlockHash) -> ExecutionPayloadBid { + ExecutionPayloadBid { + builder_index, + block_hash, + ..ExecutionPayloadBid::default() + } + } + + #[test] + fn test_valid_envelope() { + let slot = Slot::new(10); + let builder_index = 5; + let block_hash = ExecutionBlockHash::repeat_byte(0xaa); + + let envelope = make_envelope(slot, builder_index, block_hash); + let block = make_block(slot); + let bid = make_bid(builder_index, block_hash); + + assert!(verify_envelope_consistency::(&envelope, &block, &bid, Slot::new(0)).is_ok()); + } + + #[test] + fn test_prior_to_finalization() { + let slot = Slot::new(5); + let builder_index = 1; + let block_hash = ExecutionBlockHash::repeat_byte(0xbb); + + let envelope = make_envelope(slot, builder_index, block_hash); + let block = make_block(slot); + let bid = make_bid(builder_index, block_hash); + let latest_finalized_slot = Slot::new(10); + + let result = + verify_envelope_consistency::(&envelope, &block, &bid, latest_finalized_slot); + assert!(matches!( + result, + Err(EnvelopeError::PriorToFinalization { .. }) + )); + } + + #[test] + fn test_slot_mismatch() { + let builder_index = 1; + let block_hash = ExecutionBlockHash::repeat_byte(0xcc); + + let envelope = make_envelope(Slot::new(10), builder_index, block_hash); + let block = make_block(Slot::new(20)); + let bid = make_bid(builder_index, block_hash); + + let result = verify_envelope_consistency::(&envelope, &block, &bid, Slot::new(0)); + assert!(matches!(result, Err(EnvelopeError::SlotMismatch { .. }))); + } + + #[test] + fn test_builder_index_mismatch() { + let slot = Slot::new(10); + let block_hash = ExecutionBlockHash::repeat_byte(0xdd); + + let envelope = make_envelope(slot, 1, block_hash); + let block = make_block(slot); + let bid = make_bid(2, block_hash); + + let result = verify_envelope_consistency::(&envelope, &block, &bid, Slot::new(0)); + assert!(matches!( + result, + Err(EnvelopeError::BuilderIndexMismatch { .. }) + )); + } + + #[test] + fn test_block_hash_mismatch() { + let slot = Slot::new(10); + let builder_index = 1; + + let envelope = make_envelope(slot, builder_index, ExecutionBlockHash::repeat_byte(0xee)); + let block = make_block(slot); + let bid = make_bid(builder_index, ExecutionBlockHash::repeat_byte(0xff)); + + let result = verify_envelope_consistency::(&envelope, &block, &bid, Slot::new(0)); + assert!(matches!( + result, + Err(EnvelopeError::BlockHashMismatch { .. }) + )); + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs new file mode 100644 index 0000000000..2ee315e559 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -0,0 +1,354 @@ +use std::sync::Arc; +use std::time::Duration; + +use fork_choice::PayloadVerificationStatus; +use slot_clock::SlotClock; +use store::StoreOp; +use tracing::{debug, error, info, info_span, instrument, warn}; +use types::{BeaconState, BlockImportSource, Hash256, Slot}; + +use super::{ + AvailableEnvelope, AvailableExecutedEnvelope, EnvelopeError, EnvelopeImportData, + ExecutedEnvelope, gossip_verified_envelope::GossipVerifiedEnvelope, +}; +use crate::{ + AvailabilityProcessingStatus, BeaconChain, BeaconChainError, BeaconChainTypes, + NotifyExecutionLayer, block_verification_types::AvailableBlockData, metrics, + payload_envelope_verification::ExecutionPendingEnvelope, validator_monitor::get_slot_delay_ms, +}; + +const ENVELOPE_METRICS_CACHE_SLOT_LIMIT: u32 = 64; + +impl BeaconChain { + /// Returns `Ok(status)` if the given `unverified_envelope` was successfully verified and + /// imported into the chain. + /// + /// ## Errors + /// + /// Returns an `Err` if the given payload envelope was invalid, or an error was encountered during + /// verification. + #[instrument(skip_all, fields(block_root = ?block_root, block_source = %block_source))] + pub async fn process_execution_payload_envelope( + self: &Arc, + block_root: Hash256, + unverified_envelope: GossipVerifiedEnvelope, + notify_execution_layer: NotifyExecutionLayer, + block_source: BlockImportSource, + publish_fn: impl FnOnce() -> Result<(), EnvelopeError>, + ) -> Result { + let block_slot = unverified_envelope.signed_envelope.slot(); + + // Set observed time if not already set. Usually this should be set by gossip or RPC, + // but just in case we set it again here (useful for tests). + if let Some(seen_timestamp) = self.slot_clock.now_duration() { + self.envelope_times_cache.write().set_time_observed( + block_root, + block_slot, + seen_timestamp, + None, + ); + } + + // TODO(gloas) insert the pre-executed envelope into some type of cache. + + let _full_timer = metrics::start_timer(&metrics::ENVELOPE_PROCESSING_TIMES); + + metrics::inc_counter(&metrics::ENVELOPE_PROCESSING_REQUESTS); + + // A small closure to group the verification and import errors. + let chain = self.clone(); + let import_envelope = async move { + let execution_pending = unverified_envelope + .into_execution_pending_envelope(&chain, notify_execution_layer)?; + publish_fn()?; + + // Record the time it took to complete consensus verification. + if let Some(timestamp) = chain.slot_clock.now_duration() { + chain + .envelope_times_cache + .write() + .set_time_consensus_verified(block_root, block_slot, timestamp); + } + + let envelope_times_cache = chain.envelope_times_cache.clone(); + let slot_clock = chain.slot_clock.clone(); + + // TODO(gloas): rename/refactor these `into_` names to be less similar and more clear + // about what the function actually does. + let executed_envelope = chain + .into_executed_payload_envelope(execution_pending) + .await + .inspect_err(|_| { + // TODO(gloas) If the envelope fails execution for whatever reason (e.g. engine offline), + // and we keep it in the cache, then the node will NOT perform lookup and + // reprocess this block until the block is evicted from DA checker, causing the + // chain to get stuck temporarily if the block is canonical. Therefore we remove + // it from the cache if execution fails. + })?; + + // Record the time it took to wait for execution layer verification. + if let Some(timestamp) = slot_clock.now_duration() { + envelope_times_cache + .write() + .set_time_executed(block_root, block_slot, timestamp); + } + + match executed_envelope { + ExecutedEnvelope::Available(envelope) => { + self.import_available_execution_payload_envelope(Box::new(envelope)) + .await + } + ExecutedEnvelope::AvailabilityPending() => Err(EnvelopeError::InternalError( + "Pending payload envelope not yet implemented".to_owned(), + )), + } + }; + + // Verify and import the payload envelope. + match import_envelope.await { + // The payload envelope was successfully verified and imported. + Ok(status @ AvailabilityProcessingStatus::Imported(block_root)) => { + info!( + ?block_root, + %block_slot, + source = %block_source, + "Execution payload envelope imported" + ); + + // TODO(gloas) do we need to send a `PayloadImported` event to the reprocess queue? + // TODO(gloas) do we need to recompute head? + // should canonical_head return the block and the payload now? + self.recompute_head_at_current_slot().await; + + metrics::inc_counter(&metrics::ENVELOPE_PROCESSING_SUCCESSES); + + Ok(status) + } + Ok(status @ AvailabilityProcessingStatus::MissingComponents(slot, block_root)) => { + debug!(?block_root, %slot, "Payload envelope awaiting blobs"); + + Ok(status) + } + Err(EnvelopeError::BeaconChainError(e)) => { + if matches!(e.as_ref(), BeaconChainError::TokioJoin(_)) { + debug!(error = ?e, "Envelope processing cancelled"); + } else { + warn!(error = ?e, "Execution payload envelope rejected"); + } + Err(EnvelopeError::BeaconChainError(e)) + } + Err(other) => { + warn!( + reason = other.to_string(), + "Execution payload envelope rejected" + ); + Err(other) + } + } + } + + /// Accepts a fully-verified payload envelope and awaits on its payload verification handle to + /// get a fully `ExecutedEnvelope`. + /// + /// An error is returned if the verification handle couldn't be awaited. + #[instrument(skip_all, level = "debug")] + async fn into_executed_payload_envelope( + self: Arc, + pending_envelope: ExecutionPendingEnvelope, + ) -> Result, EnvelopeError> { + let ExecutionPendingEnvelope { + signed_envelope, + import_data, + payload_verification_handle, + } = pending_envelope; + + let payload_verification_outcome = payload_verification_handle + .await + .map_err(BeaconChainError::TokioJoin)? + .ok_or(BeaconChainError::RuntimeShutdown)??; + + Ok(ExecutedEnvelope::new( + signed_envelope, + import_data, + payload_verification_outcome, + )) + } + + #[instrument(skip_all)] + pub async fn import_available_execution_payload_envelope( + self: &Arc, + envelope: Box>, + ) -> Result { + let AvailableExecutedEnvelope { + envelope, + import_data, + payload_verification_outcome, + } = *envelope; + + let EnvelopeImportData { + block_root, + post_state, + } = import_data; + + let block_root = { + // Capture the current span before moving into the blocking task + let current_span = tracing::Span::current(); + let chain = self.clone(); + self.spawn_blocking_handle( + move || { + // Enter the captured span in the blocking thread + let _guard = current_span.enter(); + chain.import_execution_payload_envelope( + envelope, + block_root, + *post_state, + payload_verification_outcome.payload_verification_status, + ) + }, + "payload_verification_handle", + ) + .await?? + }; + + Ok(AvailabilityProcessingStatus::Imported(block_root)) + } + + /// Accepts a fully-verified and available envelope and imports it into the chain without performing any + /// additional verification. + /// + /// An error is returned if the envelope was unable to be imported. It may be partially imported + /// (i.e., this function is not atomic). + #[allow(clippy::too_many_arguments)] + #[instrument(skip_all)] + fn import_execution_payload_envelope( + &self, + signed_envelope: AvailableEnvelope, + block_root: Hash256, + state: BeaconState, + _payload_verification_status: PayloadVerificationStatus, + ) -> Result { + // Everything in this initial section is on the hot path for processing the envelope. + // Take an upgradable read lock on fork choice so we can check if this block has already + // been imported. We don't want to repeat work importing a block that is already imported. + let fork_choice_reader = self.canonical_head.fork_choice_upgradable_read_lock(); + if !fork_choice_reader.contains_block(&block_root) { + return Err(EnvelopeError::BlockRootUnknown { block_root }); + } + + // TODO(gloas) add defensive check to see if payload envelope is already in fork choice + // Note that a duplicate cache/payload status table should prevent this from happening + // but it doesnt hurt to be defensive. + + // TODO(gloas) when the code below is implemented we can delete this drop + drop(fork_choice_reader); + + // TODO(gloas) no fork choice logic yet + // Take an exclusive write-lock on fork choice. It's very important to prevent deadlocks by + // avoiding taking other locks whilst holding this lock. + // let fork_choice = parking_lot::RwLockUpgradableReadGuard::upgrade(fork_choice_reader); + + // TODO(gloas) Do we need this check? Do not import a block that doesn't descend from the finalized root. + // let signed_block = check_block_is_finalized_checkpoint_or_descendant(self, &fork_choice, signed_block)?; + + // TODO(gloas) emit SSE event if the payload became the new head payload + + // It is important NOT to return errors here before the database commit, because the envelope + // has already been added to fork choice and the database would be left in an inconsistent + // state if we returned early without committing. In other words, an error here would + // corrupt the node's database permanently. + + // Store the envelope, its post-state, and any data columns. + // If the write fails, revert fork choice to the version from disk, else we can + // end up with envelopes in fork choice that are missing from disk. + // See https://github.com/sigp/lighthouse/issues/2028 + let (signed_envelope, columns) = signed_envelope.deconstruct(); + + let mut ops = vec![]; + + if let Some(blobs_or_columns_store_op) = self.get_blobs_or_columns_store_op( + block_root, + signed_envelope.slot(), + AvailableBlockData::DataColumns(columns), + ) { + ops.push(blobs_or_columns_store_op); + } + + let db_write_timer = metrics::start_timer(&metrics::ENVELOPE_PROCESSING_DB_WRITE); + + ops.push(StoreOp::PutPayloadEnvelope( + block_root, + signed_envelope.clone(), + )); + ops.push(StoreOp::PutState( + signed_envelope.message.state_root, + &state, + )); + + let db_span = info_span!("persist_payloads_and_blobs").entered(); + + if let Err(e) = self.store.do_atomically_with_block_and_blobs_cache(ops) { + error!( + msg = "Restoring fork choice from disk", + error = ?e, + "Database write failed!" + ); + return Err(e.into()); + // TODO(gloas) handle db write failure + // return Err(self + // .handle_import_block_db_write_error(fork_choice) + // .err() + // .unwrap_or(e.into())); + } + + drop(db_span); + + // TODO(gloas) drop fork choice lock + // The fork choice write-lock is dropped *after* the on-disk database has been updated. + // This prevents inconsistency between the two at the expense of concurrency. + // drop(fork_choice); + + // We're declaring the envelope "imported" at this point, since fork choice and the DB know + // about it. + let envelope_time_imported = self.slot_clock.now_duration().unwrap_or(Duration::MAX); + + // TODO(gloas) depending on what happens with light clients + // we might need to do some light client related computations here + + metrics::stop_timer(db_write_timer); + + self.import_envelope_update_metrics_and_events( + block_root, + signed_envelope.slot(), + envelope_time_imported, + ); + + Ok(block_root) + } + + fn import_envelope_update_metrics_and_events( + &self, + block_root: Hash256, + envelope_slot: Slot, + envelope_time_imported: Duration, + ) { + let envelope_delay_total = + get_slot_delay_ms(envelope_time_imported, envelope_slot, &self.slot_clock); + + // Do not write to the cache for envelopes older than 2 epochs, this helps reduce writes + // to the cache during sync. + if envelope_delay_total + < self + .slot_clock + .slot_duration() + .saturating_mul(ENVELOPE_METRICS_CACHE_SLOT_LIMIT) + { + self.envelope_times_cache.write().set_time_imported( + block_root, + envelope_slot, + envelope_time_imported, + ); + } + + // TODO(gloas) emit SSE event for envelope import (similar to SseBlock for blocks). + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs new file mode 100644 index 0000000000..c707d62dc7 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs @@ -0,0 +1,285 @@ +//! The incremental processing steps (e.g., signatures verified but not the state transition) is +//! represented as a sequence of wrapper-types around the envelope. There is a linear progression of +//! types, starting at a `SignedExecutionPayloadEnvelope` and finishing with an `AvailableExecutedEnvelope` (see +//! diagram below). +//! +//! ```ignore +//! SignedExecutionPayloadEnvelope +//! | +//! ▼ +//! GossipVerifiedEnvelope +//! | +//! ▼ +//! ExecutionPendingEnvelope +//! | +//! await +//! ▼ +//! ExecutedEnvelope +//! +//! ``` + +use std::sync::Arc; + +use store::Error as DBError; + +use state_processing::{BlockProcessingError, envelope_processing::EnvelopeProcessingError}; +use tracing::instrument; +use types::{ + BeaconState, BeaconStateError, ChainSpec, DataColumnSidecarList, EthSpec, ExecutionBlockHash, + ExecutionPayloadEnvelope, Hash256, SignedExecutionPayloadEnvelope, Slot, +}; + +use crate::{ + BeaconChainError, BeaconChainTypes, BeaconStore, BlockError, ExecutionPayloadError, + PayloadVerificationOutcome, +}; + +pub mod execution_pending_envelope; +pub mod gossip_verified_envelope; +pub mod import; +mod payload_notifier; + +pub use execution_pending_envelope::ExecutionPendingEnvelope; + +#[derive(PartialEq)] +pub struct EnvelopeImportData { + pub block_root: Hash256, + pub post_state: Box>, +} + +#[derive(Debug)] +#[allow(dead_code)] +pub struct AvailableEnvelope { + execution_block_hash: ExecutionBlockHash, + envelope: Arc>, + columns: DataColumnSidecarList, + /// Timestamp at which this envelope first became available (UNIX timestamp, time since 1970). + columns_available_timestamp: Option, + pub spec: Arc, +} + +impl AvailableEnvelope { + pub fn message(&self) -> &ExecutionPayloadEnvelope { + &self.envelope.message + } + + #[allow(clippy::type_complexity)] + pub fn deconstruct( + self, + ) -> ( + Arc>, + DataColumnSidecarList, + ) { + let AvailableEnvelope { + envelope, columns, .. + } = self; + (envelope, columns) + } +} + +pub enum MaybeAvailableEnvelope { + Available(AvailableEnvelope), + AvailabilityPending { + block_hash: ExecutionBlockHash, + envelope: Arc>, + }, +} + +/// This snapshot is to be used for verifying a payload envelope. +#[derive(Debug, Clone)] +pub struct EnvelopeProcessingSnapshot { + /// This state is equivalent to the `self.beacon_block.state_root()` before applying the envelope. + pub pre_state: BeaconState, + pub state_root: Hash256, + pub beacon_block_root: Hash256, +} + +/// A payload envelope that has gone through processing checks and execution by an EL client. +/// This envelope hasn't necessarily completed data availability checks. +/// +/// +/// It contains 2 variants: +/// 1. `Available`: This envelope has been executed and also contains all data to consider it +/// fully available. +/// 2. `AvailabilityPending`: This envelope hasn't received all required blobs to consider it +/// fully available. +pub enum ExecutedEnvelope { + Available(AvailableExecutedEnvelope), + // TODO(gloas) implement availability pending + AvailabilityPending(), +} + +impl ExecutedEnvelope { + pub fn new( + envelope: MaybeAvailableEnvelope, + import_data: EnvelopeImportData, + payload_verification_outcome: PayloadVerificationOutcome, + ) -> Self { + match envelope { + MaybeAvailableEnvelope::Available(available_envelope) => { + Self::Available(AvailableExecutedEnvelope::new( + available_envelope, + import_data, + payload_verification_outcome, + )) + } + // TODO(gloas) implement availability pending + MaybeAvailableEnvelope::AvailabilityPending { + block_hash: _, + envelope: _, + } => Self::AvailabilityPending(), + } + } +} + +/// A payload envelope that has completed all payload processing checks including verification +/// by an EL client **and** has all requisite blob data to be imported into fork choice. +pub struct AvailableExecutedEnvelope { + pub envelope: AvailableEnvelope, + pub import_data: EnvelopeImportData, + pub payload_verification_outcome: PayloadVerificationOutcome, +} + +impl AvailableExecutedEnvelope { + pub fn new( + envelope: AvailableEnvelope, + import_data: EnvelopeImportData, + payload_verification_outcome: PayloadVerificationOutcome, + ) -> Self { + Self { + envelope, + import_data, + payload_verification_outcome, + } + } +} + +#[derive(Debug)] +pub enum EnvelopeError { + /// The envelope's block root is unknown. + BlockRootUnknown { block_root: Hash256 }, + /// The signature is invalid. + BadSignature, + /// The builder index doesn't match the committed bid + BuilderIndexMismatch { committed_bid: u64, envelope: u64 }, + /// The envelope slot doesn't match the block + SlotMismatch { block: Slot, envelope: Slot }, + /// The validator index is unknown + UnknownValidator { proposer_index: u64 }, + /// The block hash doesn't match the committed bid + BlockHashMismatch { + committed_bid: ExecutionBlockHash, + envelope: ExecutionBlockHash, + }, + /// The block's proposer_index does not match the locally computed proposer + IncorrectBlockProposer { + proposer_index: u64, + local_shuffling: u64, + }, + /// The slot belongs to a block that is from a slot prior than + /// to most recently finalized slot + PriorToFinalization { + payload_slot: Slot, + latest_finalized_slot: Slot, + }, + /// Some Beacon Chain Error + BeaconChainError(Arc), + /// Some Beacon State error + BeaconStateError(BeaconStateError), + /// Some BlockProcessingError (for electra operations) + BlockProcessingError(BlockProcessingError), + /// Some EnvelopeProcessingError + EnvelopeProcessingError(EnvelopeProcessingError), + /// Error verifying the execution payload + ExecutionPayloadError(ExecutionPayloadError), + /// An error from block-level checks reused during envelope import + BlockError(BlockError), + /// Internal error + InternalError(String), +} + +impl std::fmt::Display for EnvelopeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for EnvelopeError { + fn from(e: BeaconChainError) -> Self { + EnvelopeError::BeaconChainError(Arc::new(e)) + } +} + +impl From for EnvelopeError { + fn from(e: ExecutionPayloadError) -> Self { + EnvelopeError::ExecutionPayloadError(e) + } +} + +impl From for EnvelopeError { + fn from(e: BeaconStateError) -> Self { + EnvelopeError::BeaconStateError(e) + } +} + +impl From for EnvelopeError { + fn from(e: DBError) -> Self { + EnvelopeError::BeaconChainError(Arc::new(BeaconChainError::DBError(e))) + } +} + +impl From for EnvelopeError { + fn from(e: BlockError) -> Self { + EnvelopeError::BlockError(e) + } +} + +/// Pull errors up from EnvelopeProcessingError to EnvelopeError +impl From for EnvelopeError { + fn from(e: EnvelopeProcessingError) -> Self { + match e { + EnvelopeProcessingError::BadSignature => EnvelopeError::BadSignature, + EnvelopeProcessingError::BeaconStateError(e) => EnvelopeError::BeaconStateError(e), + EnvelopeProcessingError::BlockHashMismatch { + committed_bid, + envelope, + } => EnvelopeError::BlockHashMismatch { + committed_bid, + envelope, + }, + EnvelopeProcessingError::BlockProcessingError(e) => { + EnvelopeError::BlockProcessingError(e) + } + e => EnvelopeError::EnvelopeProcessingError(e), + } + } +} + +#[instrument(skip_all, level = "debug", fields(beacon_block_root = %beacon_block_root))] +/// Load state from store given a known state root and block root. +/// Use this when the proto block has already been looked up from fork choice. +pub(crate) fn load_snapshot_from_state_root( + beacon_block_root: Hash256, + block_state_root: Hash256, + store: &BeaconStore, +) -> Result, EnvelopeError> { + // TODO(EIP-7732): add metrics here + + // We can use `get_hot_state` here rather than `get_advanced_hot_state` because the envelope + // must be from the same slot as its block (so no advance is required). + let cache_state = true; + let state = store + .get_hot_state(&block_state_root, cache_state) + .map_err(EnvelopeError::from)? + .ok_or_else(|| { + BeaconChainError::DBInconsistent(format!( + "Missing state for envelope block {block_state_root:?}", + )) + })?; + + Ok(EnvelopeProcessingSnapshot { + pre_state: state, + state_root: block_state_root, + beacon_block_root, + }) +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs new file mode 100644 index 0000000000..df21d33493 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs @@ -0,0 +1,94 @@ +use std::sync::Arc; + +use execution_layer::{NewPayloadRequest, NewPayloadRequestGloas}; +use fork_choice::PayloadVerificationStatus; +use state_processing::per_block_processing::deneb::kzg_commitment_to_versioned_hash; +use tracing::warn; +use types::{SignedBeaconBlock, SignedExecutionPayloadEnvelope}; + +use crate::{ + BeaconChain, BeaconChainTypes, BlockError, NotifyExecutionLayer, + execution_payload::notify_new_payload, payload_envelope_verification::EnvelopeError, +}; + +/// Used to await the result of executing payload with a remote EE. +pub struct PayloadNotifier { + pub chain: Arc>, + envelope: Arc>, + block: Arc>, + payload_verification_status: Option, +} + +impl PayloadNotifier { + pub fn new( + chain: Arc>, + envelope: Arc>, + block: Arc>, + notify_execution_layer: NotifyExecutionLayer, + ) -> Result { + let payload_verification_status = { + let payload_message = &envelope.message; + + match notify_execution_layer { + NotifyExecutionLayer::No if chain.config.optimistic_finalized_sync => { + let new_payload_request = Self::build_new_payload_request(&envelope, &block)?; + // TODO(gloas): check and test RLP block hash calculation post-Gloas + if let Err(e) = new_payload_request.perform_optimistic_sync_verifications() { + warn!( + block_number = ?payload_message.payload.block_number, + info = "you can silence this warning with --disable-optimistic-finalized-sync", + error = ?e, + "Falling back to slow block hash verification" + ); + None + } else { + Some(PayloadVerificationStatus::Optimistic) + } + } + _ => None, + } + }; + + Ok(Self { + chain, + envelope, + block, + payload_verification_status, + }) + } + + pub async fn notify_new_payload(self) -> Result { + if let Some(precomputed_status) = self.payload_verification_status { + Ok(precomputed_status) + } else { + let parent_root = self.block.message().parent_root(); + let request = Self::build_new_payload_request(&self.envelope, &self.block)?; + notify_new_payload(&self.chain, self.envelope.slot(), parent_root, request).await + } + } + + fn build_new_payload_request<'a>( + envelope: &'a SignedExecutionPayloadEnvelope, + block: &'a SignedBeaconBlock, + ) -> Result, BlockError> { + let bid = &block + .message() + .body() + .signed_execution_payload_bid() + .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))? + .message; + + let versioned_hashes = bid + .blob_kzg_commitments + .iter() + .map(kzg_commitment_to_versioned_hash) + .collect(); + + Ok(NewPayloadRequest::Gloas(NewPayloadRequestGloas { + execution_payload: &envelope.message.payload, + versioned_hashes, + parent_beacon_block_root: block.message().parent_root(), + execution_requests: &envelope.message.execution_requests, + })) + } +} diff --git a/beacon_node/beacon_chain/tests/block_verification.rs b/beacon_node/beacon_chain/tests/block_verification.rs index e94e64e91d..e385e0dc48 100644 --- a/beacon_node/beacon_chain/tests/block_verification.rs +++ b/beacon_node/beacon_chain/tests/block_verification.rs @@ -1,5 +1,5 @@ #![cfg(not(debug_assertions))] - +// TODO(gloas) we probably need similar test for payload envelope verification use beacon_chain::block_verification_types::{AsBlock, ExecutedBlock, RpcBlock}; use beacon_chain::data_availability_checker::{AvailabilityCheckError, AvailableBlockData}; use beacon_chain::data_column_verification::CustodyDataColumn; diff --git a/beacon_node/network/src/metrics.rs b/beacon_node/network/src/metrics.rs index 240fd70e01..2119acf946 100644 --- a/beacon_node/network/src/metrics.rs +++ b/beacon_node/network/src/metrics.rs @@ -539,6 +539,16 @@ pub static SYNC_RPC_REQUEST_TIME: LazyLock> = LazyLock::new ) }); +/* + * Execution Payload Envelope Delay Metrics + */ +pub static ENVELOPE_DELAY_GOSSIP: LazyLock> = LazyLock::new(|| { + try_create_int_gauge( + "payload_envelope_delay_gossip", + "The first time we see this payload envelope from gossip as a delay from the start of the slot", + ) +}); + /* * Block Delay Metrics */ diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index e90018c851..3335315157 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4,7 +4,6 @@ use crate::{ service::NetworkMessage, sync::SyncMessage, }; -use beacon_chain::blob_verification::{GossipBlobError, GossipVerifiedBlob}; use beacon_chain::block_verification_types::AsBlock; use beacon_chain::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; use beacon_chain::store::Error; @@ -19,6 +18,10 @@ use beacon_chain::{ sync_committee_verification::{self, Error as SyncCommitteeError}, validator_monitor::{get_block_delay_ms, get_slot_delay_ms}, }; +use beacon_chain::{ + blob_verification::{GossipBlobError, GossipVerifiedBlob}, + payload_envelope_verification::gossip_verified_envelope::GossipVerifiedEnvelope, +}; use beacon_processor::{Work, WorkEvent}; use lighthouse_network::{Client, MessageAcceptance, MessageId, PeerAction, PeerId, ReportSource}; use logging::crit; @@ -3248,25 +3251,166 @@ impl NetworkBeaconProcessor { } } - pub async fn process_gossip_execution_payload( + #[allow(clippy::too_many_arguments)] + #[instrument( + name = "lh_process_execution_payload_envelope", + parent = None, + level = "debug", + skip_all, + fields(beacon_block_root = tracing::field::Empty), + )] + pub async fn process_gossip_execution_payload_envelope( + self: Arc, + message_id: MessageId, + peer_id: PeerId, + envelope: Arc>, + seen_timestamp: Duration, + ) { + if let Some(gossip_verified_envelope) = self + .process_gossip_unverified_execution_payload_envelope( + message_id, + peer_id, + envelope.clone(), + seen_timestamp, + ) + .await + { + let beacon_block_root = gossip_verified_envelope.signed_envelope.beacon_block_root(); + + Span::current().record("beacon_block_root", beacon_block_root.to_string()); + + // TODO(gloas) in process_gossip_block here we check_and_insert on the duplicate cache + // before calling gossip_verified_block. We need this to ensure we dont try to execute the + // payload multiple times. + + self.process_gossip_verified_execution_payload_envelope( + peer_id, + gossip_verified_envelope, + ) + .await; + } + } + + async fn process_gossip_unverified_execution_payload_envelope( self: &Arc, message_id: MessageId, peer_id: PeerId, - execution_payload: SignedExecutionPayloadEnvelope, + envelope: Arc>, + seen_duration: Duration, + ) -> Option> { + let envelope_delay = + get_slot_delay_ms(seen_duration, envelope.slot(), &self.chain.slot_clock); + + let verification_result = self + .chain + .clone() + .verify_envelope_for_gossip(envelope.clone()) + .await; + + let verified_envelope = match verification_result { + Ok(verified_envelope) => { + metrics::set_gauge( + &metrics::ENVELOPE_DELAY_GOSSIP, + envelope_delay.as_millis() as i64, + ); + + // Write the time the envelope was observed into the delay cache. + self.chain.envelope_times_cache.write().set_time_observed( + verified_envelope.signed_envelope.beacon_block_root(), + envelope.slot(), + seen_duration, + Some(peer_id.to_string()), + ); + + info!( + slot = %verified_envelope.signed_envelope.slot(), + root = ?verified_envelope.signed_envelope.beacon_block_root(), + "New envelope received" + ); + + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Accept); + + verified_envelope + } + // TODO(gloas) penalize peers accordingly + Err(_) => return None, + }; + + let envelope_slot = verified_envelope.signed_envelope.slot(); + let beacon_block_root = verified_envelope.signed_envelope.beacon_block_root(); + match self.chain.slot() { + // We only need to do a simple check about the envelope slot vs the current slot because + // `verify_envelope_for_gossip` already ensures that the envelope slot is within tolerance + // for envelope imports. + Ok(current_slot) if envelope_slot > current_slot => { + warn!( + ?envelope_slot, + ?beacon_block_root, + msg = "if this happens consistently, check system clock", + "envelope arrived early" + ); + + // TODO(gloas) update metrics to note how early the envelope arrived + + let inner_self = self.clone(); + let _process_fn = Box::pin(async move { + inner_self + .process_gossip_verified_execution_payload_envelope( + peer_id, + verified_envelope, + ) + .await; + }); + + // TODO(gloas) send to reprocess queue + None + } + Ok(_) => Some(verified_envelope), + Err(e) => { + error!( + error = ?e, + %envelope_slot, + ?beacon_block_root, + location = "envelope gossip", + "Failed to defer envelope import" + ); + None + } + } + } + + async fn process_gossip_verified_execution_payload_envelope( + self: Arc, + _peer_id: PeerId, + verified_envelope: GossipVerifiedEnvelope, ) { - // TODO(EIP-7732): Implement proper execution payload envelope gossip processing. - // This should integrate with the envelope_verification.rs module once it's implemented. + let _processing_start_time = Instant::now(); + let beacon_block_root = verified_envelope.signed_envelope.beacon_block_root(); - trace!( - %peer_id, - builder_index = execution_payload.message.builder_index, - slot = %execution_payload.message.slot, - beacon_block_root = %execution_payload.message.beacon_block_root, - "Processing execution payload envelope" - ); + #[allow(clippy::result_large_err)] + let result = self + .chain + .process_execution_payload_envelope( + beacon_block_root, + verified_envelope, + NotifyExecutionLayer::Yes, + BlockImportSource::Gossip, + || Ok(()), + ) + .await; - // For now, ignore all envelopes since verification is not implemented - self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + // TODO(gloas) metrics + // register_process_result_metrics(&result, metrics::BlockSource::Gossip, "envelope"); + + match &result { + Ok(AvailabilityProcessingStatus::Imported(_)) + | Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => { + // Nothing to do + } + Err(_) => { + // TODO(gloas) implement peer penalties + } + } } pub fn process_gossip_execution_payload_bid( diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index e1adf860de..357d6c08fd 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -429,11 +429,17 @@ impl NetworkBeaconProcessor { message_id: MessageId, peer_id: PeerId, execution_payload: Box>, + seen_timestamp: Duration, ) -> Result<(), Error> { let processor = self.clone(); let process_fn = async move { processor - .process_gossip_execution_payload(message_id, peer_id, *execution_payload) + .process_gossip_execution_payload_envelope( + message_id, + peer_id, + Arc::new(*execution_payload), + seen_timestamp, + ) .await }; diff --git a/beacon_node/network/src/router.rs b/beacon_node/network/src/router.rs index 8373dec322..77d64c92e6 100644 --- a/beacon_node/network/src/router.rs +++ b/beacon_node/network/src/router.rs @@ -493,6 +493,7 @@ impl Router { message_id, peer_id, signed_execution_payload_envelope, + timestamp_now(), ), ) } From 71f6eab51f5c5e58afc9c3f4fbfbca4dfc605025 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 4 Mar 2026 15:50:42 +1100 Subject: [PATCH 053/189] Bump deps --- Cargo.lock | 79 ++++++++++++++++++++++++++---------------------------- Cargo.toml | 9 +++++-- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40c550f4c6..1795de0bc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1909,7 +1909,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -2442,7 +2442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 2.0.111", + "syn 1.0.109", ] [[package]] @@ -3985,11 +3985,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -4996,7 +4996,7 @@ dependencies = [ [[package]] name = "libp2p" version = "0.56.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "bytes", "either", @@ -5027,7 +5027,7 @@ dependencies = [ [[package]] name = "libp2p-allow-block-list" version = "0.6.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "libp2p-connection-limits" version = "0.6.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5047,7 +5047,7 @@ dependencies = [ [[package]] name = "libp2p-core" version = "0.43.2" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "either", "fnv", @@ -5071,7 +5071,7 @@ dependencies = [ [[package]] name = "libp2p-dns" version = "0.44.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "async-trait", "futures", @@ -5086,7 +5086,7 @@ dependencies = [ [[package]] name = "libp2p-gossipsub" version = "0.50.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "async-channel 2.5.0", "asynchronous-codec", @@ -5098,7 +5098,7 @@ dependencies = [ "futures", "futures-timer", "getrandom 0.2.16", - "hashlink 0.10.0", + "hashlink 0.11.0", "hex_fmt", "libp2p-core", "libp2p-identity", @@ -5116,7 +5116,7 @@ dependencies = [ [[package]] name = "libp2p-identify" version = "0.47.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "asynchronous-codec", "either", @@ -5156,7 +5156,7 @@ dependencies = [ [[package]] name = "libp2p-mdns" version = "0.48.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "hickory-proto", @@ -5174,7 +5174,7 @@ dependencies = [ [[package]] name = "libp2p-metrics" version = "0.17.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "libp2p-core", @@ -5190,7 +5190,7 @@ dependencies = [ [[package]] name = "libp2p-mplex" version = "0.43.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "asynchronous-codec", "bytes", @@ -5208,7 +5208,7 @@ dependencies = [ [[package]] name = "libp2p-noise" version = "0.46.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "asynchronous-codec", "bytes", @@ -5230,7 +5230,7 @@ dependencies = [ [[package]] name = "libp2p-quic" version = "0.13.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "futures-timer", @@ -5250,14 +5250,14 @@ dependencies = [ [[package]] name = "libp2p-swarm" -version = "0.47.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +version = "0.47.1" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "either", "fnv", "futures", "futures-timer", - "hashlink 0.10.0", + "hashlink 0.11.0", "libp2p-core", "libp2p-identity", "libp2p-swarm-derive", @@ -5272,7 +5272,7 @@ dependencies = [ [[package]] name = "libp2p-swarm-derive" version = "0.35.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "heck", "quote", @@ -5282,7 +5282,7 @@ dependencies = [ [[package]] name = "libp2p-tcp" version = "0.44.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "futures-timer", @@ -5297,7 +5297,7 @@ dependencies = [ [[package]] name = "libp2p-tls" version = "0.6.2" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "futures-rustls", @@ -5315,7 +5315,7 @@ dependencies = [ [[package]] name = "libp2p-upnp" version = "0.6.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "futures-timer", @@ -5329,7 +5329,7 @@ dependencies = [ [[package]] name = "libp2p-yamux" version = "0.47.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "either", "futures", @@ -6021,7 +6021,7 @@ dependencies = [ [[package]] name = "multistream-select" version = "0.13.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "bytes", "futures", @@ -7129,7 +7129,7 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-protobuf" version = "0.8.1" -source = "git+https://github.com/sigp/quick-protobuf.git?rev=681f413312404ab6e51f0b46f39b0075c6f4ebfd#681f413312404ab6e51f0b46f39b0075c6f4ebfd" +source = "git+https://github.com/sigp/quick-protobuf.git?rev=87c4ccb9bb2af494de375f5f6c62850badd26304#87c4ccb9bb2af494de375f5f6c62850badd26304" dependencies = [ "byteorder", ] @@ -7137,7 +7137,7 @@ dependencies = [ [[package]] name = "quick-protobuf-codec" version = "0.3.1" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "asynchronous-codec", "bytes", @@ -7149,8 +7149,7 @@ dependencies = [ [[package]] name = "quinn" version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +source = "git+https://github.com/sigp/quinn?rev=59af87979c8411864c1cb68613222f54ed2930a7#59af87979c8411864c1cb68613222f54ed2930a7" dependencies = [ "bytes", "cfg_aliases", @@ -7160,7 +7159,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.35", - "socket2 0.6.1", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -7170,8 +7169,7 @@ dependencies = [ [[package]] name = "quinn-proto" version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +source = "git+https://github.com/sigp/quinn?rev=59af87979c8411864c1cb68613222f54ed2930a7#59af87979c8411864c1cb68613222f54ed2930a7" dependencies = [ "bytes", "getrandom 0.3.4", @@ -7191,15 +7189,14 @@ dependencies = [ [[package]] name = "quinn-udp" version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +source = "git+https://github.com/sigp/quinn?rev=59af87979c8411864c1cb68613222f54ed2930a7#59af87979c8411864c1cb68613222f54ed2930a7" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -7822,7 +7819,7 @@ dependencies = [ [[package]] name = "rw-stream-sink" version = "0.4.0" -source = "git+https://github.com/libp2p/rust-libp2p.git#5e3519fb66b92c7f7c0dc744ab360fd8b669fe54" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" dependencies = [ "futures", "pin-project", @@ -10132,7 +10129,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -10607,7 +10604,7 @@ dependencies = [ [[package]] name = "yamux" version = "0.13.8" -source = "git+https://github.com/sigp/rust-yamux?rev=575b17c0f44f4253079a6bafaa2de74ca1d6dfaa#575b17c0f44f4253079a6bafaa2de74ca1d6dfaa" +source = "git+https://github.com/sigp/rust-yamux?rev=29efa6aebd4bdfcb16bfb21969ec0c785e570b74#29efa6aebd4bdfcb16bfb21969ec0c785e570b74" dependencies = [ "futures", "log", diff --git a/Cargo.toml b/Cargo.toml index 5f6f43d2f2..ab33cb6310 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -302,5 +302,10 @@ inherits = "release" debug = true [patch.crates-io] -quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "681f413312404ab6e51f0b46f39b0075c6f4ebfd" } -yamux = { git = "https://github.com/sigp/rust-yamux", rev = "575b17c0f44f4253079a6bafaa2de74ca1d6dfaa" } +quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "87c4ccb9bb2af494de375f5f6c62850badd26304" } +yamux = { git = "https://github.com/sigp/rust-yamux", rev = "29efa6aebd4bdfcb16bfb21969ec0c785e570b74" } +quinn = { git = "https://github.com/sigp/quinn", rev = "59af87979c8411864c1cb68613222f54ed2930a7" } + +[patch."https://github.com/libp2p/rust-libp2p.git"] +libp2p = { git = "https://github.com/sigp/rust-libp2p.git", rev = "f88e43de9eba00b416d0374b1a1fb2de47b65864" } +libp2p-mplex = { git = "https://github.com/sigp/rust-libp2p.git", rev = "f88e43de9eba00b416d0374b1a1fb2de47b65864" } From ac1db1d2e23f849f7937bbc38cb9c85445d837dc Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:48:30 +0800 Subject: [PATCH 054/189] update cargo-sort (#8933) Co-Authored-By: Tan Chee Keong --- Cargo.toml | 30 +++--------------------------- common/logging/Cargo.toml | 2 +- common/malloc_utils/Cargo.toml | 5 +---- 3 files changed, 5 insertions(+), 32 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ab33cb6310..82db6dbfc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,20 +166,7 @@ initialized_validators = { path = "validator_client/initialized_validators" } int_to_bytes = { path = "consensus/int_to_bytes" } itertools = "0.10" kzg = { path = "crypto/kzg" } -libp2p = { git = "https://github.com/libp2p/rust-libp2p.git", default-features = false, features = [ - "identify", - "yamux", - "noise", - "dns", - "tcp", - "tokio", - "secp256k1", - "macros", - "metrics", - "quic", - "upnp", - "gossipsub", -] } +libp2p = { git = "https://github.com/libp2p/rust-libp2p.git", default-features = false, features = ["identify", "yamux", "noise", "dns", "tcp", "tokio", "secp256k1", "macros", "metrics", "quic", "upnp", "gossipsub"] } libsecp256k1 = "0.7" lighthouse_network = { path = "beacon_node/lighthouse_network" } lighthouse_validator_store = { path = "validator_client/lighthouse_validator_store" } @@ -219,13 +206,7 @@ r2d2 = "0.8" rand = "0.9.0" rayon = "1.7" regex = "1" -reqwest = { version = "0.12", default-features = false, features = [ - "blocking", - "json", - "stream", - "rustls-tls", - "native-tls-vendored", -] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "stream", "rustls-tls", "native-tls-vendored"] } ring = "0.17" rpds = "0.11" rusqlite = { version = "0.28", features = ["bundled"] } @@ -254,12 +235,7 @@ sysinfo = "0.26" system_health = { path = "common/system_health" } task_executor = { path = "common/task_executor" } tempfile = "3" -tokio = { version = "1", features = [ - "rt-multi-thread", - "sync", - "signal", - "macros", -] } +tokio = { version = "1", features = ["rt-multi-thread", "sync", "signal", "macros"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = { version = "0.7", features = ["codec", "compat", "time"] } tracing = "0.1.40" diff --git a/common/logging/Cargo.toml b/common/logging/Cargo.toml index 41c82dbd61..cbebd1a501 100644 --- a/common/logging/Cargo.toml +++ b/common/logging/Cargo.toml @@ -13,7 +13,7 @@ logroller = { workspace = true } metrics = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tokio = { workspace = true, features = [ "time" ] } +tokio = { workspace = true, features = ["time"] } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-core = { workspace = true } diff --git a/common/malloc_utils/Cargo.toml b/common/malloc_utils/Cargo.toml index 1052128852..e90490bf09 100644 --- a/common/malloc_utils/Cargo.toml +++ b/common/malloc_utils/Cargo.toml @@ -35,7 +35,4 @@ tikv-jemallocator = { version = "0.6.0", optional = true, features = ["stats"] } # Jemalloc's background_threads feature requires Linux (pthreads). [target.'cfg(target_os = "linux")'.dependencies] -tikv-jemallocator = { version = "0.6.0", optional = true, features = [ - "stats", - "background_threads", -] } +tikv-jemallocator = { version = "0.6.0", optional = true, features = ["stats", "background_threads"] } From 5a174f2a00b33d4905bcc241749b96965541132a Mon Sep 17 00:00:00 2001 From: Mac L Date: Fri, 6 Mar 2026 09:54:43 +0200 Subject: [PATCH 055/189] Fix lints for Rust v1.94.0 (#8939) Following the release of Rust v1.94.0 there are new Clippy lints which do not pass and are blocking CI (which pulls in the latest version of Rust) This is pretty much the minimum just to get CI running again. Most of the errors involve error types being too large. For now I've added allows but later it might be worth doing a refactor to `Box` or otherwise remove the problematic error types. Co-Authored-By: Mac L --- beacon_node/beacon_chain/tests/attestation_verification.rs | 1 + beacon_node/beacon_chain/tests/payload_invalidation.rs | 1 + beacon_node/beacon_chain/tests/store_tests.rs | 1 + beacon_node/execution_layer/src/lib.rs | 2 +- beacon_node/http_api/src/lib.rs | 1 + slasher/service/src/lib.rs | 1 + 6 files changed, 6 insertions(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index 208798dfdf..9553965ae6 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -1,4 +1,5 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::attestation_verification::{ Error, batch_verify_aggregated_attestations, batch_verify_unaggregated_attestations, diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index 1204412d65..11c916e850 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1,4 +1,5 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::block_verification_types::RpcBlock; use beacon_chain::{ diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index ea5f735bde..e618873bdd 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -1,4 +1,5 @@ #![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] use beacon_chain::attestation_verification::Error as AttnError; use beacon_chain::block_verification_types::RpcBlock; diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 33b83aab09..024c6805b9 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -2205,7 +2205,7 @@ fn verify_builder_bid( .cloned() .map(|withdrawals| { Withdrawals::::try_from(withdrawals) - .map_err(InvalidBuilderPayload::SszTypesError) + .map_err(|e| Box::new(InvalidBuilderPayload::SszTypesError(e))) .map(|w| w.tree_hash_root()) }) .transpose()?; diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 095c52fb29..69aa7cd91f 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::result_large_err)] //! This crate contains a HTTP server which serves the endpoints listed here: //! //! https://github.com/ethereum/beacon-APIs diff --git a/slasher/service/src/lib.rs b/slasher/service/src/lib.rs index ac15b49ee9..69ec59aa2c 100644 --- a/slasher/service/src/lib.rs +++ b/slasher/service/src/lib.rs @@ -1,3 +1,4 @@ +#![allow(clippy::result_large_err)] mod service; pub use service::SlasherService; From 6a92761f441e5a3a9169454df11025cb1a32d751 Mon Sep 17 00:00:00 2001 From: Akihito Nakano Date: Sat, 7 Mar 2026 08:09:33 +0900 Subject: [PATCH 056/189] Fix cargo-sort errors (#8945) The `cargo-sort` job in CI is [failing](https://github.com/sigp/lighthouse/actions/runs/22781651620/job/66088700318?pr=8932) since [cargo-sort v2.1.1](https://github.com/DevinR528/cargo-sort/releases/tag/v2.1.1) has been released, which reports new errors for our Cargo.toml files. Ran `cargo-sort` formatter locally with the new version. Co-Authored-By: ackintosh --- account_manager/Cargo.toml | 5 +---- beacon_node/Cargo.toml | 13 +++++-------- beacon_node/beacon_chain/Cargo.toml | 9 ++++++--- common/logging/Cargo.toml | 3 ++- consensus/types/Cargo.toml | 5 +---- 5 files changed, 15 insertions(+), 20 deletions(-) diff --git a/account_manager/Cargo.toml b/account_manager/Cargo.toml index 8dd50cbc6e..05e6f12554 100644 --- a/account_manager/Cargo.toml +++ b/account_manager/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "account_manager" version = { workspace = true } -authors = [ - "Paul Hauner ", - "Luke Anderson ", -] +authors = ["Paul Hauner ", "Luke Anderson "] edition = { workspace = true } [dependencies] diff --git a/beacon_node/Cargo.toml b/beacon_node/Cargo.toml index 5352814dd5..ebefa6a451 100644 --- a/beacon_node/Cargo.toml +++ b/beacon_node/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "beacon_node" version = { workspace = true } -authors = [ - "Paul Hauner ", - "Age Manning ", "Age Manning "] edition = { workspace = true } [features] -test_logger = [] # Print log output to stderr when running tests instead of dropping it +# Print log output to stderr when running tests instead of dropping it. +test_logger = [] [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } diff --git a/consensus/types/Cargo.toml b/consensus/types/Cargo.toml index feea855c84..e5c5662d71 100644 --- a/consensus/types/Cargo.toml +++ b/consensus/types/Cargo.toml @@ -1,10 +1,7 @@ [package] name = "types" version = "0.2.1" -authors = [ - "Paul Hauner ", - "Age Manning ", -] +authors = ["Paul Hauner ", "Age Manning "] edition = { workspace = true } [features] From 3deab9b0410233c1d57bddfaa9903cc6fbdaa958 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 9 Mar 2026 11:27:08 +1100 Subject: [PATCH 057/189] Release v8.1.2 --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1795de0bc1..cba93f2fd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "account_manager" -version = "8.1.1" +version = "8.1.2" dependencies = [ "account_utils", "bls", @@ -1276,7 +1276,7 @@ dependencies = [ [[package]] name = "beacon_node" -version = "8.1.1" +version = "8.1.2" dependencies = [ "account_utils", "beacon_chain", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "boot_node" -version = "8.1.1" +version = "8.1.2" dependencies = [ "beacon_node", "bytes", @@ -4897,7 +4897,7 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "lcli" -version = "8.1.1" +version = "8.1.2" dependencies = [ "account_utils", "beacon_chain", @@ -5383,7 +5383,7 @@ dependencies = [ [[package]] name = "lighthouse" -version = "8.1.1" +version = "8.1.2" dependencies = [ "account_manager", "account_utils", @@ -5515,7 +5515,7 @@ dependencies = [ [[package]] name = "lighthouse_version" -version = "8.1.1" +version = "8.1.2" dependencies = [ "regex", ] @@ -9619,7 +9619,7 @@ dependencies = [ [[package]] name = "validator_client" -version = "8.1.1" +version = "8.1.2" dependencies = [ "account_utils", "beacon_node_fallback", diff --git a/Cargo.toml b/Cargo.toml index 82db6dbfc4..f483e998c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ resolver = "2" [workspace.package] edition = "2024" -version = "8.1.1" +version = "8.1.2" [workspace.dependencies] account_utils = { path = "common/account_utils" } From 9f3873f2bf242440d1ee8e06d078ea189b73e53b Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 10 Mar 2026 16:49:35 +1100 Subject: [PATCH 058/189] Fix syn in Cargo.lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index d96021aaea..704039a175 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2461,7 +2461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.111", ] [[package]] From 081229b7488e37d472f768a3908a0c37fb320d7c Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 10 Mar 2026 18:57:51 +1100 Subject: [PATCH 059/189] Implement proposer duties v2 endpoint (#8918) Fix the issue with the `proposer_duties` endpoint using the wrong dependent root post-Fulu by implementing the new v2 endpoint: - https://github.com/ethereum/beacon-APIs/pull/563 We need to add this in time for Gloas, and then we can we can deprecate and remove v1. - Add a new API handler for the v2 endpoint - Add client code in the `eth2` crate - Update existing tests and add some new ones to confirm the different behaviour of v1 and v2 There's a bit of test duplication with v1, but this will be resolved once v1 and its tests are deleted. Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul Co-Authored-By: chonghe <44791194+chong-he@users.noreply.github.com> --- beacon_node/http_api/src/lib.rs | 3 +- beacon_node/http_api/src/proposer_duties.rs | 99 ++++++-- beacon_node/http_api/src/validator/mod.rs | 17 +- .../http_api/tests/interactive_tests.rs | 234 ++++++++++++++++++ beacon_node/http_api/tests/tests.rs | 99 ++++++++ common/eth2/src/lib.rs | 18 ++ 6 files changed, 438 insertions(+), 32 deletions(-) diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 0a0ae683ca..b5b74a3840 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -263,6 +263,7 @@ pub fn prometheus_metrics() -> warp::filters::log::Log( // GET validator/duties/proposer/{epoch} let get_validator_duties_proposer = get_validator_duties_proposer( - eth_v1.clone(), + any_version.clone(), chain_filter.clone(), not_while_syncing_filter.clone(), task_spawner_filter.clone(), diff --git a/beacon_node/http_api/src/proposer_duties.rs b/beacon_node/http_api/src/proposer_duties.rs index 1ebb174785..0b0926f955 100644 --- a/beacon_node/http_api/src/proposer_duties.rs +++ b/beacon_node/http_api/src/proposer_duties.rs @@ -13,13 +13,45 @@ use slot_clock::SlotClock; use tracing::debug; use types::{Epoch, EthSpec, Hash256, Slot}; +/// Selects which dependent root to return in the API response. +/// +/// - `Legacy`: the block root at the last slot of epoch N-1 (v1 behaviour, for backwards compat). +/// - `True`: the fork-aware proposer shuffling decision root (v2 behaviour). Pre-Fulu this equals +/// the legacy root; post-Fulu it uses epoch N-2. +#[derive(Clone, Copy, PartialEq, Eq)] +enum DependentRootSelection { + Legacy, + True, +} + /// The struct that is returned to the requesting HTTP client. type ApiDuties = api_types::DutiesResponse>; -/// Handles a request from the HTTP API for proposer duties. +/// Handles a request from the HTTP API for v1 proposer duties. +/// +/// Returns the legacy dependent root (block root at end of epoch N-1) for backwards compatibility. pub fn proposer_duties( request_epoch: Epoch, chain: &BeaconChain, +) -> Result { + proposer_duties_internal(request_epoch, chain, DependentRootSelection::Legacy) +} + +/// Handles a request from the HTTP API for v2 proposer duties. +/// +/// Returns the true fork-aware dependent root. Pre-Fulu this equals the legacy root; post-Fulu it +/// uses epoch N-2 due to deterministic proposer lookahead with `min_seed_lookahead`. +pub fn proposer_duties_v2( + request_epoch: Epoch, + chain: &BeaconChain, +) -> Result { + proposer_duties_internal(request_epoch, chain, DependentRootSelection::True) +} + +fn proposer_duties_internal( + request_epoch: Epoch, + chain: &BeaconChain, + root_selection: DependentRootSelection, ) -> Result { let current_epoch = chain .slot_clock @@ -49,24 +81,29 @@ pub fn proposer_duties( if request_epoch == current_epoch || request_epoch == tolerant_current_epoch { // If we could consider ourselves in the `request_epoch` when allowing for clock disparity // tolerance then serve this request from the cache. - if let Some(duties) = try_proposer_duties_from_cache(request_epoch, chain)? { + if let Some(duties) = try_proposer_duties_from_cache(request_epoch, chain, root_selection)? + { Ok(duties) } else { debug!(%request_epoch, "Proposer cache miss"); - compute_and_cache_proposer_duties(request_epoch, chain) + compute_and_cache_proposer_duties(request_epoch, chain, root_selection) } } else if request_epoch == current_epoch .safe_add(1) .map_err(warp_utils::reject::arith_error)? { - let (proposers, _dependent_root, legacy_dependent_root, execution_status, _fork) = + let (proposers, dependent_root, legacy_dependent_root, execution_status, _fork) = compute_proposer_duties_from_head(request_epoch, chain) .map_err(warp_utils::reject::unhandled_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => legacy_dependent_root, + DependentRootSelection::True => dependent_root, + }; convert_to_api_response( chain, request_epoch, - legacy_dependent_root, + selected_root, execution_status.is_optimistic_or_invalid(), proposers, ) @@ -84,7 +121,7 @@ pub fn proposer_duties( // request_epoch < current_epoch // // Queries about the past are handled with a slow path. - compute_historic_proposer_duties(request_epoch, chain) + compute_historic_proposer_duties(request_epoch, chain, root_selection) } } @@ -98,6 +135,7 @@ pub fn proposer_duties( fn try_proposer_duties_from_cache( request_epoch: Epoch, chain: &BeaconChain, + root_selection: DependentRootSelection, ) -> Result, warp::reject::Rejection> { let head = chain.canonical_head.cached_head(); let head_block = &head.snapshot.beacon_block; @@ -116,11 +154,14 @@ fn try_proposer_duties_from_cache( .beacon_state .proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root, &chain.spec) .map_err(warp_utils::reject::beacon_state_error)?; - let legacy_dependent_root = head - .snapshot - .beacon_state - .legacy_proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root) - .map_err(warp_utils::reject::beacon_state_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => head + .snapshot + .beacon_state + .legacy_proposer_shuffling_decision_root_at_epoch(request_epoch, head_block_root) + .map_err(warp_utils::reject::beacon_state_error)?, + DependentRootSelection::True => head_decision_root, + }; let execution_optimistic = chain .is_optimistic_or_invalid_head_block(head_block) .map_err(warp_utils::reject::unhandled_error)?; @@ -134,7 +175,7 @@ fn try_proposer_duties_from_cache( convert_to_api_response( chain, request_epoch, - legacy_dependent_root, + selected_root, execution_optimistic, indices.to_vec(), ) @@ -155,6 +196,7 @@ fn try_proposer_duties_from_cache( fn compute_and_cache_proposer_duties( current_epoch: Epoch, chain: &BeaconChain, + root_selection: DependentRootSelection, ) -> Result { let (indices, dependent_root, legacy_dependent_root, execution_status, fork) = compute_proposer_duties_from_head(current_epoch, chain) @@ -168,10 +210,14 @@ fn compute_and_cache_proposer_duties( .map_err(BeaconChainError::from) .map_err(warp_utils::reject::unhandled_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => legacy_dependent_root, + DependentRootSelection::True => dependent_root, + }; convert_to_api_response( chain, current_epoch, - legacy_dependent_root, + selected_root, execution_status.is_optimistic_or_invalid(), indices, ) @@ -182,6 +228,7 @@ fn compute_and_cache_proposer_duties( fn compute_historic_proposer_duties( epoch: Epoch, chain: &BeaconChain, + root_selection: DependentRootSelection, ) -> Result { // If the head is quite old then it might still be relevant for a historical request. // @@ -219,9 +266,9 @@ fn compute_historic_proposer_duties( }; // Ensure the state lookup was correct. - if state.current_epoch() != epoch { + if state.current_epoch() != epoch && state.current_epoch() + 1 != epoch { return Err(warp_utils::reject::custom_server_error(format!( - "state epoch {} not equal to request epoch {}", + "state from epoch {} cannot serve request epoch {}", state.current_epoch(), epoch ))); @@ -234,18 +281,18 @@ fn compute_historic_proposer_duties( // We can supply the genesis block root as the block root since we know that the only block that // decides its own root is the genesis block. - let legacy_dependent_root = state - .legacy_proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root) - .map_err(BeaconChainError::from) - .map_err(warp_utils::reject::unhandled_error)?; + let selected_root = match root_selection { + DependentRootSelection::Legacy => state + .legacy_proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root) + .map_err(BeaconChainError::from) + .map_err(warp_utils::reject::unhandled_error)?, + DependentRootSelection::True => state + .proposer_shuffling_decision_root_at_epoch(epoch, chain.genesis_block_root, &chain.spec) + .map_err(BeaconChainError::from) + .map_err(warp_utils::reject::unhandled_error)?, + }; - convert_to_api_response( - chain, - epoch, - legacy_dependent_root, - execution_optimistic, - indices, - ) + convert_to_api_response(chain, epoch, selected_root, execution_optimistic, indices) } /// Converts the internal representation of proposer duties into one that is compatible with the diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index a9082df715..3d96b85870 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -6,7 +6,7 @@ use crate::utils::{ AnyVersionFilter, ChainFilter, EthV1Filter, NetworkTxFilter, NotWhileSyncingFilter, ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message, }; -use crate::version::V3; +use crate::version::{V1, V2, V3, unsupported_version_rejection}; use crate::{StateId, attester_duties, proposer_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::validator_monitor::timestamp_now; @@ -971,12 +971,12 @@ pub fn post_validator_aggregate_and_proofs( // GET validator/duties/proposer/{epoch} pub fn get_validator_duties_proposer( - eth_v1: EthV1Filter, + any_version: AnyVersionFilter, chain_filter: ChainFilter, not_while_syncing_filter: NotWhileSyncingFilter, task_spawner_filter: TaskSpawnerFilter, ) -> ResponseFilter { - eth_v1 + any_version .and(warp::path("validator")) .and(warp::path("duties")) .and(warp::path("proposer")) @@ -990,13 +990,20 @@ pub fn get_validator_duties_proposer( .and(task_spawner_filter) .and(chain_filter) .then( - |epoch: Epoch, + |endpoint_version: EndpointVersion, + epoch: Epoch, not_synced_filter: Result<(), Rejection>, task_spawner: TaskSpawner, chain: Arc>| { task_spawner.blocking_json_task(Priority::P0, move || { not_synced_filter?; - proposer_duties::proposer_duties(epoch, &chain) + if endpoint_version == V1 { + proposer_duties::proposer_duties(epoch, &chain) + } else if endpoint_version == V2 { + proposer_duties::proposer_duties_v2(epoch, &chain) + } else { + Err(unsupported_version_rejection(endpoint_version)) + } }) }, ) diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index a18dd10464..e0e4029875 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -1053,6 +1053,240 @@ async fn proposer_duties_with_gossip_tolerance() { ); } +// Test that a request for next epoch v2 proposer duties succeeds when the current slot clock is +// within gossip clock disparity (500ms) of the new epoch. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn proposer_duties_v2_with_gossip_tolerance() { + let validator_count = 24; + + let tester = InteractiveTester::::new(None, validator_count).await; + let harness = &tester.harness; + let spec = &harness.spec; + let client = &tester.client; + + let num_initial = 4 * E::slots_per_epoch() - 1; + let next_epoch_start_slot = Slot::new(num_initial + 1); + + harness.advance_slot(); + harness + .extend_chain_with_sync( + num_initial as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + SyncCommitteeStrategy::NoValidators, + LightClientStrategy::Disabled, + ) + .await; + + assert_eq!(harness.chain.slot().unwrap(), num_initial); + + // Set the clock to just before the next epoch. + harness.chain.slot_clock.advance_time( + Duration::from_secs(spec.seconds_per_slot) - spec.maximum_gossip_clock_disparity(), + ); + assert_eq!( + harness + .chain + .slot_clock + .now_with_future_tolerance(spec.maximum_gossip_clock_disparity()) + .unwrap(), + next_epoch_start_slot + ); + + let head_state = harness.get_current_state(); + let head_block_root = harness.head_block_root(); + let tolerant_current_epoch = next_epoch_start_slot.epoch(E::slots_per_epoch()); + + // Prime the proposer shuffling cache with an incorrect entry (regression test). + let wrong_decision_root = head_state + .proposer_shuffling_decision_root(head_block_root, spec) + .unwrap(); + let wrong_proposer_indices = vec![0; E::slots_per_epoch() as usize]; + harness + .chain + .beacon_proposer_cache + .lock() + .insert( + tolerant_current_epoch, + wrong_decision_root, + wrong_proposer_indices.clone(), + head_state.fork(), + ) + .unwrap(); + + // Request the v2 proposer duties. + let proposer_duties_tolerant_current_epoch = client + .get_validator_duties_proposer_v2(tolerant_current_epoch) + .await + .unwrap(); + + assert_eq!( + proposer_duties_tolerant_current_epoch.dependent_root, + head_state + .proposer_shuffling_decision_root_at_epoch( + tolerant_current_epoch, + head_block_root, + spec, + ) + .unwrap() + ); + assert_ne!( + proposer_duties_tolerant_current_epoch + .data + .iter() + .map(|data| data.validator_index as usize) + .collect::>(), + wrong_proposer_indices, + ); + + // We should get the exact same result after properly advancing into the epoch. + harness + .chain + .slot_clock + .advance_time(spec.maximum_gossip_clock_disparity()); + assert_eq!(harness.chain.slot().unwrap(), next_epoch_start_slot); + let proposer_duties_current_epoch = client + .get_validator_duties_proposer_v2(tolerant_current_epoch) + .await + .unwrap(); + + assert_eq!( + proposer_duties_tolerant_current_epoch, + proposer_duties_current_epoch + ); +} + +// Test that post-Fulu, v1 and v2 proposer duties return different dependent roots. +// Post-Fulu, the true dependent root shifts to the block root at the end of epoch N-2 (due to +// `min_seed_lookahead`), while the legacy v1 root remains at the end of epoch N-1. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn proposer_duties_v2_post_fulu_dependent_root() { + type E = MinimalEthSpec; + let spec = test_spec::(); + + if !spec.is_fulu_scheduled() { + return; + } + + let validator_count = 24; + let slots_per_epoch = E::slots_per_epoch(); + + let tester = InteractiveTester::::new(Some(spec.clone()), validator_count).await; + let harness = &tester.harness; + let client = &tester.client; + let mock_el = harness.mock_execution_layer.as_ref().unwrap(); + mock_el.server.all_payloads_valid(); + + // Build 3 full epochs of chain so we're in epoch 3. + let num_slots = 3 * slots_per_epoch; + harness.advance_slot(); + harness + .extend_chain_with_sync( + num_slots as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + SyncCommitteeStrategy::AllValidators, + LightClientStrategy::Disabled, + ) + .await; + + let current_epoch = harness.chain.epoch().unwrap(); + assert_eq!(current_epoch, Epoch::new(3)); + + // For epoch 3 with min_seed_lookahead=1: + // Post-Fulu decision slot: end of epoch N-2 = end of epoch 1 = slot 15 + // Legacy decision slot: end of epoch N-1 = end of epoch 2 = slot 23 + let true_decision_slot = Epoch::new(1).end_slot(slots_per_epoch); + let legacy_decision_slot = Epoch::new(2).end_slot(slots_per_epoch); + assert_eq!(true_decision_slot, Slot::new(15)); + assert_eq!(legacy_decision_slot, Slot::new(23)); + + // Fetch the block roots at these slots to compute expected dependent roots. + let expected_v2_root = harness + .chain + .block_root_at_slot(true_decision_slot, beacon_chain::WhenSlotSkipped::Prev) + .unwrap() + .unwrap(); + let expected_v1_root = harness + .chain + .block_root_at_slot(legacy_decision_slot, beacon_chain::WhenSlotSkipped::Prev) + .unwrap() + .unwrap(); + + // Sanity check: the two roots should be different since they refer to different blocks. + assert_ne!( + expected_v1_root, expected_v2_root, + "legacy and true decision roots should differ post-Fulu" + ); + + // Query v1 and v2 proposer duties for the current epoch. + let v1_result = client + .get_validator_duties_proposer(current_epoch) + .await + .unwrap(); + let v2_result = client + .get_validator_duties_proposer_v2(current_epoch) + .await + .unwrap(); + + // The proposer assignments (data) must be identical. + assert_eq!(v1_result.data, v2_result.data); + + // The dependent roots must differ. + assert_ne!( + v1_result.dependent_root, v2_result.dependent_root, + "v1 and v2 dependent roots should differ post-Fulu" + ); + + // Verify each root matches the expected value. + assert_eq!( + v1_result.dependent_root, expected_v1_root, + "v1 dependent root should be block root at end of epoch N-1" + ); + assert_eq!( + v2_result.dependent_root, expected_v2_root, + "v2 dependent root should be block root at end of epoch N-2" + ); + + // Also verify the next-epoch path (epoch 4). + let next_epoch = current_epoch + 1; + let v1_next = client + .get_validator_duties_proposer(next_epoch) + .await + .unwrap(); + let v2_next = client + .get_validator_duties_proposer_v2(next_epoch) + .await + .unwrap(); + + assert_eq!(v1_next.data, v2_next.data); + assert_ne!( + v1_next.dependent_root, v2_next.dependent_root, + "v1 and v2 next-epoch dependent roots should differ post-Fulu" + ); + + // For epoch 4: true decision is end of epoch 2 (slot 23), legacy is end of epoch 3 (slot 31). + let expected_v2_next_root = harness + .chain + .block_root_at_slot( + Epoch::new(2).end_slot(slots_per_epoch), + beacon_chain::WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap(); + let expected_v1_next_root = harness + .chain + .block_root_at_slot( + Epoch::new(3).end_slot(slots_per_epoch), + beacon_chain::WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap_or(harness.head_block_root()); + assert_eq!(v1_next.dependent_root, expected_v1_next_root); + assert_eq!(v2_next.dependent_root, expected_v2_next_root); + assert_ne!(expected_v2_next_root, harness.head_block_root()); +} + // Test that a request to `lighthouse/custody/backfill` succeeds by verifying that `CustodyContext` and `DataColumnCustodyInfo` // have been updated with the correct values. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 6696e109a5..739c39aa32 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -3392,6 +3392,80 @@ impl ApiTester { self } + pub async fn test_get_validator_duties_proposer_v2(self) -> Self { + let current_epoch = self.chain.epoch().unwrap(); + + for epoch in 0..=current_epoch.as_u64() + 1 { + let epoch = Epoch::from(epoch); + + // Compute the true dependent root using the spec's decision slot. + let decision_slot = self.chain.spec.proposer_shuffling_decision_slot::(epoch); + let dependent_root = self + .chain + .block_root_at_slot(decision_slot, WhenSlotSkipped::Prev) + .unwrap() + .unwrap_or(self.chain.head_beacon_block_root()); + + let result = self + .client + .get_validator_duties_proposer_v2(epoch) + .await + .unwrap(); + + let mut state = self + .chain + .state_at_slot( + epoch.start_slot(E::slots_per_epoch()), + StateSkipConfig::WithStateRoots, + ) + .unwrap(); + + state + .build_committee_cache(RelativeEpoch::Current, &self.chain.spec) + .unwrap(); + + let expected_duties = epoch + .slot_iter(E::slots_per_epoch()) + .map(|slot| { + let index = state + .get_beacon_proposer_index(slot, &self.chain.spec) + .unwrap(); + let pubkey = state.validators().get(index).unwrap().pubkey; + + ProposerData { + pubkey, + validator_index: index as u64, + slot, + } + }) + .collect::>(); + + let expected = DutiesResponse { + data: expected_duties, + execution_optimistic: Some(false), + dependent_root, + }; + + assert_eq!(result, expected); + + // v1 and v2 should return the same data. + let v1_result = self + .client + .get_validator_duties_proposer(epoch) + .await + .unwrap(); + assert_eq!(result.data, v1_result.data); + } + + // Requests to the epochs after the next epoch should fail. + self.client + .get_validator_duties_proposer_v2(current_epoch + 2) + .await + .unwrap_err(); + + self + } + pub async fn test_get_validator_duties_early(self) -> Self { let current_epoch = self.chain.epoch().unwrap(); let next_epoch = current_epoch + 1; @@ -7617,6 +7691,31 @@ async fn get_validator_duties_proposer_with_skip_slots() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_proposer_v2() { + ApiTester::new_from_config(ApiTesterConfig { + spec: test_spec::(), + retain_historic_states: true, + ..ApiTesterConfig::default() + }) + .await + .test_get_validator_duties_proposer_v2() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_proposer_v2_with_skip_slots() { + ApiTester::new_from_config(ApiTesterConfig { + spec: test_spec::(), + retain_historic_states: true, + ..ApiTesterConfig::default() + }) + .await + .skip_slots(E::slots_per_epoch() * 2) + .test_get_validator_duties_proposer_v2() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn block_production() { ApiTester::new().await.test_block_production().await; diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index ac96da6173..628c12981a 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -2144,6 +2144,24 @@ impl BeaconNodeHttpClient { .await } + /// `GET v2/validator/duties/proposer/{epoch}` + pub async fn get_validator_duties_proposer_v2( + &self, + epoch: Epoch, + ) -> Result>, Error> { + let mut path = self.eth_path(V2)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("duties") + .push("proposer") + .push(&epoch.to_string()); + + self.get_with_timeout(path, self.timeouts.proposer_duties) + .await + } + /// `GET v2/validator/blocks/{slot}` pub async fn get_validator_blocks( &self, From 906400ed3435fffebaf5a56b60b778dbe3883ec1 Mon Sep 17 00:00:00 2001 From: Romeo Date: Tue, 10 Mar 2026 14:36:58 +0100 Subject: [PATCH 060/189] Implement proposer lookahead endpoint (#8815) closes #8809 Implement GET /eth/v1/beacon/states/{state_id}/proposer_lookahead ([beacon-APIs#565](https://github.com/ethereum/beacon-APIs/pull/565)). Returns the proposer lookahead from Fulu+ states; 400 for pre-Fulu. Includes integration test. Co-Authored-By: romeoscript Co-Authored-By: Tan Chee Keong --- beacon_node/http_api/src/beacon/states.rs | 68 +++++++++++++++++- beacon_node/http_api/src/lib.rs | 5 ++ beacon_node/http_api/tests/tests.rs | 85 ++++++++++++++++++++++- common/eth2/src/lib.rs | 41 +++++++++++ 4 files changed, 196 insertions(+), 3 deletions(-) diff --git a/beacon_node/http_api/src/beacon/states.rs b/beacon_node/http_api/src/beacon/states.rs index 50be7211d8..02ac3f4da7 100644 --- a/beacon_node/http_api/src/beacon/states.rs +++ b/beacon_node/http_api/src/beacon/states.rs @@ -3,17 +3,20 @@ use crate::task_spawner::{Priority, TaskSpawner}; use crate::utils::ResponseFilter; use crate::validator::pubkey_to_validator_index; use crate::version::{ - ResponseIncludesVersion, add_consensus_version_header, + ResponseIncludesVersion, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, }; use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, WhenSlotSkipped}; use eth2::types::{ - ValidatorBalancesRequestBody, ValidatorId, ValidatorIdentitiesRequestBody, + self as api_types, ValidatorBalancesRequestBody, ValidatorId, ValidatorIdentitiesRequestBody, ValidatorsRequestBody, }; +use ssz::Encode; use std::sync::Arc; use types::{AttestationShufflingId, BeaconStateError, CommitteeCache, EthSpec, RelativeEpoch}; use warp::filters::BoxedFilter; +use warp::http::Response; +use warp::hyper::Body; use warp::{Filter, Reply}; use warp_utils::query::multi_key_query; @@ -160,6 +163,67 @@ pub fn get_beacon_state_pending_deposits( .boxed() } +// GET beacon/states/{state_id}/proposer_lookahead +pub fn get_beacon_state_proposer_lookahead( + beacon_states_path: BeaconStatesPath, +) -> ResponseFilter { + beacon_states_path + .clone() + .and(warp::path("proposer_lookahead")) + .and(warp::path::end()) + .and(warp::header::optional::("accept")) + .then( + |state_id: StateId, + task_spawner: TaskSpawner, + chain: Arc>, + accept_header: Option| { + task_spawner.blocking_response_task(Priority::P1, move || { + let (data, execution_optimistic, finalized, fork_name) = state_id + .map_state_and_execution_optimistic_and_finalized( + &chain, + |state, execution_optimistic, finalized| { + let Ok(lookahead) = state.proposer_lookahead() else { + return Err(warp_utils::reject::custom_bad_request( + "Proposer lookahead is not available for pre-Fulu states" + .to_string(), + )); + }; + + Ok(( + lookahead.to_vec(), + execution_optimistic, + finalized, + state.fork_name_unchecked(), + )) + }, + )?; + + match accept_header { + Some(api_types::Accept::Ssz) => Response::builder() + .status(200) + .body(data.as_ssz_bytes().into()) + .map(|res: Response| add_ssz_content_type_header(res)) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to create response: {}", + e + )) + }), + _ => execution_optimistic_finalized_beacon_response( + ResponseIncludesVersion::Yes(fork_name), + execution_optimistic, + finalized, + data, + ) + .map(|res| warp::reply::json(&res).into_response()), + } + .map(|resp| add_consensus_version_header(resp, fork_name)) + }) + }, + ) + .boxed() +} + // GET beacon/states/{state_id}/randao?epoch pub fn get_beacon_state_randao( beacon_states_path: BeaconStatesPath, diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index b5b74a3840..0ef8654d8d 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -650,6 +650,10 @@ pub fn serve( let get_beacon_state_pending_consolidations = states::get_beacon_state_pending_consolidations(beacon_states_path.clone()); + // GET beacon/states/{state_id}/proposer_lookahead + let get_beacon_state_proposer_lookahead = + states::get_beacon_state_proposer_lookahead(beacon_states_path.clone()); + // GET beacon/headers // // Note: this endpoint only returns information about blocks in the canonical chain. Given that @@ -3284,6 +3288,7 @@ pub fn serve( .uor(get_beacon_state_pending_deposits) .uor(get_beacon_state_pending_partial_withdrawals) .uor(get_beacon_state_pending_consolidations) + .uor(get_beacon_state_proposer_lookahead) .uor(get_beacon_headers) .uor(get_beacon_headers_block_id) .uor(get_beacon_block) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 739c39aa32..a97ce01ac1 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -37,7 +37,7 @@ use proto_array::ExecutionStatus; use reqwest::{RequestBuilder, Response, StatusCode}; use sensitive_url::SensitiveUrl; use slot_clock::SlotClock; -use ssz::BitList; +use ssz::{BitList, Decode}; use state_processing::per_block_processing::get_expected_withdrawals; use state_processing::per_slot_processing; use state_processing::state_advance::partial_state_advance; @@ -1409,6 +1409,72 @@ impl ApiTester { self } + pub async fn test_beacon_states_proposer_lookahead(self) -> Self { + for state_id in self.interesting_state_ids() { + let mut state_opt = state_id + .state(&self.chain) + .ok() + .map(|(state, _execution_optimistic, _finalized)| state); + + let result = match self + .client + .get_beacon_states_proposer_lookahead(state_id.0) + .await + { + Ok(response) => response, + Err(e) => panic!("query failed incorrectly: {e:?}"), + }; + + if result.is_none() && state_opt.is_none() { + continue; + } + + let state = state_opt.as_mut().expect("result should be none"); + let expected = state.proposer_lookahead().unwrap(); + + let response = result.unwrap(); + assert_eq!(response.data(), &expected.to_vec()); + + // Check that the version header is returned in the response + let fork_name = state.fork_name(&self.chain.spec).unwrap(); + assert_eq!(response.version(), Some(fork_name),); + } + + self + } + + pub async fn test_beacon_states_proposer_lookahead_ssz(self) -> Self { + for state_id in self.interesting_state_ids() { + let mut state_opt = state_id + .state(&self.chain) + .ok() + .map(|(state, _execution_optimistic, _finalized)| state); + + let result = match self + .client + .get_beacon_states_proposer_lookahead_ssz(state_id.0) + .await + { + Ok(response) => response, + Err(e) => panic!("query failed incorrectly: {e:?}"), + }; + + if result.is_none() && state_opt.is_none() { + continue; + } + + let state = state_opt.as_mut().expect("result should be none"); + let expected = state.proposer_lookahead().unwrap(); + + let ssz_bytes = result.unwrap(); + let decoded = Vec::::from_ssz_bytes(&ssz_bytes) + .expect("should decode SSZ proposer lookahead"); + assert_eq!(decoded, expected.to_vec()); + } + + self + } + pub async fn test_beacon_headers_all_slots(self) -> Self { for slot in 0..CHAIN_LENGTH { let slot = Slot::from(slot); @@ -7360,6 +7426,23 @@ async fn beacon_get_state_info_electra() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn beacon_get_state_info_fulu() { + let mut config = ApiTesterConfig::default(); + config.spec.altair_fork_epoch = Some(Epoch::new(0)); + config.spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + config.spec.capella_fork_epoch = Some(Epoch::new(0)); + config.spec.deneb_fork_epoch = Some(Epoch::new(0)); + config.spec.electra_fork_epoch = Some(Epoch::new(0)); + config.spec.fulu_fork_epoch = Some(Epoch::new(0)); + ApiTester::new_from_config(config) + .await + .test_beacon_states_proposer_lookahead() + .await + .test_beacon_states_proposer_lookahead_ssz() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn beacon_get_blocks() { ApiTester::new() diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 628c12981a..5547ced491 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -898,6 +898,47 @@ impl BeaconNodeHttpClient { .map(|opt| opt.map(BeaconResponse::ForkVersioned)) } + /// `GET beacon/states/{state_id}/proposer_lookahead` + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_states_proposer_lookahead( + &self, + state_id: StateId, + ) -> Result>>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("states") + .push(&state_id.to_string()) + .push("proposer_lookahead"); + + self.get_fork_contextual(path, |fork| fork) + .await + .map(|opt| opt.map(BeaconResponse::ForkVersioned)) + } + + /// `GET beacon/states/{state_id}/proposer_lookahead` + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_states_proposer_lookahead_ssz( + &self, + state_id: StateId, + ) -> Result>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("states") + .push(&state_id.to_string()) + .push("proposer_lookahead"); + + self.get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.default) + .await + } + /// `GET beacon/light_client/updates` /// /// Returns `Ok(None)` on a 404 error. From 2bb79f43aadbf6e71ab2ea67efdda4585e9184ea Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Wed, 11 Mar 2026 02:43:59 +0900 Subject: [PATCH 061/189] Update contribution guidlines regarding LLM usage (#8879) Co-Authored-By: Eitan Seri- Levi --- CONTRIBUTING.md | 9 +++++++++ wordlist.txt | 1 + 2 files changed, 10 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cad219c89..f81f75cd8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,15 @@ Requests](https://github.com/sigp/lighthouse/pulls) is where code gets reviewed. We use [discord](https://discord.gg/cyAszAh) to chat informally. +### A Note on LLM usage + +We are happy to support contributors who are genuinely engaging with the code base. Our general policy regarding LLM usage: + +- Please refrain from submissions that you haven't thoroughly understood, reviewed, and tested. +- Please disclose if a significant portion of your contribution was AI-generated. +- Descriptions and comments should be made by you. +- We reserve the right to reject any contributions we feel are violating the spirit of open source contribution. + ### General Work-Flow We recommend the following work-flow for contributors: diff --git a/wordlist.txt b/wordlist.txt index e0e1fe7d73..822e336146 100644 --- a/wordlist.txt +++ b/wordlist.txt @@ -58,6 +58,7 @@ JSON KeyManager Kurtosis LMDB +LLM LLVM LRU LTO From 815040dc3c056560c5c67a7a71a87d2bbc658fd2 Mon Sep 17 00:00:00 2001 From: Mac L Date: Wed, 11 Mar 2026 07:43:26 +0200 Subject: [PATCH 062/189] Remove `c-kzg` (#8930) #7330 Removes `c-kzg` from our `kzg` crate and rely fully on the `rust_eth_kzg` crate. This removes the old `Blob` type entirely and instead handles `rust_eth_kzg::KzgBlobRef`s directly which allows us to avoid some extra stack allocations . Similarly, we make `Bytes32` and `Bytes48` type aliases rather than structs as this fits better with the new `rust_eth_kzg` API. Co-Authored-By: Mac L --- Cargo.lock | 1 - Cargo.toml | 3 - beacon_node/beacon_chain/src/kzg_utils.rs | 50 +++-- .../test_utils/execution_block_generator.rs | 15 +- consensus/types/src/data/blob_sidecar.rs | 11 +- consensus/types/src/kzg_ext/mod.rs | 2 +- crypto/kzg/Cargo.toml | 2 - crypto/kzg/benches/benchmark.rs | 18 +- crypto/kzg/src/kzg_commitment.rs | 10 +- crypto/kzg/src/kzg_proof.rs | 8 +- crypto/kzg/src/lib.rs | 172 ++++++++---------- crypto/kzg/src/trusted_setup.rs | 18 +- deny.toml | 1 + .../cases/kzg_verify_cell_kzg_proof_batch.rs | 6 +- 14 files changed, 129 insertions(+), 188 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 704039a175..0ca12dce46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4825,7 +4825,6 @@ name = "kzg" version = "0.1.0" dependencies = [ "arbitrary", - "c-kzg", "criterion", "educe", "ethereum_hashing", diff --git a/Cargo.toml b/Cargo.toml index efedfe3b37..7572cc324d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -117,9 +117,6 @@ bitvec = "1" bls = { path = "crypto/bls" } byteorder = "1" bytes = "1.11.1" -# Turn off c-kzg's default features which include `blst/portable`. We can turn on blst's portable -# feature ourselves when desired. -c-kzg = { version = "2.1", default-features = false } cargo_metadata = "0.19" clap = { version = "4.5.4", features = ["derive", "cargo", "wrap_help"] } clap_utils = { path = "common/clap_utils" } diff --git a/beacon_node/beacon_chain/src/kzg_utils.rs b/beacon_node/beacon_chain/src/kzg_utils.rs index 33b3260361..10cb208729 100644 --- a/beacon_node/beacon_chain/src/kzg_utils.rs +++ b/beacon_node/beacon_chain/src/kzg_utils.rs @@ -1,6 +1,5 @@ use kzg::{ - Blob as KzgBlob, Bytes48, Cell as KzgCell, CellRef as KzgCellRef, CellsAndKzgProofs, - Error as KzgError, Kzg, KzgBlobRef, + Cell as KzgCell, CellRef as KzgCellRef, CellsAndKzgProofs, Error as KzgError, Kzg, KzgBlobRef, }; use rayon::prelude::*; use ssz_types::{FixedVector, VariableList}; @@ -15,18 +14,18 @@ use types::{ SignedBeaconBlock, SignedBeaconBlockHeader, SignedBlindedBeaconBlock, Slot, }; -/// Converts a blob ssz List object to an array to be used with the kzg -/// crypto library. -fn ssz_blob_to_crypto_blob(blob: &Blob) -> Result { - KzgBlob::from_bytes(blob.as_ref()).map_err(Into::into) +/// Converts a blob ssz FixedVector to a reference to a fixed-size array +/// to be used with `rust_eth_kzg`. +fn ssz_blob_to_kzg_blob_ref(blob: &Blob) -> Result, KzgError> { + blob.as_ref().try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "blob should have a guaranteed size due to FixedVector: {e:?}" + )) + }) } -fn ssz_blob_to_crypto_blob_boxed(blob: &Blob) -> Result, KzgError> { - ssz_blob_to_crypto_blob::(blob).map(Box::new) -} - -/// Converts a cell ssz List object to an array to be used with the kzg -/// crypto library. +/// Converts a cell ssz FixedVector to a reference to a fixed-size array +/// to be used with `rust_eth_kzg`. fn ssz_cell_to_crypto_cell(cell: &Cell) -> Result, KzgError> { let cell_bytes: &[u8] = cell.as_ref(); cell_bytes @@ -42,8 +41,8 @@ pub fn validate_blob( kzg_proof: KzgProof, ) -> Result<(), KzgError> { let _timer = crate::metrics::start_timer(&crate::metrics::KZG_VERIFICATION_SINGLE_TIMES); - let kzg_blob = ssz_blob_to_crypto_blob_boxed::(blob)?; - kzg.verify_blob_kzg_proof(&kzg_blob, kzg_commitment, kzg_proof) + let kzg_blob = ssz_blob_to_kzg_blob_ref::(blob)?; + kzg.verify_blob_kzg_proof(kzg_blob, kzg_commitment, kzg_proof) } /// Validate a batch of `DataColumnSidecar`. @@ -72,7 +71,7 @@ where } for &proof in data_column.kzg_proofs() { - proofs.push(Bytes48::from(proof)); + proofs.push(proof.0); } // In Gloas, commitments come from the block's ExecutionPayloadBid, not the sidecar. @@ -90,7 +89,7 @@ where }; for &commitment in kzg_commitments.iter() { - commitments.push(Bytes48::from(commitment)); + commitments.push(commitment.0); } let expected_len = column_indices.len(); @@ -120,7 +119,7 @@ pub fn validate_blobs( let _timer = crate::metrics::start_timer(&crate::metrics::KZG_VERIFICATION_BATCH_TIMES); let blobs = blobs .into_iter() - .map(|blob| ssz_blob_to_crypto_blob::(blob)) + .map(|blob| ssz_blob_to_kzg_blob_ref::(blob)) .collect::, KzgError>>()?; kzg.verify_blob_kzg_proof_batch(&blobs, expected_kzg_commitments, kzg_proofs) @@ -132,8 +131,8 @@ pub fn compute_blob_kzg_proof( blob: &Blob, kzg_commitment: KzgCommitment, ) -> Result { - let kzg_blob = ssz_blob_to_crypto_blob_boxed::(blob)?; - kzg.compute_blob_kzg_proof(&kzg_blob, kzg_commitment) + let kzg_blob = ssz_blob_to_kzg_blob_ref::(blob)?; + kzg.compute_blob_kzg_proof(kzg_blob, kzg_commitment) } /// Compute the kzg commitment for a given blob. @@ -141,8 +140,8 @@ pub fn blob_to_kzg_commitment( kzg: &Kzg, blob: &Blob, ) -> Result { - let kzg_blob = ssz_blob_to_crypto_blob_boxed::(blob)?; - kzg.blob_to_kzg_commitment(&kzg_blob) + let kzg_blob = ssz_blob_to_kzg_blob_ref::(blob)?; + kzg.blob_to_kzg_commitment(kzg_blob) } /// Compute the kzg proof for a given blob and an evaluation point z. @@ -151,10 +150,9 @@ pub fn compute_kzg_proof( blob: &Blob, z: Hash256, ) -> Result<(KzgProof, Hash256), KzgError> { - let z = z.0.into(); - let kzg_blob = ssz_blob_to_crypto_blob_boxed::(blob)?; - kzg.compute_kzg_proof(&kzg_blob, &z) - .map(|(proof, z)| (proof, Hash256::from_slice(&z.to_vec()))) + let kzg_blob = ssz_blob_to_kzg_blob_ref::(blob)?; + kzg.compute_kzg_proof(kzg_blob, &z.0) + .map(|(proof, z)| (proof, Hash256::from_slice(&z))) } /// Verify a `kzg_proof` for a `kzg_commitment` that evaluating a polynomial at `z` results in `y` @@ -165,7 +163,7 @@ pub fn verify_kzg_proof( z: Hash256, y: Hash256, ) -> Result { - kzg.verify_kzg_proof(kzg_commitment, &z.0.into(), &y.0.into(), kzg_proof) + kzg.verify_kzg_proof(kzg_commitment, &z.0, &y.0, kzg_proof) } /// Build data column sidecars from a signed beacon block and its blobs. diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index 62a46246da..e94924d8b2 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -989,7 +989,7 @@ pub fn generate_pow_block( #[cfg(test)] mod test { use super::*; - use kzg::{Bytes48, CellRef, KzgBlobRef, trusted_setup::get_trusted_setup}; + use kzg::{CellRef, KzgBlobRef, trusted_setup::get_trusted_setup}; use types::{MainnetEthSpec, MinimalEthSpec}; #[test] @@ -1015,10 +1015,11 @@ mod test { fn validate_blob_bundle_v1() -> Result<(), String> { let kzg = load_kzg()?; let (kzg_commitment, kzg_proof, blob) = load_test_blobs_bundle_v1::()?; - let kzg_blob = kzg::Blob::from_bytes(blob.as_ref()) - .map(Box::new) - .map_err(|e| format!("Error converting blob to kzg blob: {e:?}"))?; - kzg.verify_blob_kzg_proof(&kzg_blob, kzg_commitment, kzg_proof) + let kzg_blob: KzgBlobRef = blob + .as_ref() + .try_into() + .map_err(|e| format!("Error converting blob to kzg blob ref: {e:?}"))?; + kzg.verify_blob_kzg_proof(kzg_blob, kzg_commitment, kzg_proof) .map_err(|e| format!("Invalid blobs bundle: {e:?}")) } @@ -1028,8 +1029,8 @@ mod test { load_test_blobs_bundle_v2::().map(|(commitment, proofs, blob)| { let kzg_blob: KzgBlobRef = blob.as_ref().try_into().unwrap(); ( - vec![Bytes48::from(commitment); proofs.len()], - proofs.into_iter().map(|p| p.into()).collect::>(), + vec![commitment.0; proofs.len()], + proofs.into_iter().map(|p| p.0).collect::>(), kzg.compute_cells(kzg_blob).unwrap(), ) })?; diff --git a/consensus/types/src/data/blob_sidecar.rs b/consensus/types/src/data/blob_sidecar.rs index 638491d6d7..2774176190 100644 --- a/consensus/types/src/data/blob_sidecar.rs +++ b/consensus/types/src/data/blob_sidecar.rs @@ -3,7 +3,7 @@ use std::{fmt::Debug, hash::Hash, sync::Arc}; use bls::Signature; use context_deserialize::context_deserialize; use educe::Educe; -use kzg::{BYTES_PER_BLOB, BYTES_PER_FIELD_ELEMENT, Blob as KzgBlob, Kzg, KzgCommitment, KzgProof}; +use kzg::{BYTES_PER_BLOB, BYTES_PER_FIELD_ELEMENT, Kzg, KzgCommitment, KzgProof}; use merkle_proof::{MerkleTreeError, merkle_root_from_branch, verify_merkle_proof}; use rand::Rng; use safe_arith::ArithError; @@ -253,14 +253,17 @@ impl BlobSidecar { let blob = Blob::::new(blob_bytes) .map_err(|e| format!("error constructing random blob: {:?}", e))?; - let kzg_blob = KzgBlob::from_bytes(&blob).unwrap(); + let kzg_blob: &[u8; BYTES_PER_BLOB] = blob + .as_ref() + .try_into() + .map_err(|e| format!("error converting blob to kzg blob ref: {:?}", e))?; let commitment = kzg - .blob_to_kzg_commitment(&kzg_blob) + .blob_to_kzg_commitment(kzg_blob) .map_err(|e| format!("error computing kzg commitment: {:?}", e))?; let proof = kzg - .compute_blob_kzg_proof(&kzg_blob, commitment) + .compute_blob_kzg_proof(kzg_blob, commitment) .map_err(|e| format!("error computing kzg proof: {:?}", e))?; Ok(Self { diff --git a/consensus/types/src/kzg_ext/mod.rs b/consensus/types/src/kzg_ext/mod.rs index 63533ec71f..e0ec9dd956 100644 --- a/consensus/types/src/kzg_ext/mod.rs +++ b/consensus/types/src/kzg_ext/mod.rs @@ -1,6 +1,6 @@ pub mod consts; -pub use kzg::{Blob as KzgBlob, Error as KzgError, Kzg, KzgCommitment, KzgProof}; +pub use kzg::{Error as KzgError, Kzg, KzgCommitment, KzgProof}; use ssz_types::VariableList; diff --git a/crypto/kzg/Cargo.toml b/crypto/kzg/Cargo.toml index 840f8cfc9c..19f39a182b 100644 --- a/crypto/kzg/Cargo.toml +++ b/crypto/kzg/Cargo.toml @@ -12,7 +12,6 @@ fake_crypto = [] [dependencies] arbitrary = { workspace = true, optional = true } -c-kzg = { workspace = true } educe = { workspace = true } ethereum_hashing = { workspace = true } ethereum_serde_utils = { workspace = true } @@ -28,7 +27,6 @@ tree_hash = { workspace = true } [dev-dependencies] criterion = { workspace = true } -serde_json = { workspace = true } [[bench]] name = "benchmark" diff --git a/crypto/kzg/benches/benchmark.rs b/crypto/kzg/benches/benchmark.rs index 432d84654a..d5d5596211 100644 --- a/crypto/kzg/benches/benchmark.rs +++ b/crypto/kzg/benches/benchmark.rs @@ -1,6 +1,5 @@ -use c_kzg::KzgSettings; use criterion::{criterion_group, criterion_main, Criterion}; -use kzg::{trusted_setup::get_trusted_setup, TrustedSetup, NO_PRECOMPUTE}; +use kzg::trusted_setup::get_trusted_setup; use rust_eth_kzg::{DASContext, TrustedSetup as PeerDASTrustedSetup}; pub fn bench_init_context(c: &mut Criterion) { @@ -20,21 +19,6 @@ pub fn bench_init_context(c: &mut Criterion) { ) }) }); - c.bench_function("Initialize context c-kzg (4844)", |b| { - b.iter(|| { - let trusted_setup: TrustedSetup = - serde_json::from_reader(trusted_setup_bytes.as_slice()) - .map_err(|e| format!("Unable to read trusted setup file: {}", e)) - .expect("should have trusted setup"); - KzgSettings::load_trusted_setup( - &trusted_setup.g1_monomial(), - &trusted_setup.g1_lagrange(), - &trusted_setup.g2_monomial(), - NO_PRECOMPUTE, - ) - .unwrap() - }) - }); } criterion_group!(benches, bench_init_context); diff --git a/crypto/kzg/src/kzg_commitment.rs b/crypto/kzg/src/kzg_commitment.rs index bc5fc5f5aa..d8ef4b36cf 100644 --- a/crypto/kzg/src/kzg_commitment.rs +++ b/crypto/kzg/src/kzg_commitment.rs @@ -1,4 +1,4 @@ -use c_kzg::BYTES_PER_COMMITMENT; +use crate::{Bytes48, BYTES_PER_COMMITMENT}; use educe::Educe; use ethereum_hashing::hash_fixed; use serde::de::{Deserialize, Deserializer}; @@ -14,7 +14,7 @@ pub const VERSIONED_HASH_VERSION_KZG: u8 = 0x01; #[derive(Educe, Clone, Copy, Encode, Decode)] #[educe(PartialEq, Eq, Hash)] #[ssz(struct_behaviour = "transparent")] -pub struct KzgCommitment(pub [u8; c_kzg::BYTES_PER_COMMITMENT]); +pub struct KzgCommitment(pub [u8; BYTES_PER_COMMITMENT]); impl KzgCommitment { pub fn calculate_versioned_hash(&self) -> Hash256 { @@ -24,13 +24,13 @@ impl KzgCommitment { } pub fn empty_for_testing() -> Self { - KzgCommitment([0; c_kzg::BYTES_PER_COMMITMENT]) + KzgCommitment([0; BYTES_PER_COMMITMENT]) } } -impl From for c_kzg::Bytes48 { +impl From for Bytes48 { fn from(value: KzgCommitment) -> Self { - value.0.into() + value.0 } } diff --git a/crypto/kzg/src/kzg_proof.rs b/crypto/kzg/src/kzg_proof.rs index aa9ed185a0..e0867520eb 100644 --- a/crypto/kzg/src/kzg_proof.rs +++ b/crypto/kzg/src/kzg_proof.rs @@ -1,4 +1,4 @@ -use c_kzg::BYTES_PER_PROOF; +use crate::BYTES_PER_PROOF; use serde::de::{Deserialize, Deserializer}; use serde::ser::{Serialize, Serializer}; use ssz_derive::{Decode, Encode}; @@ -11,12 +11,6 @@ use tree_hash::{PackedEncoding, TreeHash}; #[ssz(struct_behaviour = "transparent")] pub struct KzgProof(pub [u8; BYTES_PER_PROOF]); -impl From for c_kzg::Bytes48 { - fn from(value: KzgProof) -> Self { - value.0.into() - } -} - impl KzgProof { /// Creates a valid proof using `G1_POINT_AT_INFINITY`. pub fn empty() -> Self { diff --git a/crypto/kzg/src/lib.rs b/crypto/kzg/src/lib.rs index 66499dad8e..6ee352b0db 100644 --- a/crypto/kzg/src/lib.rs +++ b/crypto/kzg/src/lib.rs @@ -12,11 +12,12 @@ pub use crate::{ trusted_setup::TrustedSetup, }; -pub use c_kzg::{ - Blob, Bytes32, Bytes48, KzgSettings, BYTES_PER_BLOB, BYTES_PER_COMMITMENT, - BYTES_PER_FIELD_ELEMENT, BYTES_PER_PROOF, FIELD_ELEMENTS_PER_BLOB, +pub use rust_eth_kzg::constants::{ + BYTES_PER_BLOB, BYTES_PER_COMMITMENT, BYTES_PER_FIELD_ELEMENT, FIELD_ELEMENTS_PER_BLOB, }; +pub const BYTES_PER_PROOF: usize = 48; + use crate::trusted_setup::load_trusted_setup; use rayon::prelude::*; pub use rust_eth_kzg::{ @@ -25,13 +26,6 @@ pub use rust_eth_kzg::{ }; use tracing::{instrument, Span}; -/// Disables the fixed-base multi-scalar multiplication optimization for computing -/// cell KZG proofs, because `rust-eth-kzg` already handles the precomputation. -/// -/// Details about `precompute` parameter can be found here: -/// -pub const NO_PRECOMPUTE: u64 = 0; - // Note: Both `NUMBER_OF_COLUMNS` and `CELLS_PER_EXT_BLOB` are preset values - however this // is a constant in the KZG library - be aware that overriding `NUMBER_OF_COLUMNS` will break KZG // operations. @@ -39,14 +33,15 @@ pub type CellsAndKzgProofs = ([Cell; CELLS_PER_EXT_BLOB], [KzgProof; CELLS_PER_E pub type KzgBlobRef<'a> = &'a [u8; BYTES_PER_BLOB]; +type Bytes32 = [u8; 32]; +type Bytes48 = [u8; 48]; + #[derive(Debug)] pub enum Error { /// An error from initialising the trusted setup. TrustedSetupError(String), - /// An error from the underlying kzg library. - Kzg(c_kzg::Error), - /// A prover/verifier error from the rust-eth-kzg library. - PeerDASKZG(rust_eth_kzg::Error), + /// An error from the rust-eth-kzg library. + Kzg(rust_eth_kzg::Error), /// The kzg verification failed KzgVerificationFailed, /// Misc indexing error @@ -57,38 +52,29 @@ pub enum Error { DASContextUninitialized, } -impl From for Error { - fn from(value: c_kzg::Error) -> Self { +impl From for Error { + fn from(value: rust_eth_kzg::Error) -> Self { Error::Kzg(value) } } -/// A wrapper over a kzg library that holds the trusted setup parameters. +/// A wrapper over the rust-eth-kzg library that holds the trusted setup parameters. #[derive(Debug)] pub struct Kzg { - trusted_setup: KzgSettings, context: DASContext, } impl Kzg { pub fn new_from_trusted_setup_no_precomp(trusted_setup: &[u8]) -> Result { - let (ckzg_trusted_setup, rkzg_trusted_setup) = load_trusted_setup(trusted_setup)?; + let rkzg_trusted_setup = load_trusted_setup(trusted_setup)?; let context = DASContext::new(&rkzg_trusted_setup, rust_eth_kzg::UsePrecomp::No); - Ok(Self { - trusted_setup: KzgSettings::load_trusted_setup( - &ckzg_trusted_setup.g1_monomial(), - &ckzg_trusted_setup.g1_lagrange(), - &ckzg_trusted_setup.g2_monomial(), - NO_PRECOMPUTE, - )?, - context, - }) + Ok(Self { context }) } /// Load the kzg trusted setup parameters from a vec of G1 and G2 points. pub fn new_from_trusted_setup(trusted_setup: &[u8]) -> Result { - let (ckzg_trusted_setup, rkzg_trusted_setup) = load_trusted_setup(trusted_setup)?; + let rkzg_trusted_setup = load_trusted_setup(trusted_setup)?; // It's not recommended to change the config parameter for precomputation as storage // grows exponentially, but the speedup is exponential - after a while the speedup @@ -100,15 +86,7 @@ impl Kzg { }, ); - Ok(Self { - trusted_setup: KzgSettings::load_trusted_setup( - &ckzg_trusted_setup.g1_monomial(), - &ckzg_trusted_setup.g1_lagrange(), - &ckzg_trusted_setup.g2_monomial(), - NO_PRECOMPUTE, - )?, - context, - }) + Ok(Self { context }) } fn context(&self) -> &DASContext { @@ -118,34 +96,35 @@ impl Kzg { /// Compute the kzg proof given a blob and its kzg commitment. pub fn compute_blob_kzg_proof( &self, - blob: &Blob, + blob: KzgBlobRef<'_>, kzg_commitment: KzgCommitment, ) -> Result { - self.trusted_setup - .compute_blob_kzg_proof(blob, &kzg_commitment.into()) - .map(|proof| KzgProof(proof.to_bytes().into_inner())) - .map_err(Into::into) + let proof = self + .context() + .compute_blob_kzg_proof(blob, &kzg_commitment.0) + .map_err(Error::Kzg)?; + Ok(KzgProof(proof)) } /// Verify a kzg proof given the blob, kzg commitment and kzg proof. pub fn verify_blob_kzg_proof( &self, - blob: &Blob, + blob: KzgBlobRef<'_>, kzg_commitment: KzgCommitment, kzg_proof: KzgProof, ) -> Result<(), Error> { if cfg!(feature = "fake_crypto") { return Ok(()); } - if !self.trusted_setup.verify_blob_kzg_proof( - blob, - &kzg_commitment.into(), - &kzg_proof.into(), - )? { - Err(Error::KzgVerificationFailed) - } else { - Ok(()) - } + self.context() + .verify_blob_kzg_proof(blob, &kzg_commitment.0, &kzg_proof.0) + .map_err(|e| { + if e.is_proof_invalid() { + Error::KzgVerificationFailed + } else { + Error::Kzg(e) + } + }) } /// Verify a batch of blob commitment proof triplets. @@ -154,52 +133,48 @@ impl Kzg { /// TODO(pawan): test performance against a parallelized rayon impl. pub fn verify_blob_kzg_proof_batch( &self, - blobs: &[Blob], + blobs: &[KzgBlobRef<'_>], kzg_commitments: &[KzgCommitment], kzg_proofs: &[KzgProof], ) -> Result<(), Error> { if cfg!(feature = "fake_crypto") { return Ok(()); } - let commitments_bytes = kzg_commitments - .iter() - .map(|comm| Bytes48::from(*comm)) - .collect::>(); + let blob_refs: Vec<&[u8; BYTES_PER_BLOB]> = blobs.to_vec(); + let commitment_refs: Vec<&[u8; 48]> = kzg_commitments.iter().map(|c| &c.0).collect(); + let proof_refs: Vec<&[u8; 48]> = kzg_proofs.iter().map(|p| &p.0).collect(); - let proofs_bytes = kzg_proofs - .iter() - .map(|proof| Bytes48::from(*proof)) - .collect::>(); - - if !self.trusted_setup.verify_blob_kzg_proof_batch( - blobs, - &commitments_bytes, - &proofs_bytes, - )? { - Err(Error::KzgVerificationFailed) - } else { - Ok(()) - } + self.context() + .verify_blob_kzg_proof_batch(blob_refs, commitment_refs, proof_refs) + .map_err(|e| { + if e.is_proof_invalid() { + Error::KzgVerificationFailed + } else { + Error::Kzg(e) + } + }) } /// Converts a blob to a kzg commitment. - pub fn blob_to_kzg_commitment(&self, blob: &Blob) -> Result { - self.trusted_setup + pub fn blob_to_kzg_commitment(&self, blob: KzgBlobRef<'_>) -> Result { + let commitment = self + .context() .blob_to_kzg_commitment(blob) - .map(|commitment| KzgCommitment(commitment.to_bytes().into_inner())) - .map_err(Into::into) + .map_err(Error::Kzg)?; + Ok(KzgCommitment(commitment)) } /// Computes the kzg proof for a given `blob` and an evaluation point `z` pub fn compute_kzg_proof( &self, - blob: &Blob, + blob: KzgBlobRef<'_>, z: &Bytes32, ) -> Result<(KzgProof, Bytes32), Error> { - self.trusted_setup - .compute_kzg_proof(blob, z) - .map(|(proof, y)| (KzgProof(proof.to_bytes().into_inner()), y)) - .map_err(Into::into) + let (proof, y) = self + .context() + .compute_kzg_proof(blob, *z) + .map_err(Error::Kzg)?; + Ok((KzgProof(proof), y)) } /// Verifies a `kzg_proof` for a `kzg_commitment` that evaluating a polynomial at `z` results in `y` @@ -213,9 +188,14 @@ impl Kzg { if cfg!(feature = "fake_crypto") { return Ok(true); } - self.trusted_setup - .verify_kzg_proof(&kzg_commitment.into(), z, y, &kzg_proof.into()) - .map_err(Into::into) + match self + .context() + .verify_kzg_proof(&kzg_commitment.0, *z, *y, &kzg_proof.0) + { + Ok(()) => Ok(true), + Err(e) if e.is_proof_invalid() => Ok(false), + Err(e) => Err(Error::Kzg(e)), + } } /// Computes the cells and associated proofs for a given `blob`. @@ -226,18 +206,15 @@ impl Kzg { let (cells, proofs) = self .context() .compute_cells_and_kzg_proofs(blob) - .map_err(Error::PeerDASKZG)?; + .map_err(Error::Kzg)?; - // Convert the proof type to a c-kzg proof type - let c_kzg_proof = proofs.map(KzgProof); - Ok((cells, c_kzg_proof)) + let kzg_proofs = proofs.map(KzgProof); + Ok((cells, kzg_proofs)) } /// Computes the cells for a given `blob`. pub fn compute_cells(&self, blob: KzgBlobRef<'_>) -> Result<[Cell; CELLS_PER_EXT_BLOB], Error> { - self.context() - .compute_cells(blob) - .map_err(Error::PeerDASKZG) + self.context().compute_cells(blob).map_err(Error::Kzg) } /// Verifies a batch of cell-proof-commitment triplets. @@ -291,8 +268,8 @@ impl Kzg { for (cell, proof, commitment) in &column_data { cells.push(*cell); - proofs.push(proof.as_ref()); - commitments.push(commitment.as_ref()); + proofs.push(proof); + commitments.push(commitment); } // Create per-chunk tracing span for visualizing parallel processing. @@ -319,7 +296,7 @@ impl Kzg { Err(e) if e.is_proof_invalid() => { Err((Some(column_index), Error::KzgVerificationFailed)) } - Err(e) => Err((Some(column_index), Error::PeerDASKZG(e))), + Err(e) => Err((Some(column_index), Error::Kzg(e))), } }) .collect::, (Option, Error)>>()?; @@ -335,10 +312,9 @@ impl Kzg { let (cells, proofs) = self .context() .recover_cells_and_kzg_proofs(cell_ids.to_vec(), cells.to_vec()) - .map_err(Error::PeerDASKZG)?; + .map_err(Error::Kzg)?; - // Convert the proof type to a c-kzg proof type - let c_kzg_proof = proofs.map(KzgProof); - Ok((cells, c_kzg_proof)) + let kzg_proofs = proofs.map(KzgProof); + Ok((cells, kzg_proofs)) } } diff --git a/crypto/kzg/src/trusted_setup.rs b/crypto/kzg/src/trusted_setup.rs index 75884b8199..5c285b50f2 100644 --- a/crypto/kzg/src/trusted_setup.rs +++ b/crypto/kzg/src/trusted_setup.rs @@ -24,7 +24,7 @@ struct G1Point([u8; BYTES_PER_G1_POINT]); struct G2Point([u8; BYTES_PER_G2_POINT]); /// Contains the trusted setup parameters that are required to instantiate a -/// `c_kzg::KzgSettings` object. +/// `rust_eth_kzg::TrustedSetup` object. /// /// The serialize/deserialize implementations are written according to /// the format specified in the ethereum consensus specs trusted setup files. @@ -155,19 +155,9 @@ fn strip_prefix(s: &str) -> &str { } } -/// Loads the trusted setup from JSON. -/// -/// ## Note: -/// Currently we load both c-kzg and rust-eth-kzg trusted setup structs, because c-kzg is still being -/// used for 4844. Longer term we're planning to switch all KZG operations to the rust-eth-kzg -/// crate, and we'll be able to maintain a single trusted setup struct. -pub(crate) fn load_trusted_setup( - trusted_setup: &[u8], -) -> Result<(TrustedSetup, PeerDASTrustedSetup), Error> { - let ckzg_trusted_setup: TrustedSetup = serde_json::from_slice(trusted_setup) - .map_err(|e| Error::TrustedSetupError(format!("{e:?}")))?; +/// Loads the trusted setup from JSON bytes into a `rust_eth_kzg::TrustedSetup`. +pub(crate) fn load_trusted_setup(trusted_setup: &[u8]) -> Result { let trusted_setup_json = std::str::from_utf8(trusted_setup) .map_err(|e| Error::TrustedSetupError(format!("{e:?}")))?; - let rkzg_trusted_setup = PeerDASTrustedSetup::from_json(trusted_setup_json); - Ok((ckzg_trusted_setup, rkzg_trusted_setup)) + Ok(PeerDASTrustedSetup::from_json(trusted_setup_json)) } diff --git a/deny.toml b/deny.toml index 3b230155f7..cf0cd7d3cd 100644 --- a/deny.toml +++ b/deny.toml @@ -11,6 +11,7 @@ deny = [ { crate = "derivative", reason = "use educe or derive_more instead" }, { crate = "ark-ff", reason = "present in Cargo.lock but not needed by Lighthouse" }, { crate = "openssl", reason = "non-Rust dependency, use rustls instead" }, + { crate = "c-kzg", reason = "non-Rust dependency, use rust_eth_kzg instead" }, { crate = "strum", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "reqwest", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "aes", deny-multiple-versions = true, reason = "takes a long time to compile" }, diff --git a/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs b/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs index 7973af861f..200f439c28 100644 --- a/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs +++ b/testing/ef_tests/src/cases/kzg_verify_cell_kzg_proof_batch.rs @@ -1,6 +1,6 @@ use super::*; use crate::case_result::compare_result; -use kzg::{Bytes48, Error as KzgError}; +use kzg::Error as KzgError; use serde::Deserialize; use std::marker::PhantomData; @@ -47,8 +47,8 @@ impl Case for KZGVerifyCellKZGProofBatch { let result = parse_input(&self.input).and_then(|(cells, proofs, cell_indices, commitments)| { - let proofs: Vec = proofs.iter().map(|&proof| proof.into()).collect(); - let commitments: Vec = commitments.iter().map(|&c| c.into()).collect(); + let proofs = proofs.iter().map(|&proof| proof.0).collect::>(); + let commitments = commitments.iter().map(|&c| c.0).collect::>(); let cells = cells.iter().map(|c| c.as_ref()).collect::>(); let kzg = get_kzg(); match kzg.verify_cell_proof_batch(&cells, &proofs, cell_indices, &commitments) { From 6350a270319267615034475613c7ff3366b941d3 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:20:02 -0500 Subject: [PATCH 063/189] Optionally check DB invariants at runtime (#8952) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/beacon_chain/src/invariants.rs | 56 ++ beacon_node/beacon_chain/src/lib.rs | 1 + beacon_node/beacon_chain/tests/store_tests.rs | 43 + beacon_node/http_api/src/database.rs | 9 + beacon_node/http_api/src/lib.rs | 14 + beacon_node/store/src/invariants.rs | 781 ++++++++++++++++++ beacon_node/store/src/lib.rs | 1 + beacon_node/store/src/state_cache.rs | 13 + 8 files changed, 918 insertions(+) create mode 100644 beacon_node/beacon_chain/src/invariants.rs create mode 100644 beacon_node/store/src/invariants.rs diff --git a/beacon_node/beacon_chain/src/invariants.rs b/beacon_node/beacon_chain/src/invariants.rs new file mode 100644 index 0000000000..7bcec7b0b4 --- /dev/null +++ b/beacon_node/beacon_chain/src/invariants.rs @@ -0,0 +1,56 @@ +//! Beacon chain database invariant checks. +//! +//! Builds the `InvariantContext` from beacon chain state and delegates all checks +//! to `HotColdDB::check_invariants`. + +use crate::BeaconChain; +use crate::beacon_chain::BeaconChainTypes; +use store::invariants::{InvariantCheckResult, InvariantContext}; + +impl BeaconChain { + /// Run all database invariant checks. + /// + /// Collects context from fork choice, state cache, custody columns, and pubkey cache, + /// then delegates to the store-level `check_invariants` method. + pub fn check_database_invariants(&self) -> Result { + let fork_choice_blocks = { + let fc = self.canonical_head.fork_choice_read_lock(); + let proto_array = fc.proto_array().core_proto_array(); + proto_array + .nodes + .iter() + .filter(|node| { + // Only check blocks that are descendants of the finalized checkpoint. + // Pruned non-canonical fork blocks may linger in the proto-array but + // are legitimately absent from the database. + fc.is_finalized_checkpoint_or_descendant(node.root) + }) + .map(|node| (node.root, node.slot)) + .collect() + }; + + let custody_context = self.data_availability_checker.custody_context(); + + let ctx = InvariantContext { + fork_choice_blocks, + state_cache_roots: self.store.state_cache.lock().state_roots(), + custody_columns: custody_context + .custody_columns_for_epoch(None, &self.spec) + .to_vec(), + pubkey_cache_pubkeys: { + let cache = self.validator_pubkey_cache.read(); + (0..cache.len()) + .filter_map(|i| { + cache.get(i).map(|pk| { + use store::StoreItem; + crate::validator_pubkey_cache::DatabasePubkey::from_pubkey(pk) + .as_store_bytes() + }) + }) + .collect() + }, + }; + + self.store.check_invariants(&ctx) + } +} diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index 4efd90bd22..29081fd767 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -29,6 +29,7 @@ pub mod fork_choice_signal; pub mod graffiti_calculator; pub mod historical_blocks; pub mod historical_data_columns; +pub mod invariants; pub mod kzg_utils; pub mod light_client_finality_update_verification; pub mod light_client_optimistic_update_verification; diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index b6d729cc61..86f4af3efc 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -148,6 +148,22 @@ fn get_harness_generic( harness } +/// Check that all database invariants hold. +/// +/// Panics with a descriptive message if any invariant is violated. +fn check_db_invariants(harness: &TestHarness) { + let result = harness + .chain + .check_database_invariants() + .expect("invariant check should not error"); + + assert!( + result.is_ok(), + "database invariant violations found:\n{:#?}", + result.violations, + ); +} + fn get_states_descendant_of_block( store: &HotColdDB, BeaconNodeBackend>, block_root: Hash256, @@ -308,6 +324,7 @@ async fn full_participation_no_skips() { check_split_slot(&harness, store); check_chain_dump(&harness, num_blocks_produced + 1); check_iterators(&harness); + check_db_invariants(&harness); } #[tokio::test] @@ -352,6 +369,7 @@ async fn randomised_skips() { check_split_slot(&harness, store.clone()); check_chain_dump(&harness, num_blocks_produced + 1); check_iterators(&harness); + check_db_invariants(&harness); } #[tokio::test] @@ -400,6 +418,7 @@ async fn long_skip() { check_split_slot(&harness, store); check_chain_dump(&harness, initial_blocks + final_blocks + 1); check_iterators(&harness); + check_db_invariants(&harness); } /// Go forward to the point where the genesis randao value is no longer part of the vector. @@ -1769,6 +1788,8 @@ async fn prunes_abandoned_fork_between_two_finalized_checkpoints() { } assert!(!rig.knows_head(&stray_head)); + + check_db_invariants(&rig); } #[tokio::test] @@ -1897,6 +1918,8 @@ async fn pruning_does_not_touch_abandoned_block_shared_with_canonical_chain() { assert!(!rig.knows_head(&stray_head)); let chain_dump = rig.chain.chain_dump().unwrap(); assert!(get_blocks(&chain_dump).contains(&shared_head)); + + check_db_invariants(&rig); } #[tokio::test] @@ -1988,6 +2011,8 @@ async fn pruning_does_not_touch_blocks_prior_to_finalization() { } rig.assert_knows_head(stray_head.into()); + + check_db_invariants(&rig); } #[tokio::test] @@ -2127,6 +2152,8 @@ async fn prunes_fork_growing_past_youngest_finalized_checkpoint() { } assert!(!rig.knows_head(&stray_head)); + + check_db_invariants(&rig); } // This is to check if state outside of normal block processing are pruned correctly. @@ -2377,6 +2404,8 @@ async fn finalizes_non_epoch_start_slot() { state_hash ); } + + check_db_invariants(&rig); } fn check_all_blocks_exist<'a>( @@ -2643,6 +2672,8 @@ async fn pruning_test( check_all_states_exist(&harness, all_canonical_states.iter()); check_no_states_exist(&harness, stray_states.difference(&all_canonical_states)); check_no_blocks_exist(&harness, stray_blocks.values()); + + check_db_invariants(&harness); } #[tokio::test] @@ -2707,6 +2738,8 @@ async fn garbage_collect_temp_states_from_failed_block_on_finalization() { vec![(genesis_state_root, Slot::new(0))], "get_states_descendant_of_block({bad_block_parent_root:?})" ); + + check_db_invariants(&harness); } #[tokio::test] @@ -3361,6 +3394,16 @@ async fn weak_subjectivity_sync_test( store.clone().reconstruct_historic_states(None).unwrap(); assert_eq!(store.get_anchor_info().anchor_slot, wss_aligned_slot); assert_eq!(store.get_anchor_info().state_upper_limit, Slot::new(0)); + + // Check database invariants after full checkpoint sync + backfill + reconstruction. + let result = beacon_chain + .check_database_invariants() + .expect("invariant check should not error"); + assert!( + result.is_ok(), + "database invariant violations:\n{:#?}", + result.violations, + ); } // This test prunes data columns from epoch 0 and then tries to re-import them via diff --git a/beacon_node/http_api/src/database.rs b/beacon_node/http_api/src/database.rs index 8a50ec45b0..4737d92079 100644 --- a/beacon_node/http_api/src/database.rs +++ b/beacon_node/http_api/src/database.rs @@ -2,6 +2,7 @@ use beacon_chain::store::metadata::CURRENT_SCHEMA_VERSION; use beacon_chain::{BeaconChain, BeaconChainTypes}; use serde::Serialize; use std::sync::Arc; +use store::invariants::InvariantCheckResult; use store::{AnchorInfo, BlobInfo, Split, StoreConfig}; #[derive(Debug, Serialize)] @@ -30,3 +31,11 @@ pub fn info( blob_info, }) } + +pub fn check_invariants( + chain: Arc>, +) -> Result { + chain.check_database_invariants().map_err(|e| { + warp_utils::reject::custom_bad_request(format!("error checking database invariants: {e:?}")) + }) +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 0ef8654d8d..26bad809df 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -3007,6 +3007,19 @@ pub fn serve( }, ); + // GET lighthouse/database/invariants + let get_lighthouse_database_invariants = database_path + .and(warp::path("invariants")) + .and(warp::path::end()) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .then( + |task_spawner: TaskSpawner, chain: Arc>| { + task_spawner + .blocking_json_task(Priority::P1, move || database::check_invariants(chain)) + }, + ); + // POST lighthouse/database/reconstruct let post_lighthouse_database_reconstruct = database_path .and(warp::path("reconstruct")) @@ -3336,6 +3349,7 @@ pub fn serve( .uor(get_lighthouse_validator_inclusion) .uor(get_lighthouse_staking) .uor(get_lighthouse_database_info) + .uor(get_lighthouse_database_invariants) .uor(get_lighthouse_custody_info) .uor(get_lighthouse_attestation_performance) .uor(get_beacon_light_client_optimistic_update) diff --git a/beacon_node/store/src/invariants.rs b/beacon_node/store/src/invariants.rs new file mode 100644 index 0000000000..eb5232d344 --- /dev/null +++ b/beacon_node/store/src/invariants.rs @@ -0,0 +1,781 @@ +//! Database invariant checks for the hot and cold databases. +//! +//! These checks verify the consistency of data stored in the database. They are designed to be +//! called from the HTTP API and from tests to detect data corruption or bugs in the store logic. +//! +//! See the `check_invariants` and `check_database_invariants` methods for the full list. + +use crate::hdiff::StorageStrategy; +use crate::hot_cold_store::{ColdStateSummary, HotStateSummary}; +use crate::{DBColumn, Error, ItemStore}; +use crate::{HotColdDB, Split}; +use serde::Serialize; +use ssz::Decode; +use std::cmp; +use std::collections::HashSet; +use types::*; + +/// Result of running invariant checks on the database. +#[derive(Debug, Clone, Serialize)] +pub struct InvariantCheckResult { + /// List of invariant violations found. + pub violations: Vec, +} + +impl InvariantCheckResult { + pub fn new() -> Self { + Self { + violations: Vec::new(), + } + } + + pub fn is_ok(&self) -> bool { + self.violations.is_empty() + } + + pub fn add_violation(&mut self, violation: InvariantViolation) { + self.violations.push(violation); + } + + pub fn merge(&mut self, other: InvariantCheckResult) { + self.violations.extend(other.violations); + } +} + +impl Default for InvariantCheckResult { + fn default() -> Self { + Self::new() + } +} + +/// Context data from the beacon chain needed for invariant checks. +/// +/// This allows all invariant checks to live in the store crate while still checking +/// invariants that depend on fork choice, state cache, and custody context. +pub struct InvariantContext { + /// Block roots tracked by fork choice (invariant 1). + pub fork_choice_blocks: Vec<(Hash256, Slot)>, + /// State roots held in the in-memory state cache (invariant 8). + pub state_cache_roots: Vec, + /// Custody columns for the current epoch (invariant 7). + pub custody_columns: Vec, + /// Compressed pubkey bytes from the in-memory validator pubkey cache, indexed by validator index + /// (invariant 9). + pub pubkey_cache_pubkeys: Vec>, +} + +/// A single invariant violation. +#[derive(Debug, Clone, Serialize)] +pub enum InvariantViolation { + /// Invariant 1: fork choice block consistency. + /// + /// ```text + /// block in fork_choice && descends_from_finalized -> block in hot_db + /// ``` + ForkChoiceBlockMissing { block_root: Hash256, slot: Slot }, + /// Invariant 2: block and state consistency. + /// + /// ```text + /// block in hot_db && block.slot >= split.slot + /// -> state_summary for block.state_root() in hot_db + /// ``` + HotBlockMissingStateSummary { + block_root: Hash256, + slot: Slot, + state_root: Hash256, + }, + /// Invariant 3: state summary diff consistency. + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db according to hierarchy rules + /// ``` + HotStateMissingSnapshot { state_root: Hash256, slot: Slot }, + /// Invariant 3: state summary diff consistency (missing diff). + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db according to hierarchy rules + /// ``` + HotStateMissingDiff { state_root: Hash256, slot: Slot }, + /// Invariant 3: DiffFrom/ReplayFrom base slot must reference an existing summary. + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db according to hierarchy rules + /// ``` + HotStateBaseSummaryMissing { + slot: Slot, + base_state_root: Hash256, + }, + /// Invariant 4: state summary chain consistency. + /// + /// ```text + /// state_summary in hot_db && state_summary.slot > split.slot + /// -> state_summary for previous_state_root in hot_db + /// ``` + HotStateMissingPreviousSummary { + slot: Slot, + previous_state_root: Hash256, + }, + /// Invariant 5: block and execution payload consistency. + /// + /// ```text + /// block in hot_db && !prune_payloads -> payload for block.root in hot_db + /// ``` + ExecutionPayloadMissing { block_root: Hash256, slot: Slot }, + /// Invariant 6: block and blobs consistency. + /// + /// ```text + /// block in hot_db && num_blob_commitments > 0 + /// -> blob_list for block.root in hot_db + /// ``` + BlobSidecarMissing { block_root: Hash256, slot: Slot }, + /// Invariant 7: block and data columns consistency. + /// + /// ```text + /// block in hot_db && num_blob_commitments > 0 + /// && block.slot >= earliest_available_slot + /// && data_column_idx in custody_columns + /// -> (block_root, data_column_idx) in hot_db + /// ``` + DataColumnMissing { + block_root: Hash256, + slot: Slot, + column_index: ColumnIndex, + }, + /// Invariant 8: state cache and disk consistency. + /// + /// ```text + /// state in state_cache -> state_summary in hot_db + /// ``` + StateCacheMissingSummary { state_root: Hash256 }, + /// Invariant 9: pubkey cache consistency. + /// + /// ```text + /// state_summary in hot_db + /// -> all validator pubkeys from state.validators are in the hot_db + /// ``` + PubkeyCacheMissing { validator_index: usize }, + /// Invariant 9b: pubkey cache value mismatch. + /// + /// ```text + /// pubkey_cache[i] == hot_db(PubkeyCache)[i] + /// ``` + PubkeyCacheMismatch { validator_index: usize }, + /// Invariant 10: block root indices mapping. + /// + /// ```text + /// oldest_block_slot <= i < split.slot + /// -> block_root for slot i in cold_db + /// && block for block_root in hot_db + /// ``` + ColdBlockRootMissing { + slot: Slot, + oldest_block_slot: Slot, + split_slot: Slot, + }, + /// Invariant 10: block root index references a block that must exist. + /// + /// ```text + /// oldest_block_slot <= i < split.slot + /// -> block_root for slot i in cold_db + /// && block for block_root in hot_db + /// ``` + ColdBlockRootOrphan { slot: Slot, block_root: Hash256 }, + /// Invariant 11: state root indices mapping. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + ColdStateRootMissing { + slot: Slot, + state_lower_limit: Slot, + state_upper_limit: Slot, + split_slot: Slot, + }, + /// Invariant 11: state root index must have a cold state summary. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + ColdStateRootMissingSummary { slot: Slot, state_root: Hash256 }, + /// Invariant 11: cold state summary slot must match index slot. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + ColdStateRootSlotMismatch { + slot: Slot, + state_root: Hash256, + summary_slot: Slot, + }, + /// Invariant 12: cold state diff consistency. + /// + /// ```text + /// cold_state_summary in cold_db + /// -> slot |-> state diff/snapshot/nothing in cold_db according to diff hierarchy + /// ``` + ColdStateMissingSnapshot { state_root: Hash256, slot: Slot }, + /// Invariant 12: cold state diff consistency (missing diff). + /// + /// ```text + /// cold_state_summary in cold_db + /// -> slot |-> state diff/snapshot/nothing in cold_db according to diff hierarchy + /// ``` + ColdStateMissingDiff { state_root: Hash256, slot: Slot }, + /// Invariant 12: DiffFrom/ReplayFrom base slot must reference an existing summary. + /// + /// ```text + /// cold_state_summary in cold_db + /// -> slot |-> state diff/snapshot/nothing in cold_db according to diff hierarchy + /// ``` + ColdStateBaseSummaryMissing { slot: Slot, base_slot: Slot }, +} + +impl, Cold: ItemStore> HotColdDB { + /// Run all database invariant checks. + /// + /// The `ctx` parameter provides data from the beacon chain layer (fork choice, state cache, + /// custody columns, pubkey cache) so that all invariant checks can live in this single file. + pub fn check_invariants(&self, ctx: &InvariantContext) -> Result { + let mut result = InvariantCheckResult::new(); + let split = self.get_split_info(); + + result.merge(self.check_fork_choice_block_consistency(ctx)?); + result.merge(self.check_hot_block_invariants(&split, ctx)?); + result.merge(self.check_hot_state_summary_diff_consistency()?); + result.merge(self.check_hot_state_summary_chain_consistency(&split)?); + result.merge(self.check_state_cache_consistency(ctx)?); + result.merge(self.check_cold_block_root_indices(&split)?); + result.merge(self.check_cold_state_root_indices(&split)?); + result.merge(self.check_cold_state_diff_consistency()?); + result.merge(self.check_pubkey_cache_consistency(ctx)?); + + Ok(result) + } + + /// Invariant 1 (Hot DB): Fork choice block consistency. + /// + /// ```text + /// block in fork_choice && descends_from_finalized -> block in hot_db + /// ``` + /// + /// Every canonical fork choice block (descending from finalized) must exist in the hot + /// database. Pruned non-canonical fork blocks may linger in the proto-array and are + /// excluded from this check. + fn check_fork_choice_block_consistency( + &self, + ctx: &InvariantContext, + ) -> Result { + let mut result = InvariantCheckResult::new(); + + for &(block_root, slot) in &ctx.fork_choice_blocks { + let exists = self + .hot_db + .key_exists(DBColumn::BeaconBlock, block_root.as_slice())?; + if !exists { + result + .add_violation(InvariantViolation::ForkChoiceBlockMissing { block_root, slot }); + } + } + + Ok(result) + } + + /// Invariants 2, 5, 6, 7 (Hot DB): Block-related consistency checks. + /// + /// Iterates hot DB blocks once and checks: + /// - Invariant 2: block-state summary consistency + /// - Invariant 5: execution payload consistency (when prune_payloads=false) + /// - Invariant 6: blob sidecar consistency (Deneb to Fulu) + /// - Invariant 7: data column consistency (post-Fulu, when custody_columns provided) + fn check_hot_block_invariants( + &self, + split: &Split, + ctx: &InvariantContext, + ) -> Result { + let mut result = InvariantCheckResult::new(); + + let check_payloads = !self.get_config().prune_payloads; + let bellatrix_fork_slot = self + .spec + .bellatrix_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let deneb_fork_slot = self + .spec + .deneb_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let fulu_fork_slot = self + .spec + .fulu_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let oldest_blob_slot = self.get_blob_info().oldest_blob_slot; + let oldest_data_column_slot = self.get_data_column_info().oldest_data_column_slot; + + for res in self.hot_db.iter_column::(DBColumn::BeaconBlock) { + let (block_root, block_bytes) = res?; + let block = SignedBlindedBeaconBlock::::from_ssz_bytes(&block_bytes, &self.spec)?; + let slot = block.slot(); + + // Invariant 2: block-state consistency. + if slot >= split.slot { + let state_root = block.state_root(); + let has_summary = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSummary, state_root.as_slice())?; + if !has_summary { + result.add_violation(InvariantViolation::HotBlockMissingStateSummary { + block_root, + slot, + state_root, + }); + } + } + + // Invariant 5: execution payload consistency. + // TODO(gloas): reconsider this invariant + if check_payloads + && let Some(bellatrix_slot) = bellatrix_fork_slot + && slot >= bellatrix_slot + && !self.execution_payload_exists(&block_root)? + && !self.payload_envelope_exists(&block_root)? + { + result.add_violation(InvariantViolation::ExecutionPayloadMissing { + block_root, + slot, + }); + } + + // Invariant 6: blob sidecar consistency. + // Only check blocks that actually have blob KZG commitments — blocks with 0 + // commitments legitimately have no blob sidecars stored. + if let Some(deneb_slot) = deneb_fork_slot + && let Some(oldest_blob) = oldest_blob_slot + && slot >= deneb_slot + && slot >= oldest_blob + && fulu_fork_slot.is_none_or(|fulu_slot| slot < fulu_slot) + && block.num_expected_blobs() > 0 + { + let has_blob = self + .blobs_db + .key_exists(DBColumn::BeaconBlob, block_root.as_slice())?; + if !has_blob { + result + .add_violation(InvariantViolation::BlobSidecarMissing { block_root, slot }); + } + } + + // Invariant 7: data column consistency. + // Only check blocks that actually have blob KZG commitments. + // TODO(gloas): reconsider this invariant — non-canonical payloads won't have + // their data column sidecars stored. + if !ctx.custody_columns.is_empty() + && let Some(fulu_slot) = fulu_fork_slot + && let Some(oldest_dc) = oldest_data_column_slot + && slot >= fulu_slot + && slot >= oldest_dc + && block.num_expected_blobs() > 0 + { + let stored_columns = self.get_data_column_keys(block_root)?; + for col_idx in &ctx.custody_columns { + if !stored_columns.contains(col_idx) { + result.add_violation(InvariantViolation::DataColumnMissing { + block_root, + slot, + column_index: *col_idx, + }); + } + } + } + } + + Ok(result) + } + + /// Invariant 3 (Hot DB): State summary diff/snapshot consistency. + /// + /// ```text + /// state_summary in hot_db + /// -> state diff/snapshot/nothing in hot_db per HDiff hierarchy rules + /// ``` + /// + /// Each hot state summary should have the correct storage artifact (snapshot, diff, or + /// nothing) according to the HDiff hierarchy configuration. The hierarchy uses the + /// anchor_slot as its start point for the hot DB. + fn check_hot_state_summary_diff_consistency(&self) -> Result { + let mut result = InvariantCheckResult::new(); + + let anchor_slot = self.get_anchor_info().anchor_slot; + + // Collect all summary slots and their strategies in a first pass. + let mut known_state_roots = HashSet::new(); + let mut base_state_refs: Vec<(Slot, Hash256)> = Vec::new(); + + for res in self + .hot_db + .iter_column::(DBColumn::BeaconStateHotSummary) + { + let (state_root, value) = res?; + let summary = HotStateSummary::from_ssz_bytes(&value)?; + + known_state_roots.insert(state_root); + + match self.hierarchy.storage_strategy(summary.slot, anchor_slot)? { + StorageStrategy::Snapshot => { + let has_snapshot = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSnapshot, state_root.as_slice())?; + if !has_snapshot { + result.add_violation(InvariantViolation::HotStateMissingSnapshot { + state_root, + slot: summary.slot, + }); + } + } + StorageStrategy::DiffFrom(base_slot) => { + let has_diff = self + .hot_db + .key_exists(DBColumn::BeaconStateHotDiff, state_root.as_slice())?; + if !has_diff { + result.add_violation(InvariantViolation::HotStateMissingDiff { + state_root, + slot: summary.slot, + }); + } + if let Ok(base_root) = summary.diff_base_state.get_root(base_slot) { + base_state_refs.push((summary.slot, base_root)); + } + } + StorageStrategy::ReplayFrom(base_slot) => { + if let Ok(base_root) = summary.diff_base_state.get_root(base_slot) { + base_state_refs.push((summary.slot, base_root)); + } + } + } + } + + // Verify that all diff base state roots reference existing summaries. + for (slot, base_state_root) in base_state_refs { + if !known_state_roots.contains(&base_state_root) { + result.add_violation(InvariantViolation::HotStateBaseSummaryMissing { + slot, + base_state_root, + }); + } + } + + Ok(result) + } + + /// Invariant 4 (Hot DB): State summary chain consistency. + /// + /// ```text + /// state_summary in hot_db && state_summary.slot > split.slot + /// -> state_summary for previous_state_root in hot_db + /// ``` + /// + /// The chain of `previous_state_root` links must be continuous back to the split state. + /// The split state itself is the boundary and does not need a predecessor in the hot DB. + fn check_hot_state_summary_chain_consistency( + &self, + split: &Split, + ) -> Result { + let mut result = InvariantCheckResult::new(); + + for res in self + .hot_db + .iter_column::(DBColumn::BeaconStateHotSummary) + { + let (_state_root, value) = res?; + let summary = HotStateSummary::from_ssz_bytes(&value)?; + + if summary.slot > split.slot { + let prev_root = summary.previous_state_root; + let has_prev = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSummary, prev_root.as_slice())?; + if !has_prev { + result.add_violation(InvariantViolation::HotStateMissingPreviousSummary { + slot: summary.slot, + previous_state_root: prev_root, + }); + } + } + } + + Ok(result) + } + + /// Invariant 8 (Hot DB): State cache and disk consistency. + /// + /// ```text + /// state in state_cache -> state_summary in hot_db + /// ``` + /// + /// Every state held in the in-memory state cache (including the finalized state) should + /// have a corresponding hot state summary on disk. + fn check_state_cache_consistency( + &self, + ctx: &InvariantContext, + ) -> Result { + let mut result = InvariantCheckResult::new(); + + for &state_root in &ctx.state_cache_roots { + let has_summary = self + .hot_db + .key_exists(DBColumn::BeaconStateHotSummary, state_root.as_slice())?; + if !has_summary { + result.add_violation(InvariantViolation::StateCacheMissingSummary { state_root }); + } + } + + Ok(result) + } + + /// Invariant 10 (Cold DB): Block root indices. + /// + /// ```text + /// oldest_block_slot <= i < split.slot + /// -> block_root for slot i in cold_db + /// && block for block_root in hot_db + /// ``` + /// + /// Every slot in the cold range (from `oldest_block_slot` to `split.slot`) should have a + /// block root index entry, and the referenced block should exist in the hot DB. Note that + /// skip slots store the most recent non-skipped block's root, so `block.slot()` may differ + /// from the index slot. + fn check_cold_block_root_indices(&self, split: &Split) -> Result { + let mut result = InvariantCheckResult::new(); + + let anchor_info = self.get_anchor_info(); + + if anchor_info.oldest_block_slot >= split.slot { + return Ok(result); + } + + for slot_val in anchor_info.oldest_block_slot.as_u64()..split.slot.as_u64() { + let slot = Slot::new(slot_val); + + let slot_bytes = slot_val.to_be_bytes(); + let block_root_bytes = self + .cold_db + .get_bytes(DBColumn::BeaconBlockRoots, &slot_bytes)?; + + let Some(root_bytes) = block_root_bytes else { + result.add_violation(InvariantViolation::ColdBlockRootMissing { + slot, + oldest_block_slot: anchor_info.oldest_block_slot, + split_slot: split.slot, + }); + continue; + }; + + if root_bytes.len() != 32 { + return Err(Error::InvalidKey(format!( + "cold block root at slot {slot} has invalid length {}", + root_bytes.len() + ))); + } + + let block_root = Hash256::from_slice(&root_bytes); + let block_exists = self + .hot_db + .key_exists(DBColumn::BeaconBlock, block_root.as_slice())?; + if !block_exists { + result.add_violation(InvariantViolation::ColdBlockRootOrphan { slot, block_root }); + } + } + + Ok(result) + } + + /// Invariant 11 (Cold DB): State root indices. + /// + /// ```text + /// (i <= state_lower_limit || i >= min(split.slot, state_upper_limit)) && i < split.slot + /// -> i |-> state_root in cold_db(BeaconStateRoots) + /// && state_root |-> cold_state_summary in cold_db(BeaconColdStateSummary) + /// && cold_state_summary.slot == i + /// ``` + fn check_cold_state_root_indices(&self, split: &Split) -> Result { + let mut result = InvariantCheckResult::new(); + + let anchor_info = self.get_anchor_info(); + + // Expected slots are: (i <= state_lower_limit || i >= effective_upper) && i < split.slot + // where effective_upper = min(split.slot, state_upper_limit). + for slot_val in 0..split.slot.as_u64() { + let slot = Slot::new(slot_val); + + if slot <= anchor_info.state_lower_limit + || slot >= cmp::min(split.slot, anchor_info.state_upper_limit) + { + let slot_bytes = slot_val.to_be_bytes(); + let Some(root_bytes) = self + .cold_db + .get_bytes(DBColumn::BeaconStateRoots, &slot_bytes)? + else { + result.add_violation(InvariantViolation::ColdStateRootMissing { + slot, + state_lower_limit: anchor_info.state_lower_limit, + state_upper_limit: anchor_info.state_upper_limit, + split_slot: split.slot, + }); + continue; + }; + + if root_bytes.len() != 32 { + return Err(Error::InvalidKey(format!( + "cold state root at slot {slot} has invalid length {}", + root_bytes.len() + ))); + } + + let state_root = Hash256::from_slice(&root_bytes); + + match self + .cold_db + .get_bytes(DBColumn::BeaconColdStateSummary, state_root.as_slice())? + { + None => { + result.add_violation(InvariantViolation::ColdStateRootMissingSummary { + slot, + state_root, + }); + } + Some(summary_bytes) => { + let summary = ColdStateSummary::from_ssz_bytes(&summary_bytes)?; + if summary.slot != slot { + result.add_violation(InvariantViolation::ColdStateRootSlotMismatch { + slot, + state_root, + summary_slot: summary.slot, + }); + } + } + } + } + } + + Ok(result) + } + + /// Invariant 12 (Cold DB): Cold state diff/snapshot consistency. + /// + /// ```text + /// cold_state_summary in cold_db + /// -> state diff/snapshot/nothing in cold_db per HDiff hierarchy rules + /// ``` + /// + /// Each cold state summary should have the correct storage artifact according to the + /// HDiff hierarchy. Cold states always use genesis (slot 0) as the hierarchy start since + /// they are finalized and have no anchor_slot dependency. + fn check_cold_state_diff_consistency(&self) -> Result { + let mut result = InvariantCheckResult::new(); + + let mut summary_slots = HashSet::new(); + let mut base_slot_refs = Vec::new(); + + for res in self + .cold_db + .iter_column::(DBColumn::BeaconColdStateSummary) + { + let (state_root, value) = res?; + let summary = ColdStateSummary::from_ssz_bytes(&value)?; + + summary_slots.insert(summary.slot); + + let slot_bytes = summary.slot.as_u64().to_be_bytes(); + + match self + .hierarchy + .storage_strategy(summary.slot, Slot::new(0))? + { + StorageStrategy::Snapshot => { + let has_snapshot = self + .cold_db + .key_exists(DBColumn::BeaconStateSnapshot, &slot_bytes)?; + if !has_snapshot { + result.add_violation(InvariantViolation::ColdStateMissingSnapshot { + state_root, + slot: summary.slot, + }); + } + } + StorageStrategy::DiffFrom(base_slot) => { + let has_diff = self + .cold_db + .key_exists(DBColumn::BeaconStateDiff, &slot_bytes)?; + if !has_diff { + result.add_violation(InvariantViolation::ColdStateMissingDiff { + state_root, + slot: summary.slot, + }); + } + base_slot_refs.push((summary.slot, base_slot)); + } + StorageStrategy::ReplayFrom(base_slot) => { + base_slot_refs.push((summary.slot, base_slot)); + } + } + } + + // Verify that all DiffFrom/ReplayFrom base slots reference existing summaries. + for (slot, base_slot) in base_slot_refs { + if !summary_slots.contains(&base_slot) { + result.add_violation(InvariantViolation::ColdStateBaseSummaryMissing { + slot, + base_slot, + }); + } + } + + Ok(result) + } + + /// Invariant 9 (Hot DB): Pubkey cache consistency. + /// + /// ```text + /// all validator pubkeys from states are in hot_db(PubkeyCache) + /// ``` + /// + /// Checks that the in-memory pubkey cache and the on-disk PubkeyCache column have the same + /// number of entries AND that each pubkey matches at every validator index. + fn check_pubkey_cache_consistency( + &self, + ctx: &InvariantContext, + ) -> Result { + let mut result = InvariantCheckResult::new(); + + // Read on-disk pubkeys by sequential validator index (matching how they are stored + // with Hash256::from_low_u64_be(index) as key). + // Iterate in-memory pubkeys and verify each matches on disk. + for (validator_index, in_memory_bytes) in ctx.pubkey_cache_pubkeys.iter().enumerate() { + let mut key = [0u8; 32]; + key[24..].copy_from_slice(&(validator_index as u64).to_be_bytes()); + match self.hot_db.get_bytes(DBColumn::PubkeyCache, &key)? { + Some(on_disk_bytes) if in_memory_bytes != &on_disk_bytes => { + result + .add_violation(InvariantViolation::PubkeyCacheMismatch { validator_index }); + } + None => { + result + .add_violation(InvariantViolation::PubkeyCacheMissing { validator_index }); + } + _ => {} + } + } + + Ok(result) + } +} diff --git a/beacon_node/store/src/lib.rs b/beacon_node/store/src/lib.rs index 3363eb800c..bfa1200602 100644 --- a/beacon_node/store/src/lib.rs +++ b/beacon_node/store/src/lib.rs @@ -15,6 +15,7 @@ pub mod hdiff; pub mod historic_state_cache; pub mod hot_cold_store; mod impls; +pub mod invariants; mod memory_store; pub mod metadata; pub mod metrics; diff --git a/beacon_node/store/src/state_cache.rs b/beacon_node/store/src/state_cache.rs index 4b0d1ee016..6d159c9361 100644 --- a/beacon_node/store/src/state_cache.rs +++ b/beacon_node/store/src/state_cache.rs @@ -111,6 +111,19 @@ impl StateCache { self.hdiff_buffers.mem_usage() } + /// Return all state roots currently held in the cache, including the finalized state. + pub fn state_roots(&self) -> Vec { + let mut roots: Vec = self + .states + .iter() + .map(|(&state_root, _)| state_root) + .collect(); + if let Some(ref finalized) = self.finalized_state { + roots.push(finalized.state_root); + } + roots + } + pub fn update_finalized_state( &mut self, state_root: Hash256, From bff72a920da50a2abefa44b75c98b9597200ee8a Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 12 Mar 2026 10:06:25 +1100 Subject: [PATCH 064/189] Update database and block replayer to handle payload envelopes (#8886) Closes: - https://github.com/sigp/lighthouse/issues/8869 - Update `BlockReplayer` to support replay of execution payload envelopes. - Update `HotColdDB` to load payload envelopes and feed them to the `BlockReplayer` for both hot + cold states. However the cold DB code is not fully working yet (see: https://github.com/sigp/lighthouse/issues/8958). - Add `StatePayloadStatus` to allow callers to specify whether they want a state with a payload applied, or not. - Fix the state cache to key by `StatePayloadStatus`. - Lots of fixes to block production and block processing regarding state management. - Initial test harness support for producing+processing Gloas blocks+envelopes - A few new tests to cover Gloas DB operations Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul Co-Authored-By: Jimmy Chen --- beacon_node/beacon_chain/src/beacon_chain.rs | 27 +- .../beacon_chain/src/blob_verification.rs | 10 +- .../src/block_production/gloas.rs | 15 +- .../beacon_chain/src/block_production/mod.rs | 41 +- .../beacon_chain/src/block_verification.rs | 37 +- beacon_node/beacon_chain/src/builder.rs | 12 +- .../beacon_chain/src/canonical_head.rs | 13 +- .../src/data_column_verification.rs | 11 +- .../src/schema_change/migration_schema_v24.rs | 2 + .../beacon_chain/src/state_advance_timer.rs | 14 +- beacon_node/beacon_chain/src/test_utils.rs | 164 ++++++- beacon_node/beacon_chain/tests/rewards.rs | 3 +- beacon_node/beacon_chain/tests/store_tests.rs | 443 +++++++++++++++++- .../test_utils/execution_block_generator.rs | 10 +- .../http_api/src/attestation_performance.rs | 3 +- .../http_api/src/block_packing_efficiency.rs | 3 +- beacon_node/http_api/src/produce_block.rs | 2 +- .../http_api/src/sync_committee_rewards.rs | 3 +- beacon_node/store/src/hdiff.rs | 6 + beacon_node/store/src/hot_cold_store.rs | 252 ++++++++-- beacon_node/store/src/reconstruct.rs | 1 + beacon_node/store/src/state_cache.rs | 43 +- .../state_processing/src/block_replayer.rs | 136 +++++- .../src/envelope_processing.rs | 2 - .../src/per_block_processing/tests.rs | 2 +- .../state_processing/src/state_advance.rs | 5 + .../types/src/block/signed_beacon_block.rs | 27 ++ consensus/types/src/execution/mod.rs | 2 + .../src/execution/state_payload_status.rs | 18 + consensus/types/src/state/beacon_state.rs | 20 +- 30 files changed, 1243 insertions(+), 84 deletions(-) create mode 100644 consensus/types/src/execution/state_payload_status.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 07f3bb01fa..ab2097e001 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -2031,9 +2031,16 @@ impl BeaconChain { // required information. (justified_checkpoint, committee_len) } else { + // We assume that the `Pending` state has the same shufflings as a `Full` state + // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root let (advanced_state_root, mut state) = self .store - .get_advanced_hot_state(beacon_block_root, request_slot, beacon_state_root)? + .get_advanced_hot_state( + beacon_block_root, + StatePayloadStatus::Pending, + request_slot, + beacon_state_root, + )? .ok_or(Error::MissingBeaconState(beacon_state_root))?; if state.current_epoch() < request_epoch { partial_state_advance( @@ -4662,12 +4669,19 @@ impl BeaconChain { if cached_head.head_block_root() == parent_block_root { (Cow::Borrowed(head_state), cached_head.head_state_root()) } else { + // TODO(gloas): this function needs updating to be envelope-aware + // See: https://github.com/sigp/lighthouse/issues/8957 let block = self .get_blinded_block(&parent_block_root)? .ok_or(Error::MissingBeaconBlock(parent_block_root))?; let (state_root, state) = self .store - .get_advanced_hot_state(parent_block_root, proposal_slot, block.state_root())? + .get_advanced_hot_state( + parent_block_root, + StatePayloadStatus::Pending, + proposal_slot, + block.state_root(), + )? .ok_or(Error::MissingBeaconState(block.state_root()))?; (Cow::Owned(state), state_root) }; @@ -6599,9 +6613,16 @@ impl BeaconChain { let (mut state, state_root) = if let Some((state, state_root)) = head_state_opt { (state, state_root) } else { + // We assume that the `Pending` state has the same shufflings as a `Full` state + // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root let (state_root, state) = self .store - .get_advanced_hot_state(head_block_root, target_slot, head_block.state_root)? + .get_advanced_hot_state( + head_block_root, + StatePayloadStatus::Pending, + target_slot, + head_block.state_root, + )? .ok_or(Error::MissingBeaconState(head_block.state_root))?; (state, state_root) }; diff --git a/beacon_node/beacon_chain/src/blob_verification.rs b/beacon_node/beacon_chain/src/blob_verification.rs index fe111628db..86b385d818 100644 --- a/beacon_node/beacon_chain/src/blob_verification.rs +++ b/beacon_node/beacon_chain/src/blob_verification.rs @@ -20,6 +20,7 @@ use tree_hash::TreeHash; use types::data::BlobIdentifier; use types::{ BeaconStateError, BlobSidecar, Epoch, EthSpec, Hash256, SignedBeaconBlockHeader, Slot, + StatePayloadStatus, }; /// An error occurred while validating a gossip blob. @@ -508,9 +509,16 @@ pub fn validate_blob_sidecar_for_gossip = (BeaconBlock>, ConsensusBlockValue); +type BlockProductionResult = (BeaconBlock, BeaconState, ConsensusBlockValue); pub type PreparePayloadResult = Result, BlockProductionError>; pub type PreparePayloadHandle = JoinHandle>>; @@ -425,6 +425,12 @@ impl BeaconChain { )) } + /// Complete a block by computing its state root, and + /// + /// Return `(block, pending_state, block_value)` where: + /// + /// - `pending_state` is the state post block application (prior to payload application) + /// - `block_value` is the consensus-layer rewards for `block` #[allow(clippy::type_complexity)] fn complete_partial_beacon_block_gloas( &self, @@ -433,7 +439,7 @@ impl BeaconChain { payload_data: Option>, mut state: BeaconState, verification: ProduceBlockVerification, - ) -> Result<(BeaconBlock>, u64), BlockProductionError> { + ) -> Result, BlockProductionError> { let PartialBeaconBlock { slot, proposer_index, @@ -545,6 +551,9 @@ impl BeaconChain { drop(state_root_timer); + // Clone the Pending state (post-block, pre-envelope) for callers that need it. + let pending_state = state.clone(); + let (mut block, _) = signed_beacon_block.deconstruct(); *block.state_root_mut() = state_root; @@ -605,7 +614,7 @@ impl BeaconChain { "Produced beacon block" ); - Ok((block, consensus_block_value)) + Ok((block, pending_state, consensus_block_value)) } // TODO(gloas) introduce `ProposerPreferences` so we can build out trustless diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 76c8b77e93..b33323f527 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -3,7 +3,7 @@ use std::{sync::Arc, time::Duration}; use proto_array::ProposerHeadError; use slot_clock::SlotClock; use tracing::{debug, error, info, instrument, warn}; -use types::{BeaconState, Hash256, Slot}; +use types::{BeaconState, Hash256, Slot, StatePayloadStatus}; use crate::{ BeaconChain, BeaconChainTypes, BlockProductionError, StateSkipConfig, @@ -37,8 +37,14 @@ impl BeaconChain { }; let (state, state_root_opt) = if head_slot < slot { // Attempt an aggressive re-org if configured and the conditions are right. - if let Some((re_org_state, re_org_state_root)) = - self.get_state_for_re_org(slot, head_slot, head_block_root) + // TODO(gloas): re-enable reorgs + let gloas_enabled = self + .spec + .fork_name_at_slot::(slot) + .gloas_enabled(); + if !gloas_enabled + && let Some((re_org_state, re_org_state_root)) = + self.get_state_for_re_org(slot, head_slot, head_block_root) { info!( %slot, @@ -49,9 +55,30 @@ impl BeaconChain { } else { // Fetch the head state advanced through to `slot`, which should be present in the // state cache thanks to the state advance timer. + // TODO(gloas): need to fix this once fork choice understands payloads + // for now we just use the existence of the head's payload envelope to determine + // whether we should build atop it + let (payload_status, parent_state_root) = if gloas_enabled + && let Ok(Some(envelope)) = self.store.get_payload_envelope(&head_block_root) + { + debug!( + %slot, + parent_state_root = ?envelope.message.state_root, + parent_block_root = ?head_block_root, + "Building Gloas block on full state" + ); + (StatePayloadStatus::Full, envelope.message.state_root) + } else { + (StatePayloadStatus::Pending, head_state_root) + }; let (state_root, state) = self .store - .get_advanced_hot_state(head_block_root, slot, head_state_root) + .get_advanced_hot_state( + head_block_root, + payload_status, + slot, + parent_state_root, + ) .map_err(BlockProductionError::FailedToLoadState)? .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; (state, Some(state_root)) @@ -204,7 +231,11 @@ impl BeaconChain { let (state_root, state) = self .store - .get_advanced_hot_state_from_cache(re_org_parent_block, slot) + .get_advanced_hot_state_from_cache( + re_org_parent_block, + StatePayloadStatus::Pending, + slot, + ) .or_else(|| { warn!(reason = "no state in cache", "Not attempting re-org"); None diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index b748bf5c6c..1be9bd4181 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -99,7 +99,8 @@ use tracing::{Instrument, Span, debug, debug_span, error, info_span, instrument} use types::{ BeaconBlockRef, BeaconState, BeaconStateError, BlobsList, ChainSpec, DataColumnSidecarList, Epoch, EthSpec, FullPayload, Hash256, InconsistentFork, KzgProofs, RelativeEpoch, - SignedBeaconBlock, SignedBeaconBlockHeader, Slot, data::DataColumnSidecarError, + SignedBeaconBlock, SignedBeaconBlockHeader, Slot, StatePayloadStatus, + data::DataColumnSidecarError, }; /// Maximum block slot number. Block with slots bigger than this constant will NOT be processed. @@ -1491,7 +1492,11 @@ impl ExecutionPendingBlock { let distance = block.slot().as_u64().saturating_sub(state.slot().as_u64()); for _ in 0..distance { - let state_root = if parent.beacon_block.slot() == state.slot() { + // TODO(gloas): could do a similar optimisation here for Full blocks if we have access + // to the parent envelope and its `state_root`. + let state_root = if parent.beacon_block.slot() == state.slot() + && state.payload_status() == StatePayloadStatus::Pending + { // If it happens that `pre_state` has *not* already been advanced forward a single // slot, then there is no need to compute the state root for this // `per_slot_processing` call since that state root is already stored in the parent @@ -1908,9 +1913,31 @@ fn load_parent>( // Retrieve any state that is advanced through to at most `block.slot()`: this is // particularly important if `block` descends from the finalized/split block, but at a slot // prior to the finalized slot (which is invalid and inaccessible in our DB schema). + // + // Post-Gloas we must also fetch a state with the correct payload status. If the current + // block builds upon the payload of its parent block, then we know the parent block is FULL + // and we need to load the full state. + let (payload_status, parent_state_root) = + if block.as_block().fork_name_unchecked().gloas_enabled() + && let Ok(parent_bid_block_hash) = parent_block.payload_bid_block_hash() + { + if block.as_block().is_parent_block_full(parent_bid_block_hash) { + // TODO(gloas): loading the envelope here is not very efficient + let envelope = chain.store.get_payload_envelope(&root)?.ok_or_else(|| { + BeaconChainError::DBInconsistent(format!( + "Missing envelope for parent block {root:?}", + )) + })?; + (StatePayloadStatus::Full, envelope.message.state_root) + } else { + (StatePayloadStatus::Pending, parent_block.state_root()) + } + } else { + (StatePayloadStatus::Pending, parent_block.state_root()) + }; let (parent_state_root, state) = chain .store - .get_advanced_hot_state(root, block.slot(), parent_block.state_root())? + .get_advanced_hot_state(root, payload_status, block.slot(), parent_state_root)? .ok_or_else(|| { BeaconChainError::DBInconsistent( format!("Missing state for parent block {root:?}",), @@ -1933,7 +1960,9 @@ fn load_parent>( ); } - let beacon_state_root = if state.slot() == parent_block.slot() { + let beacon_state_root = if state.slot() == parent_block.slot() + && let StatePayloadStatus::Pending = payload_status + { // Sanity check. if parent_state_root != parent_block.state_root() { return Err(BeaconChainError::DBInconsistent(format!( diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index d5935b492a..59fa5ec9ec 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -45,7 +45,7 @@ use tree_hash::TreeHash; use types::data::CustodyIndex; use types::{ BeaconBlock, BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, - Epoch, EthSpec, Hash256, SignedBeaconBlock, Slot, + Epoch, EthSpec, Hash256, SignedBeaconBlock, Slot, StatePayloadStatus, }; /// An empty struct used to "witness" all the `BeaconChainTypes` traits. It has no user-facing @@ -783,8 +783,16 @@ where .map_err(|e| descriptive_db_error("head block", &e))? .ok_or("Head block not found in store")?; + // TODO(gloas): update head loading to load Full block once fork choice works + let payload_status = StatePayloadStatus::Pending; + let (_head_state_root, head_state) = store - .get_advanced_hot_state(head_block_root, current_slot, head_block.state_root()) + .get_advanced_hot_state( + head_block_root, + payload_status, + current_slot, + head_block.state_root(), + ) .map_err(|e| descriptive_db_error("head state", &e))? .ok_or("Head state not found in store")?; diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 1a08ac3f88..fd060e2b59 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -305,8 +305,16 @@ impl CanonicalHead { .get_full_block(&beacon_block_root)? .ok_or(Error::MissingBeaconBlock(beacon_block_root))?; let current_slot = fork_choice.fc_store().get_current_slot(); + + // TODO(gloas): pass a better payload status once fork choice is implemented + let payload_status = StatePayloadStatus::Pending; let (_, beacon_state) = store - .get_advanced_hot_state(beacon_block_root, current_slot, beacon_block.state_root())? + .get_advanced_hot_state( + beacon_block_root, + payload_status, + current_slot, + beacon_block.state_root(), + )? .ok_or(Error::MissingBeaconState(beacon_block.state_root()))?; let snapshot = BeaconSnapshot { @@ -673,10 +681,13 @@ impl BeaconChain { .get_full_block(&new_view.head_block_root)? .ok_or(Error::MissingBeaconBlock(new_view.head_block_root))?; + // TODO(gloas): update once we have fork choice + let payload_status = StatePayloadStatus::Pending; let (_, beacon_state) = self .store .get_advanced_hot_state( new_view.head_block_root, + payload_status, current_slot, beacon_block.state_root(), )? diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index 08acfdffa4..dde9fad342 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -20,7 +20,7 @@ use tracing::{debug, instrument}; use types::data::ColumnIndex; use types::{ BeaconStateError, ChainSpec, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSubnetId, - EthSpec, Hash256, Slot, + EthSpec, Hash256, Slot, StatePayloadStatus, }; /// An error occurred while validating a gossip data column. @@ -706,9 +706,16 @@ fn verify_proposer_and_signature( index = %column_index, "Proposer shuffling cache miss for column verification" ); + // We assume that the `Pending` state has the same shufflings as a `Full` state + // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root chain .store - .get_advanced_hot_state(block_parent_root, column_slot, parent_block.state_root) + .get_advanced_hot_state( + block_parent_root, + StatePayloadStatus::Pending, + column_slot, + parent_block.state_root, + ) .map_err(|e| GossipDataColumnError::BeaconChainError(Box::new(e.into())))? .ok_or_else(|| { GossipDataColumnError::BeaconChainError(Box::new( diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs index 1e1823a836..c8dfe1ac9b 100644 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs +++ b/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs @@ -16,6 +16,7 @@ use store::{ use tracing::{debug, info, warn}; use types::{ BeaconState, CACHED_EPOCHS, ChainSpec, Checkpoint, CommitteeCache, EthSpec, Hash256, Slot, + execution::StatePayloadStatus, }; /// We stopped using the pruning checkpoint in schema v23 but never explicitly deleted it. @@ -58,6 +59,7 @@ pub fn get_state_v22( base_state, summary.slot, summary.latest_block_root, + StatePayloadStatus::Pending, update_cache, ) .map(Some) diff --git a/beacon_node/beacon_chain/src/state_advance_timer.rs b/beacon_node/beacon_chain/src/state_advance_timer.rs index cb916cb514..4c070e7ecc 100644 --- a/beacon_node/beacon_chain/src/state_advance_timer.rs +++ b/beacon_node/beacon_chain/src/state_advance_timer.rs @@ -26,7 +26,10 @@ use std::sync::{ use task_executor::TaskExecutor; use tokio::time::{Instant, sleep, sleep_until}; use tracing::{Instrument, debug, debug_span, error, instrument, warn}; -use types::{AttestationShufflingId, BeaconStateError, EthSpec, Hash256, RelativeEpoch, Slot}; +use types::{ + AttestationShufflingId, BeaconStateError, EthSpec, Hash256, RelativeEpoch, Slot, + StatePayloadStatus, +}; /// If the head slot is more than `MAX_ADVANCE_DISTANCE` from the current slot, then don't perform /// the state advancement. @@ -277,9 +280,16 @@ fn advance_head(beacon_chain: &Arc>) -> Resu (snapshot.beacon_block_root, snapshot.beacon_state_root()) }; + // TODO(gloas): do better once we have fork choice + let payload_status = StatePayloadStatus::Pending; let (head_state_root, mut state) = beacon_chain .store - .get_advanced_hot_state(head_block_root, current_slot, head_block_state_root)? + .get_advanced_hot_state( + head_block_root, + payload_status, + current_slot, + head_block_state_root, + )? .ok_or(Error::HeadMissingFromSnapshotCache(head_block_root))?; let initial_slot = state.slot(); diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index eefb5d48b7..4bc5bb21d3 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -27,7 +27,7 @@ use bls::{ use eth2::types::{GraffitiPolicy, SignedBlockContentsTuple}; use execution_layer::test_utils::generate_genesis_header; use execution_layer::{ - ExecutionLayer, + ExecutionLayer, NewPayloadRequest, NewPayloadRequestGloas, auth::JwtKey, test_utils::{DEFAULT_JWT_SECRET, ExecutionBlockGenerator, MockBuilder, MockExecutionLayer}, }; @@ -52,7 +52,8 @@ use ssz_types::{RuntimeVariableList, VariableList}; use state_processing::ConsensusContext; use state_processing::per_block_processing::compute_timestamp_at_slot; use state_processing::per_block_processing::{ - BlockSignatureStrategy, VerifyBlockRoot, per_block_processing, + BlockSignatureStrategy, VerifyBlockRoot, deneb::kzg_commitment_to_versioned_hash, + per_block_processing, }; use state_processing::state_advance::complete_state_advance; use std::borrow::Cow; @@ -66,6 +67,7 @@ use store::database::interface::BeaconNodeBackend; use store::{HotColdDB, ItemStore, MemoryStore, config::StoreConfig}; use task_executor::TaskExecutor; use task_executor::{ShutdownReason, test_utils::TestRuntime}; +use tracing::debug; use tree_hash::TreeHash; use typenum::U4294967296; use types::attestation::IndexedAttestationBase; @@ -1092,6 +1094,86 @@ where (block_contents, block_response.state) } + /// Returns a newly created block, signed by the proposer for the given slot, + /// along with the execution payload envelope (for Gloas) and the pending state. + /// + /// For pre-Gloas forks, the envelope is `None` and this behaves like `make_block`. + pub async fn make_block_with_envelope( + &self, + mut state: BeaconState, + slot: Slot, + ) -> ( + SignedBlockContentsTuple, + Option>, + BeaconState, + ) { + assert_ne!(slot, 0, "can't produce a block at slot 0"); + assert!(slot >= state.slot()); + + if state.fork_name_unchecked().gloas_enabled() + || self.spec.fork_name_at_slot::(slot).gloas_enabled() + { + complete_state_advance(&mut state, None, slot, &self.spec) + .expect("should be able to advance state to slot"); + state.build_caches(&self.spec).expect("should build caches"); + + let proposer_index = state.get_beacon_proposer_index(slot, &self.spec).unwrap(); + + let graffiti = Graffiti::from(self.rng.lock().random::<[u8; 32]>()); + let graffiti_settings = + GraffitiSettings::new(Some(graffiti), Some(GraffitiPolicy::PreserveUserGraffiti)); + let randao_reveal = self.sign_randao_reveal(&state, proposer_index, slot); + + let (block, pending_state, _consensus_block_value) = self + .chain + .produce_block_on_state_gloas( + state, + None, + slot, + randao_reveal, + graffiti_settings, + ProduceBlockVerification::VerifyRandao, + ) + .await + .unwrap(); + + let signed_block = Arc::new(block.sign( + &self.validator_keypairs[proposer_index].sk, + &pending_state.fork(), + pending_state.genesis_validators_root(), + &self.spec, + )); + + // Retrieve the cached envelope produced during block production and sign it. + let signed_envelope = self + .chain + .pending_payload_envelopes + .write() + .remove(slot) + .map(|envelope| { + let epoch = slot.epoch(E::slots_per_epoch()); + let domain = self.spec.get_domain( + epoch, + Domain::BeaconBuilder, + &pending_state.fork(), + pending_state.genesis_validators_root(), + ); + let message = envelope.signing_root(domain); + let signature = self.validator_keypairs[proposer_index].sk.sign(message); + SignedExecutionPayloadEnvelope { + message: envelope, + signature, + } + }); + + let block_contents: SignedBlockContentsTuple = (signed_block, None); + (block_contents, signed_envelope, pending_state) + } else { + let (block_contents, state) = self.make_block(state, slot).await; + (block_contents, None, state) + } + } + /// Useful for the `per_block_processing` tests. Creates a block, and returns the state after /// caches are built but before the generated block is processed. pub async fn make_block_return_pre_state( @@ -2575,6 +2657,84 @@ where Ok(block_hash) } + /// Process an execution payload envelope for a Gloas block. + pub async fn process_envelope( + &self, + block_root: Hash256, + signed_envelope: SignedExecutionPayloadEnvelope, + pending_state: &mut BeaconState, + ) -> Hash256 { + let state_root = signed_envelope.message.state_root; + debug!( + slot = %signed_envelope.message.slot, + ?state_root, + "Processing execution payload envelope" + ); + let block_state_root = pending_state + .update_tree_hash_cache() + .expect("should compute pending state root"); + + state_processing::envelope_processing::process_execution_payload_envelope( + pending_state, + Some(block_state_root), + &signed_envelope, + state_processing::VerifySignatures::True, + state_processing::envelope_processing::VerifyStateRoot::True, + &self.spec, + ) + .expect("should process envelope"); + + // Notify the EL of the new payload so forkchoiceUpdated can reference it. + let block = self + .chain + .store + .get_blinded_block(&block_root) + .expect("should read block from store") + .expect("block should exist in store"); + + let bid = &block + .message() + .body() + .signed_execution_payload_bid() + .expect("Gloas block should have a payload bid") + .message; + + let versioned_hashes = bid + .blob_kzg_commitments + .iter() + .map(kzg_commitment_to_versioned_hash) + .collect(); + + let request = NewPayloadRequest::Gloas(NewPayloadRequestGloas { + execution_payload: &signed_envelope.message.payload, + versioned_hashes, + parent_beacon_block_root: block.message().parent_root(), + execution_requests: &signed_envelope.message.execution_requests, + }); + + self.chain + .execution_layer + .as_ref() + .expect("harness should have execution layer") + .notify_new_payload(request) + .await + .expect("newPayload should succeed"); + + // Store the envelope. + self.chain + .store + .put_payload_envelope(&block_root, signed_envelope) + .expect("should store envelope"); + + // Store the Full state. + self.chain + .store + .put_state(&state_root, pending_state) + .expect("should store full state"); + + state_root + } + /// Builds an `Rpc` block from a `SignedBeaconBlock` and blobs or data columns retrieved from /// the database. pub fn build_rpc_block_from_store_blobs( diff --git a/beacon_node/beacon_chain/tests/rewards.rs b/beacon_node/beacon_chain/tests/rewards.rs index bc7c98041f..1889c1f625 100644 --- a/beacon_node/beacon_chain/tests/rewards.rs +++ b/beacon_node/beacon_chain/tests/rewards.rs @@ -845,13 +845,14 @@ async fn check_all_base_rewards_for_subset( .state_at_slot(Slot::new(slot - 1), StateSkipConfig::WithoutStateRoots) .unwrap(); + // TODO(gloas): handle payloads? let mut pre_state = BlockReplayer::>::new( parent_state, &harness.spec, ) .no_signature_verification() .minimal_block_root_verification() - .apply_blocks(vec![], Some(block.slot())) + .apply_blocks(vec![], vec![], Some(block.slot())) .unwrap() .into_state(); diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 86f4af3efc..a70ad89ca9 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -708,8 +708,13 @@ async fn block_replayer_hooks() { .add_attested_blocks_at_slots(state.clone(), state_root, &block_slots, &all_validators) .await; - let blocks = store - .load_blocks_to_replay(Slot::new(0), max_slot, end_block_root.into()) + let (blocks, envelopes) = store + .load_blocks_to_replay( + Slot::new(0), + max_slot, + end_block_root.into(), + StatePayloadStatus::Pending, + ) .unwrap(); let mut pre_slots = vec![]; @@ -744,7 +749,7 @@ async fn block_replayer_hooks() { post_block_slots.push(block.slot()); Ok(()) })) - .apply_blocks(blocks, None) + .apply_blocks(blocks, envelopes, None) .unwrap() .into_state(); @@ -3842,7 +3847,12 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { let (split_state_root, mut advanced_split_state) = harness .chain .store - .get_advanced_hot_state(split.block_root, split.slot, split.state_root) + .get_advanced_hot_state( + split.block_root, + StatePayloadStatus::Pending, + split.slot, + split.state_root, + ) .unwrap() .unwrap(); complete_state_advance( @@ -5470,6 +5480,427 @@ fn check_finalization(harness: &TestHarness, expected_slot: u64) { ); } +// ===================== Gloas Store Tests ===================== + +/// Test basic Gloas block + envelope storage and retrieval. +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_no_skips() { + test_gloas_block_and_envelope_storage_generic(32, vec![], false).await +} + +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_some_skips() { + test_gloas_block_and_envelope_storage_generic(32, vec![2, 4, 5, 16, 23, 24, 25], false).await +} + +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_no_skips_w_cache() { + test_gloas_block_and_envelope_storage_generic(32, vec![], true).await +} + +#[tokio::test] +async fn test_gloas_block_and_envelope_storage_some_skips_w_cache() { + test_gloas_block_and_envelope_storage_generic(32, vec![2, 4, 5, 16, 23, 24, 25], true).await +} + +async fn test_gloas_block_and_envelope_storage_generic( + num_slots: u64, + skipped_slots: Vec, + use_state_cache: bool, +) { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store_config = if !use_state_cache { + StoreConfig { + state_cache_size: new_non_zero_usize(1), + ..StoreConfig::default() + } + } else { + StoreConfig::default() + }; + let spec = test_spec::(); + let store = get_store_generic(&db_path, store_config, spec); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + let spec = &harness.chain.spec; + + let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); + let mut state = genesis_state; + + let mut block_roots = vec![]; + let mut stored_states = vec![(Slot::new(0), StatePayloadStatus::Full, genesis_state_root)]; + + for i in 1..=num_slots { + let slot = Slot::new(i); + harness.advance_slot(); + + if skipped_slots.contains(&i) { + complete_state_advance(&mut state, None, slot, spec) + .expect("should be able to advance state to slot"); + + let state_root = state.canonical_root().unwrap(); + store.put_state(&state_root, &state).unwrap(); + stored_states.push((slot, state.payload_status(), state_root)); + } + + let (block_contents, envelope, mut pending_state) = + harness.make_block_with_envelope(state, slot).await; + let block_root = block_contents.0.canonical_root(); + + // Process the block. + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + let pending_state_root = pending_state.update_tree_hash_cache().unwrap(); + stored_states.push((slot, StatePayloadStatus::Pending, pending_state_root)); + + // Process the envelope. + let envelope = envelope.expect("Gloas block should have envelope"); + let mut full_state = pending_state.clone(); + let envelope_state_root = envelope.message.state_root; + let full_state_root = harness + .process_envelope(block_root, envelope, &mut full_state) + .await; + assert_eq!(full_state_root, envelope_state_root); + stored_states.push((slot, StatePayloadStatus::Full, full_state_root)); + + block_roots.push(block_root); + state = full_state; + } + + // Verify block storage. + for (i, block_root) in block_roots.iter().enumerate() { + // Block can be loaded. + assert!( + store.get_blinded_block(block_root).unwrap().is_some(), + "block at slot {} should be in DB", + i + 1 + ); + + // Envelope can be loaded. + let loaded_envelope = store.get_payload_envelope(block_root).unwrap(); + assert!( + loaded_envelope.is_some(), + "envelope at slot {} should be in DB", + i + 1 + ); + } + + // Verify state storage. + // Iterate in reverse order to frustrate the cache. + for (slot, payload_status, state_root) in stored_states.into_iter().rev() { + println!("{slot}: {state_root:?}"); + let Some(mut loaded_state) = store + .get_state(&state_root, Some(slot), CACHE_STATE_IN_TESTS) + .unwrap() + else { + panic!("missing {payload_status:?} state at slot {slot} with root {state_root:?}"); + }; + assert_eq!(loaded_state.slot(), slot); + assert_eq!( + loaded_state.payload_status(), + payload_status, + "slot = {slot}" + ); + assert_eq!( + loaded_state.canonical_root().unwrap(), + state_root, + "slot = {slot}" + ); + } +} + +/// Test that Pending and Full states have the correct payload status through round-trip +/// storage and retrieval. +#[tokio::test] +async fn test_gloas_state_payload_status() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + let num_blocks = 6u64; + let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); + let mut state = genesis_state; + + for i in 1..=num_blocks { + let slot = Slot::new(i); + harness.advance_slot(); + + let (block_contents, envelope, pending_state) = + harness.make_block_with_envelope(state, slot).await; + let block_root = block_contents.0.canonical_root(); + + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + // Verify the pending state has correct payload status. + assert_eq!( + pending_state.payload_status(), + StatePayloadStatus::Pending, + "pending state at slot {} should be Pending", + i + ); + + // Process the envelope and verify the full state has correct payload status. + let envelope = envelope.expect("Gloas block should have envelope"); + let mut full_state = pending_state; + let full_state_root = harness + .process_envelope(block_root, envelope, &mut full_state) + .await; + + assert_eq!( + full_state.payload_status(), + StatePayloadStatus::Full, + "full state at slot {} should be Full", + i + ); + + // Round-trip: load the full state from DB and check status. + let loaded_full = store + .get_state(&full_state_root, None, CACHE_STATE_IN_TESTS) + .unwrap() + .expect("full state should exist in DB"); + assert_eq!( + loaded_full.payload_status(), + StatePayloadStatus::Full, + "loaded full state at slot {} should be Full after round-trip", + i + ); + + state = full_state; + } +} + +/// Test block replay with and without envelopes. +#[tokio::test] +async fn test_gloas_block_replay_with_envelopes() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + let num_blocks = 16u64; + let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); + let mut state = genesis_state.clone(); + + let mut last_block_root = Hash256::zero(); + let mut pending_states = HashMap::new(); + let mut full_states = HashMap::new(); + + for i in 1..=num_blocks { + let slot = Slot::new(i); + harness.advance_slot(); + + let (block_contents, envelope, pending_state) = + harness.make_block_with_envelope(state, slot).await; + let block_root = block_contents.0.canonical_root(); + + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + let pending_state_root = pending_state.clone().update_tree_hash_cache().unwrap(); + pending_states.insert(slot, (pending_state_root, pending_state.clone())); + + let envelope = envelope.expect("Gloas block should have envelope"); + let mut full_state = pending_state; + let full_state_root = harness + .process_envelope(block_root, envelope, &mut full_state) + .await; + full_states.insert(slot, (full_state_root, full_state.clone())); + + last_block_root = block_root; + state = full_state; + } + + let end_slot = Slot::new(num_blocks); + + // Load blocks for Pending replay (no envelopes for the last block). + let (blocks_pending, envelopes_pending) = store + .load_blocks_to_replay( + Slot::new(0), + end_slot, + last_block_root, + StatePayloadStatus::Pending, + ) + .unwrap(); + assert!( + !blocks_pending.is_empty(), + "should have blocks for pending replay" + ); + // For Pending, no envelope for the first block (slot 0) or last block; envelopes for + // intermediate blocks whose payloads are canonical. + let expected_pending_envelopes = blocks_pending.len().saturating_sub(2); + assert_eq!( + envelopes_pending.len(), + expected_pending_envelopes, + "pending replay should have envelopes for all blocks except the last" + ); + assert!( + blocks_pending + .iter() + .skip(1) + .take(envelopes_pending.len()) + .map(|block| block.slot()) + .eq(envelopes_pending + .iter() + .map(|envelope| envelope.message.slot)), + "block and envelope slots should match" + ); + + // Load blocks for Full replay (envelopes for all blocks including the last). + let (blocks_full, envelopes_full) = store + .load_blocks_to_replay( + Slot::new(0), + end_slot, + last_block_root, + StatePayloadStatus::Full, + ) + .unwrap(); + assert_eq!( + envelopes_full.len(), + expected_pending_envelopes + 1, + "full replay should have one more envelope than pending replay" + ); + + // Replay to Pending state and verify. + let mut replayed_pending = + BlockReplayer::::new(genesis_state.clone(), store.get_chain_spec()) + .no_signature_verification() + .minimal_block_root_verification() + .desired_state_payload_status(StatePayloadStatus::Pending) + .apply_blocks(blocks_pending, envelopes_pending, None) + .expect("should replay blocks to pending state") + .into_state(); + replayed_pending.apply_pending_mutations().unwrap(); + + let (_, mut expected_pending) = pending_states.get(&end_slot).unwrap().clone(); + expected_pending.apply_pending_mutations().unwrap(); + + replayed_pending.drop_all_caches().unwrap(); + expected_pending.drop_all_caches().unwrap(); + assert_eq!( + replayed_pending, expected_pending, + "replayed pending state should match stored pending state" + ); + + // Replay to Full state and verify. + let mut replayed_full = + BlockReplayer::::new(genesis_state, store.get_chain_spec()) + .no_signature_verification() + .minimal_block_root_verification() + .desired_state_payload_status(StatePayloadStatus::Full) + .apply_blocks(blocks_full, envelopes_full, None) + .expect("should replay blocks to full state") + .into_state(); + replayed_full.apply_pending_mutations().unwrap(); + + let (_, mut expected_full) = full_states.get(&end_slot).unwrap().clone(); + expected_full.apply_pending_mutations().unwrap(); + + replayed_full.drop_all_caches().unwrap(); + expected_full.drop_all_caches().unwrap(); + assert_eq!( + replayed_full, expected_full, + "replayed full state should match stored full state" + ); +} + +/// Test the hot state hierarchy with Full states stored as ReplayFrom. +#[tokio::test] +async fn test_gloas_hot_state_hierarchy() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + // Build enough blocks to span multiple epochs. With MinimalEthSpec (8 slots/epoch), + // 40 slots covers 5 epochs. + let num_blocks = E::slots_per_epoch() * 5; + // TODO(gloas): enable finalisation by increasing this threshold + let some_validators = (0..LOW_VALIDATOR_COUNT / 2).collect::>(); + + let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); + + // Use manual block building with envelopes for the first few blocks, + // then use the standard attested-blocks path once we've verified envelope handling. + let mut state = genesis_state; + let mut last_block_root = Hash256::zero(); + + for i in 1..=num_blocks { + let slot = Slot::new(i); + harness.advance_slot(); + + let (block_contents, envelope, pending_state) = + harness.make_block_with_envelope(state.clone(), slot).await; + let block_root = block_contents.0.canonical_root(); + + // Attest to previous block before processing next. + if i > 1 { + let state_root = state.update_tree_hash_cache().unwrap(); + harness.attest_block( + &state, + state_root, + last_block_root.into(), + &block_contents.0, + &some_validators, + ); + } + + harness + .process_block(slot, block_root, block_contents) + .await + .unwrap(); + + let envelope = envelope.expect("Gloas block should have envelope"); + let mut full_state = pending_state; + harness + .process_envelope(block_root, envelope, &mut full_state) + .await; + + last_block_root = block_root; + state = full_state; + } + + // Verify states can be loaded and have correct payload status. + let _head_state = harness.get_current_state(); + let _head_slot = harness.head_slot(); + + // States at all slots on the canonical chain should be retrievable. + for slot_num in 1..=num_blocks { + let slot = Slot::new(slot_num); + // Get the state root from the block at this slot via the state root iterator. + let state_root = harness.chain.state_root_at_slot(slot).unwrap().unwrap(); + + let mut loaded_state = store + .get_state(&state_root, Some(slot), CACHE_STATE_IN_TESTS) + .unwrap() + .unwrap(); + assert_eq!(loaded_state.canonical_root().unwrap(), state_root); + } + + // Verify chain dump and iterators work with Gloas states. + check_chain_dump(&harness, num_blocks + 1); + check_iterators(&harness); +} + /// Check that the HotColdDB's split_slot is equal to the start slot of the last finalized epoch. fn check_split_slot( harness: &TestHarness, @@ -5521,7 +5952,9 @@ fn check_chain_dump_from_slot(harness: &TestHarness, from_slot: Slot, expected_l ); // Check presence of execution payload on disk. - if harness.chain.spec.bellatrix_fork_epoch.is_some() { + if harness.chain.spec.bellatrix_fork_epoch.is_some() + && !harness.chain.spec.is_gloas_scheduled() + { assert!( harness .chain diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index e94924d8b2..a66f7a9b55 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -932,8 +932,14 @@ pub fn generate_genesis_header(spec: &ChainSpec) -> Option None, + ForkName::Gloas => { + // TODO(gloas): we are using a Fulu header for now, but this gets fixed up by the + // genesis builder anyway which translates it to bid/latest_block_hash. + let mut header = ExecutionPayloadHeader::Fulu(<_>::default()); + *header.block_hash_mut() = genesis_block_hash.unwrap_or_default(); + *header.transactions_root_mut() = empty_transactions_root; + Some(header) + } } } diff --git a/beacon_node/http_api/src/attestation_performance.rs b/beacon_node/http_api/src/attestation_performance.rs index 6e285829d2..05ed36e68b 100644 --- a/beacon_node/http_api/src/attestation_performance.rs +++ b/beacon_node/http_api/src/attestation_performance.rs @@ -205,8 +205,9 @@ pub fn get_attestation_performance( }) .collect::, _>>()?; + // TODO(gloas): add payloads replayer = replayer - .apply_blocks(blocks, None) + .apply_blocks(blocks, vec![], None) .map_err(|e| custom_server_error(format!("{:?}", e)))?; } diff --git a/beacon_node/http_api/src/block_packing_efficiency.rs b/beacon_node/http_api/src/block_packing_efficiency.rs index 3772470b28..725a0648a5 100644 --- a/beacon_node/http_api/src/block_packing_efficiency.rs +++ b/beacon_node/http_api/src/block_packing_efficiency.rs @@ -398,8 +398,9 @@ pub fn get_block_packing_efficiency( }) .collect::, _>>()?; + // TODO(gloas): add payloads replayer = replayer - .apply_blocks(blocks, None) + .apply_blocks(blocks, vec![], None) .map_err(|e: PackingEfficiencyError| custom_server_error(format!("{:?}", e)))?; } diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 607221686f..70475de130 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -70,7 +70,7 @@ pub async fn produce_block_v4( let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); - let (block, consensus_block_value) = chain + let (block, _pending_state, consensus_block_value) = chain .produce_block_with_verification_gloas( randao_reveal, slot, diff --git a/beacon_node/http_api/src/sync_committee_rewards.rs b/beacon_node/http_api/src/sync_committee_rewards.rs index 9bc1f6ead4..8715fc2b1e 100644 --- a/beacon_node/http_api/src/sync_committee_rewards.rs +++ b/beacon_node/http_api/src/sync_committee_rewards.rs @@ -66,11 +66,12 @@ pub fn get_state_before_applying_block( }) .map_err(|e| custom_not_found(format!("Parent state is not available! {:?}", e)))?; + // TODO(gloas): handle payloads? let replayer = BlockReplayer::new(parent_state, &chain.spec) .no_signature_verification() .state_root_iter([Ok((parent_block.state_root(), parent_block.slot()))].into_iter()) .minimal_block_root_verification() - .apply_blocks(vec![], Some(block.slot())) + .apply_blocks(vec![], vec![], Some(block.slot())) .map_err(unhandled_error::)?; Ok(replayer.into_state()) diff --git a/beacon_node/store/src/hdiff.rs b/beacon_node/store/src/hdiff.rs index 3777c83b60..85ac56454c 100644 --- a/beacon_node/store/src/hdiff.rs +++ b/beacon_node/store/src/hdiff.rs @@ -654,6 +654,12 @@ impl HierarchyModuli { /// layer 2 diff will point to the start snapshot instead of the layer 1 diff at /// 2998272. pub fn storage_strategy(&self, slot: Slot, start_slot: Slot) -> Result { + // Initially had the idea of using different storage strategies for full and pending states, + // but it was very complex. However without this concept we end up storing two diffs/two + // snapshots at full slots. The complexity of managing skipped slots was the main impetus + // for reverting the payload-status sensitive design: a Full skipped slot has no same-slot + // Pending state to replay from, so has to be handled differently from Full non-skipped + // slots. match slot.cmp(&start_slot) { Ordering::Less => return Err(Error::LessThanStart(slot, start_slot)), Ordering::Equal => return Ok(StorageStrategy::Snapshot), diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index fe3477dbfe..428086c464 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -186,6 +186,7 @@ pub enum HotColdDBError { MissingHotHDiff(Hash256), MissingHDiff(Slot), MissingExecutionPayload(Hash256), + MissingExecutionPayloadEnvelope(Hash256), MissingFullBlockExecutionPayloadPruned(Hash256, Slot), MissingAnchorInfo, MissingFrozenBlockSlot(Hash256), @@ -1132,10 +1133,13 @@ impl, Cold: ItemStore> HotColdDB pub fn get_advanced_hot_state( &self, block_root: Hash256, + payload_status: StatePayloadStatus, max_slot: Slot, state_root: Hash256, ) -> Result)>, Error> { - if let Some(cached) = self.get_advanced_hot_state_from_cache(block_root, max_slot) { + if let Some(cached) = + self.get_advanced_hot_state_from_cache(block_root, payload_status, max_slot) + { return Ok(Some(cached)); } @@ -1157,7 +1161,11 @@ impl, Cold: ItemStore> HotColdDB .into()); } - let state_root = if block_root == split.block_root && split.slot <= max_slot { + // Split state should always be `Pending`. + let state_root = if block_root == split.block_root + && let StatePayloadStatus::Pending = payload_status + && split.slot <= max_slot + { split.state_root } else { state_root @@ -1204,11 +1212,12 @@ impl, Cold: ItemStore> HotColdDB pub fn get_advanced_hot_state_from_cache( &self, block_root: Hash256, + payload_status: StatePayloadStatus, max_slot: Slot, ) -> Option<(Hash256, BeaconState)> { self.state_cache .lock() - .get_by_block_root(block_root, max_slot) + .get_by_block_root(block_root, payload_status, max_slot) } /// Delete a state, ensuring it is removed from the LRU cache, as well as from on-disk. @@ -1379,6 +1388,8 @@ impl, Cold: ItemStore> HotColdDB // NOTE: `hot_storage_strategy` can error if there are states in the database // prior to the `anchor_slot`. This can happen if checkpoint sync has been // botched and left some states in the database prior to completing. + // Use `Pending` status here because snapshots and diffs are only stored for + // `Pending` states. if let Some(slot) = slot && let Ok(strategy) = self.hot_storage_strategy(slot) { @@ -1846,6 +1857,55 @@ impl, Cold: ItemStore> HotColdDB } } + /// Compute the `StatePayloadStatus` for a stored state based on its summary. + /// + /// In future this might become a field of the summary, but this would require a whole DB + /// migration. For now we use an extra read from the DB to determine it. + fn get_hot_state_summary_payload_status( + &self, + summary: &HotStateSummary, + ) -> Result { + // Treat pre-Gloas states as `Pending`. + if !self + .spec + .fork_name_at_slot::(summary.slot) + .gloas_enabled() + { + return Ok(StatePayloadStatus::Pending); + } + + // Treat genesis state as `Pending` (`BeaconBlock` state). + let previous_state_root = summary.previous_state_root; + if previous_state_root.is_zero() { + return Ok(StatePayloadStatus::Pending); + } + + // Load the hot state summary for the previous state. + // + // If it has the same slot as this summary then we know this summary is for a `Full` state + // (payload state), because they are always diffed against their same-slot `Pending` state. + // + // If the previous summary has a different slot AND the latest block is from `summary.slot`, + // then this state *must* be `Pending` (it is the summary for latest block itself). + // + // Otherwise, we are at a skipped slot and must traverse the graph of state summaries + // backwards until we reach a summary for the latest block. This recursion could be quite + // far in the case of a long skip. We could optimise this in future using the + // `diff_base_state` (like in `get_ancestor_state_root`), or by doing a proper DB + // migration. + let previous_state_summary = self + .load_hot_state_summary(&previous_state_root)? + .ok_or(Error::MissingHotStateSummary(previous_state_root))?; + + if previous_state_summary.slot == summary.slot { + Ok(StatePayloadStatus::Full) + } else if summary.slot == summary.latest_block_slot { + Ok(StatePayloadStatus::Pending) + } else { + self.get_hot_state_summary_payload_status(&previous_state_summary) + } + } + fn load_hot_hdiff_buffer(&self, state_root: Hash256) -> Result { if let Some(buffer) = self .state_cache @@ -1941,13 +2001,22 @@ impl, Cold: ItemStore> HotColdDB ) -> Result, Hash256)>, Error> { metrics::inc_counter(&metrics::BEACON_STATE_HOT_GET_COUNT); - if let Some(HotStateSummary { - slot, - latest_block_root, - diff_base_state, - .. - }) = self.load_hot_state_summary(state_root)? + if let Some( + summary @ HotStateSummary { + slot, + latest_block_root, + diff_base_state, + .. + }, + ) = self.load_hot_state_summary(state_root)? { + let payload_status = self.get_hot_state_summary_payload_status(&summary)?; + debug!( + %slot, + ?state_root, + ?payload_status, + "Loading hot state" + ); let mut state = match self.hot_storage_strategy(slot)? { strat @ StorageStrategy::Snapshot | strat @ StorageStrategy::DiffFrom(_) => { let buffer_timer = metrics::start_timer_vec( @@ -1999,6 +2068,7 @@ impl, Cold: ItemStore> HotColdDB base_state, slot, latest_block_root, + payload_status, update_cache, )? } @@ -2016,19 +2086,26 @@ impl, Cold: ItemStore> HotColdDB base_state: BeaconState, slot: Slot, latest_block_root: Hash256, + desired_payload_status: StatePayloadStatus, update_cache: bool, ) -> Result, Error> { - if base_state.slot() == slot { + if base_state.slot() == slot && base_state.payload_status() == desired_payload_status { return Ok(base_state); } - let blocks = self.load_blocks_to_replay(base_state.slot(), slot, latest_block_root)?; + let (blocks, envelopes) = self.load_blocks_to_replay( + base_state.slot(), + slot, + latest_block_root, + desired_payload_status, + )?; let _t = metrics::start_timer(&metrics::STORE_BEACON_REPLAY_HOT_BLOCKS_TIME); // If replaying blocks, and `update_cache` is true, also cache the epoch boundary // state that this state is based on. It may be useful as the basis of more states // in the same epoch. let state_cache_hook = |state_root, state: &mut BeaconState| { + // TODO(gloas): prevent caching of the payload_status=Full state? if !update_cache || state.slot() % E::slots_per_epoch() != 0 { return Ok(()); } @@ -2052,9 +2129,19 @@ impl, Cold: ItemStore> HotColdDB Ok(()) }; + debug!( + %slot, + blocks = ?blocks.iter().map(|block| block.slot()).collect::>(), + envelopes = ?envelopes.iter().map(|e| e.message.slot).collect::>(), + payload_status = ?desired_payload_status, + "Replaying blocks and envelopes" + ); + self.replay_blocks( base_state, blocks, + envelopes, + desired_payload_status, slot, no_state_root_iter(), Some(Box::new(state_cache_hook)), @@ -2358,7 +2445,7 @@ impl, Cold: ItemStore> HotColdDB return Ok(base_state); } - let blocks = self.load_cold_blocks(base_state.slot() + 1, slot)?; + let (blocks, envelopes) = self.load_cold_blocks(base_state.slot() + 1, slot)?; // Include state root for base state as it is required by block processing to not // have to hash the state. @@ -2367,7 +2454,17 @@ impl, Cold: ItemStore> HotColdDB self.forwards_state_roots_iterator_until(base_state.slot(), slot, || { Err(Error::StateShouldNotBeRequired(slot)) })?; - let state = self.replay_blocks(base_state, blocks, slot, Some(state_root_iter), None)?; + // TODO(gloas): calculate correct payload status for cold states + let payload_status = StatePayloadStatus::Pending; + let state = self.replay_blocks( + base_state, + blocks, + envelopes, + payload_status, + slot, + Some(state_root_iter), + None, + )?; debug!( target_slot = %slot, replay_time_ms = metrics::stop_timer_with_duration(replay_timer).as_millis(), @@ -2460,40 +2557,77 @@ impl, Cold: ItemStore> HotColdDB } } - /// Load cold blocks between `start_slot` and `end_slot` inclusive. + /// Load cold blocks and payload envelopes between `start_slot` and `end_slot` inclusive. + #[allow(clippy::type_complexity)] pub fn load_cold_blocks( &self, start_slot: Slot, end_slot: Slot, - ) -> Result>, Error> { + ) -> Result< + ( + Vec>, + Vec>, + ), + Error, + > { let _t = metrics::start_timer(&metrics::STORE_BEACON_LOAD_COLD_BLOCKS_TIME); let block_root_iter = self.forwards_block_roots_iterator_until(start_slot, end_slot, || { Err(Error::StateShouldNotBeRequired(end_slot)) })?; - process_results(block_root_iter, |iter| { + let blocks = process_results(block_root_iter, |iter| { iter.map(|(block_root, _slot)| block_root) .dedup() .map(|block_root| { self.get_blinded_block(&block_root)? .ok_or(Error::MissingBlock(block_root)) }) - .collect() - })? + .collect::, Error>>() + })??; + + // If Gloas is not enabled for any slots in the range, just return `blocks`. + if !self.spec.fork_name_at_slot::(start_slot).gloas_enabled() + && !self.spec.fork_name_at_slot::(end_slot).gloas_enabled() + { + return Ok((blocks, vec![])); + } + // TODO(gloas): wire this up + let end_block_root = Hash256::ZERO; + let desired_payload_status = StatePayloadStatus::Pending; + let envelopes = self.load_payload_envelopes_for_blocks( + &blocks, + end_block_root, + desired_payload_status, + )?; + + Ok((blocks, envelopes)) } - /// Load the blocks between `start_slot` and `end_slot` by backtracking from `end_block_hash`. + /// Load the blocks & envelopes between `start_slot` and `end_slot` by backtracking from + /// `end_block_root`. /// /// Blocks are returned in slot-ascending order, suitable for replaying on a state with slot /// equal to `start_slot`, to reach a state with slot equal to `end_slot`. + /// + /// Payloads are also returned in slot-ascending order, but only payloads forming part of + /// the chain are loaded (payloads for EMPTY slots are omitted). Prior to Gloas, an empty + /// vec of payloads will be returned. + #[allow(clippy::type_complexity)] pub fn load_blocks_to_replay( &self, start_slot: Slot, end_slot: Slot, - end_block_hash: Hash256, - ) -> Result>>, Error> { + end_block_root: Hash256, + desired_payload_status: StatePayloadStatus, + ) -> Result< + ( + Vec>, + Vec>, + ), + Error, + > { let _t = metrics::start_timer(&metrics::STORE_BEACON_LOAD_HOT_BLOCKS_TIME); - let mut blocks = ParentRootBlockIterator::new(self, end_block_hash) + let mut blocks = ParentRootBlockIterator::new(self, end_block_root) .map(|result| result.map(|(_, block)| block)) // Include the block at the end slot (if any), it needs to be // replayed in order to construct the canonical state at `end_slot`. @@ -2520,17 +2654,70 @@ impl, Cold: ItemStore> HotColdDB }) .collect::, _>>()?; blocks.reverse(); - Ok(blocks) + + // If Gloas is not enabled for any slots in the range, just return `blocks`. + if !self.spec.fork_name_at_slot::(start_slot).gloas_enabled() + && !self.spec.fork_name_at_slot::(end_slot).gloas_enabled() + { + return Ok((blocks, vec![])); + } + + let envelopes = self.load_payload_envelopes_for_blocks( + &blocks, + end_block_root, + desired_payload_status, + )?; + + Ok((blocks, envelopes)) + } + + pub fn load_payload_envelopes_for_blocks( + &self, + blocks: &[SignedBlindedBeaconBlock], + end_block_root: Hash256, + desired_payload_status: StatePayloadStatus, + ) -> Result>, Error> { + let mut envelopes = vec![]; + + for (block, next_block) in blocks.iter().tuple_windows() { + if block.fork_name_unchecked().gloas_enabled() { + // Check next block to see if this block's payload is canonical on this chain. + let block_hash = block.payload_bid_block_hash()?; + if !next_block.is_parent_block_full(block_hash) { + // No payload at this slot (empty), nothing to load. + continue; + } + // Using `parent_root` avoids computation. + let block_root = next_block.parent_root(); + let envelope = self + .get_payload_envelope(&block_root)? + .ok_or(HotColdDBError::MissingExecutionPayloadEnvelope(block_root))?; + envelopes.push(envelope); + } + } + + // Load the payload for the last block if desired. + if let StatePayloadStatus::Full = desired_payload_status { + let envelope = self.get_payload_envelope(&end_block_root)?.ok_or( + HotColdDBError::MissingExecutionPayloadEnvelope(end_block_root), + )?; + envelopes.push(envelope); + } + + Ok(envelopes) } /// Replay `blocks` on top of `state` until `target_slot` is reached. /// /// Will skip slots as necessary. The returned state is not guaranteed /// to have any caches built, beyond those immediately required by block processing. + #[allow(clippy::too_many_arguments)] pub fn replay_blocks( &self, state: BeaconState, - blocks: Vec>>, + blocks: Vec>, + envelopes: Vec>, + desired_payload_status: StatePayloadStatus, target_slot: Slot, state_root_iter: Option>>, pre_slot_hook: Option>, @@ -2539,7 +2726,8 @@ impl, Cold: ItemStore> HotColdDB let mut block_replayer = BlockReplayer::new(state, &self.spec) .no_signature_verification() - .minimal_block_root_verification(); + .minimal_block_root_verification() + .desired_state_payload_status(desired_payload_status); let have_state_root_iterator = state_root_iter.is_some(); if let Some(state_root_iter) = state_root_iter { @@ -2551,7 +2739,7 @@ impl, Cold: ItemStore> HotColdDB } block_replayer - .apply_blocks(blocks, Some(target_slot)) + .apply_blocks(blocks, envelopes, Some(target_slot)) .map(|block_replayer| { if have_state_root_iterator && block_replayer.state_root_miss() { warn!( @@ -4006,11 +4194,15 @@ impl HotStateSummary { // slots where there isn't a skip). let latest_block_root = state.get_latest_block_root(state_root); + // Payload status of the state determines a lot about how it is stored. + let payload_status = state.payload_status(); + let get_state_root = |slot| { if slot == state.slot() { + // TODO(gloas): I think we can remove this case Ok::<_, Error>(state_root) } else { - Ok(get_ancestor_state_root(store, state, slot).map_err(|e| { + Ok::<_, Error>(get_ancestor_state_root(store, state, slot).map_err(|e| { Error::StateSummaryIteratorError { error: e, from_state_root: state_root, @@ -4030,6 +4222,12 @@ impl HotStateSummary { let previous_state_root = if state.slot() == 0 { // Set to 0x0 for genesis state to prevent any sort of circular reference. Hash256::zero() + } else if let StatePayloadStatus::Full = payload_status + && state.slot() == state.latest_block_header().slot + { + // A Full state at a non-skipped slot builds off the Pending state of the same slot, + // i.e. the state with the same `state_root` as its `BeaconBlock` + state.latest_block_header().state_root } else { get_state_root(state.slot().safe_sub(1_u64)?)? }; diff --git a/beacon_node/store/src/reconstruct.rs b/beacon_node/store/src/reconstruct.rs index 7aca692ef9..e51543c3a2 100644 --- a/beacon_node/store/src/reconstruct.rs +++ b/beacon_node/store/src/reconstruct.rs @@ -67,6 +67,7 @@ where state.build_caches(&self.spec)?; + // TODO(gloas): handle payload envelope replay process_results(block_root_iter, |iter| -> Result<(), Error> { let mut io_batch = vec![]; diff --git a/beacon_node/store/src/state_cache.rs b/beacon_node/store/src/state_cache.rs index 6d159c9361..d016922ade 100644 --- a/beacon_node/store/src/state_cache.rs +++ b/beacon_node/store/src/state_cache.rs @@ -7,7 +7,7 @@ use lru::LruCache; use std::collections::{BTreeMap, HashMap, HashSet}; use std::num::NonZeroUsize; use tracing::instrument; -use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256, Slot}; +use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256, Slot, execution::StatePayloadStatus}; /// Fraction of the LRU cache to leave intact during culling. const CULL_EXEMPT_NUMERATOR: usize = 1; @@ -23,10 +23,10 @@ pub struct FinalizedState { state: BeaconState, } -/// Map from block_root -> slot -> state_root. +/// Map from (block_root, payload_status) -> slot -> state_root. #[derive(Debug, Default)] pub struct BlockMap { - blocks: HashMap, + blocks: HashMap<(Hash256, StatePayloadStatus), SlotMap>, } /// Map from slot -> state_root. @@ -143,8 +143,11 @@ impl StateCache { return Err(Error::FinalizedStateDecreasingSlot); } + let payload_status = state.payload_status(); + // Add to block map. - self.block_map.insert(block_root, state.slot(), state_root); + self.block_map + .insert(block_root, payload_status, state.slot(), state_root); // Prune block map. let state_roots_to_prune = self.block_map.prune(state.slot()); @@ -267,7 +270,9 @@ impl StateCache { // Record the connection from block root and slot to this state. let slot = state.slot(); - self.block_map.insert(block_root, slot, state_root); + let payload_status = state.payload_status(); + self.block_map + .insert(block_root, payload_status, slot, state_root); Ok(PutStateOutcome::New(deleted_states)) } @@ -316,9 +321,10 @@ impl StateCache { pub fn get_by_block_root( &mut self, block_root: Hash256, + payload_status: StatePayloadStatus, slot: Slot, ) -> Option<(Hash256, BeaconState)> { - let slot_map = self.block_map.blocks.get(&block_root)?; + let slot_map = self.block_map.blocks.get(&(block_root, payload_status))?; // Find the state at `slot`, or failing that the most recent ancestor. let state_root = slot_map @@ -339,7 +345,12 @@ impl StateCache { } pub fn delete_block_states(&mut self, block_root: &Hash256) { - if let Some(slot_map) = self.block_map.delete_block_states(block_root) { + let (pending_state_roots, full_state_roots) = + self.block_map.delete_block_states(block_root); + for slot_map in [pending_state_roots, full_state_roots] + .into_iter() + .flatten() + { for state_root in slot_map.slots.values() { self.states.pop(state_root); } @@ -412,8 +423,14 @@ impl StateCache { } impl BlockMap { - fn insert(&mut self, block_root: Hash256, slot: Slot, state_root: Hash256) { - let slot_map = self.blocks.entry(block_root).or_default(); + fn insert( + &mut self, + block_root: Hash256, + payload_status: StatePayloadStatus, + slot: Slot, + state_root: Hash256, + ) { + let slot_map = self.blocks.entry((block_root, payload_status)).or_default(); slot_map.slots.insert(slot, state_root); } @@ -444,8 +461,12 @@ impl BlockMap { }); } - fn delete_block_states(&mut self, block_root: &Hash256) -> Option { - self.blocks.remove(block_root) + fn delete_block_states(&mut self, block_root: &Hash256) -> (Option, Option) { + let pending_state_roots = self + .blocks + .remove(&(*block_root, StatePayloadStatus::Pending)); + let full_state_roots = self.blocks.remove(&(*block_root, StatePayloadStatus::Full)); + (pending_state_roots, full_state_roots) } } diff --git a/consensus/state_processing/src/block_replayer.rs b/consensus/state_processing/src/block_replayer.rs index 56e667cdd3..a10d6179fe 100644 --- a/consensus/state_processing/src/block_replayer.rs +++ b/consensus/state_processing/src/block_replayer.rs @@ -1,6 +1,11 @@ use crate::{ BlockProcessingError, BlockSignatureStrategy, ConsensusContext, SlotProcessingError, - VerifyBlockRoot, per_block_processing, per_epoch_processing::EpochProcessingSummary, + VerifyBlockRoot, VerifySignatures, + envelope_processing::{ + EnvelopeProcessingError, VerifyStateRoot, process_execution_payload_envelope, + }, + per_block_processing, + per_epoch_processing::EpochProcessingSummary, per_slot_processing, }; use itertools::Itertools; @@ -8,7 +13,7 @@ use std::iter::Peekable; use std::marker::PhantomData; use types::{ BeaconState, BeaconStateError, BlindedPayload, ChainSpec, EthSpec, Hash256, SignedBeaconBlock, - Slot, + SignedExecutionPayloadEnvelope, Slot, execution::StatePayloadStatus, }; pub type PreBlockHook<'a, E, Error> = Box< @@ -24,7 +29,7 @@ pub type PostSlotHook<'a, E, Error> = Box< >; pub type StateRootIterDefault = std::iter::Empty>; -/// Efficiently apply blocks to a state while configuring various parameters. +/// Efficiently apply blocks and payloads to a state while configuring various parameters. /// /// Usage follows a builder pattern. pub struct BlockReplayer< @@ -41,8 +46,21 @@ pub struct BlockReplayer< post_block_hook: Option>, pre_slot_hook: Option>, post_slot_hook: Option>, + /// Iterator over state roots for all *block* states. + /// + /// Pre-Gloas, this is all states. Post-Gloas, this is *just* the states corresponding to beacon + /// blocks. For states corresponding to payloads, we read the state root from the payload + /// envelope. + // TODO(gloas): this concept might need adjusting when we implement the cold DB. pub(crate) state_root_iter: Option>, state_root_miss: bool, + /// The payload status of the state desired as the end result of block replay. + /// + /// This dictates whether a payload should be applied after applying the last block. + /// + /// Prior to Gloas, this should always be set to `StatePayloadStatus::Pending` to indicate + /// that no envelope needs to be applied. + desired_state_payload_status: StatePayloadStatus, _phantom: PhantomData, } @@ -50,7 +68,12 @@ pub struct BlockReplayer< pub enum BlockReplayError { SlotProcessing(SlotProcessingError), BlockProcessing(BlockProcessingError), + EnvelopeProcessing(EnvelopeProcessingError), BeaconState(BeaconStateError), + /// A payload envelope for this `slot` was required but not provided. + MissingPayloadEnvelope { + slot: Slot, + }, } impl From for BlockReplayError { @@ -65,6 +88,12 @@ impl From for BlockReplayError { } } +impl From for BlockReplayError { + fn from(e: EnvelopeProcessingError) -> Self { + Self::EnvelopeProcessing(e) + } +} + impl From for BlockReplayError { fn from(e: BeaconStateError) -> Self { Self::BeaconState(e) @@ -96,6 +125,7 @@ where post_slot_hook: None, state_root_iter: None, state_root_miss: false, + desired_state_payload_status: StatePayloadStatus::Pending, _phantom: PhantomData, } } @@ -161,6 +191,14 @@ where self } + /// Set the desired payload status of the state reached by replay. + /// + /// This determines whether to apply a payload after applying the last block. + pub fn desired_state_payload_status(mut self, payload_status: StatePayloadStatus) -> Self { + self.desired_state_payload_status = payload_status; + self + } + /// Compute the state root for `self.state` as efficiently as possible. /// /// This function MUST only be called when `self.state` is a post-state, i.e. it MUST not be @@ -208,6 +246,38 @@ where Ok(state_root) } + /// Apply an execution payload envelope to `self.state`. + /// + /// The `block_state_root` MUST be the `state_root` of the most recently applied block. + /// + /// Returns the `state_root` of `self.state` after payload application. + fn apply_payload_envelope( + &mut self, + envelope: &SignedExecutionPayloadEnvelope, + block_state_root: Hash256, + ) -> Result { + // TODO(gloas): bulk signature verification could be relevant here? + let verify_payload_signatures = + if let BlockSignatureStrategy::NoVerification = self.block_sig_strategy { + VerifySignatures::False + } else { + VerifySignatures::True + }; + // TODO(gloas): state root verif enabled during initial prototyping + let verify_state_root = VerifyStateRoot::True; + process_execution_payload_envelope( + &mut self.state, + Some(block_state_root), + envelope, + verify_payload_signatures, + verify_state_root, + self.spec, + ) + .map_err(BlockReplayError::from)?; + + Ok(envelope.message.state_root) + } + /// Apply `blocks` atop `self.state`, taking care of slot processing. /// /// If `target_slot` is provided then the state will be advanced through to `target_slot` @@ -215,8 +285,21 @@ where pub fn apply_blocks( mut self, blocks: Vec>>, + payload_envelopes: Vec>, target_slot: Option, ) -> Result { + let mut envelopes_iter = payload_envelopes.into_iter(); + + let mut next_envelope_at_slot = |slot| { + if let Some(envelope) = envelopes_iter.next() + && envelope.message.slot == slot + { + Ok(envelope) + } else { + Err(BlockReplayError::MissingPayloadEnvelope { slot }) + } + }; + for (i, block) in blocks.iter().enumerate() { // Allow one additional block at the start which is only used for its state root. if i == 0 && block.slot() <= self.state.slot() { @@ -224,7 +307,35 @@ where } while self.state.slot() < block.slot() { - let state_root = self.get_state_root(&blocks, i)?; + let mut state_root = self.get_state_root(&blocks, i)?; + + // Apply the payload for the *previous* block if the bid in the current block + // indicates that the parent is full (and it hasn't already been applied). + state_root = if block.fork_name_unchecked().gloas_enabled() + && self.state.slot() == self.state.latest_block_header().slot + { + let latest_bid_block_hash = self + .state + .latest_execution_payload_bid() + .map_err(BlockReplayError::from)? + .block_hash; + + // Similar to `is_parent_block_full`, but reading the block hash from the + // not-yet-applied `block`. The slot 0 case covers genesis (no block replay reqd). + if self.state.slot() != 0 && block.is_parent_block_full(latest_bid_block_hash) { + let envelope = next_envelope_at_slot(self.state.slot())?; + // State root for the next slot processing is now the envelope's state root. + self.apply_payload_envelope(&envelope, state_root)? + } else { + // Empty payload at this slot, the state root is unchanged from when the + // beacon block was applied. + state_root + } + } else { + // Pre-Gloas or at skipped slots post-Gloas, the state root of the parent state + // is always the output from `self.get_state_root`. + state_root + }; if let Some(ref mut pre_slot_hook) = self.pre_slot_hook { pre_slot_hook(state_root, &mut self.state)?; @@ -268,9 +379,24 @@ where } } + // Apply the last payload if desired. + let mut opt_state_root = if let StatePayloadStatus::Full = self.desired_state_payload_status + && let Some(last_block) = blocks.last() + { + let envelope = next_envelope_at_slot(self.state.slot())?; + Some(self.apply_payload_envelope(&envelope, last_block.state_root())?) + } else { + None + }; + if let Some(target_slot) = target_slot { while self.state.slot() < target_slot { - let state_root = self.get_state_root(&blocks, blocks.len())?; + // Read state root from `opt_state_root` if a payload was just applied. + let state_root = if let Some(root) = opt_state_root.take() { + root + } else { + self.get_state_root(&blocks, blocks.len())? + }; if let Some(ref mut pre_slot_hook) = self.pre_slot_hook { pre_slot_hook(state_root, &mut self.state)?; diff --git a/consensus/state_processing/src/envelope_processing.rs b/consensus/state_processing/src/envelope_processing.rs index be6b7c1b29..97953b835f 100644 --- a/consensus/state_processing/src/envelope_processing.rs +++ b/consensus/state_processing/src/envelope_processing.rs @@ -241,8 +241,6 @@ pub fn process_execution_payload_envelope( // TODO(gloas): newPayload happens here in the spec, ensure we wire that up correctly process_deposit_requests_post_gloas(state, &execution_requests.deposits, spec)?; - - // TODO(gloas): gotta update these process_withdrawal_requests(state, &execution_requests.withdrawals, spec)?; process_consolidation_requests(state, &execution_requests.consolidations, spec)?; diff --git a/consensus/state_processing/src/per_block_processing/tests.rs b/consensus/state_processing/src/per_block_processing/tests.rs index 96610c2010..0203b33e61 100644 --- a/consensus/state_processing/src/per_block_processing/tests.rs +++ b/consensus/state_processing/src/per_block_processing/tests.rs @@ -1014,7 +1014,7 @@ async fn block_replayer_peeking_state_roots() { let block_replayer = BlockReplayer::new(parent_state, &harness.chain.spec) .state_root_iter(state_root_iter.into_iter()) .no_signature_verification() - .apply_blocks(vec![target_block], None) + .apply_blocks(vec![target_block], vec![], None) .unwrap(); assert_eq!( diff --git a/consensus/state_processing/src/state_advance.rs b/consensus/state_processing/src/state_advance.rs index 11a956bc2a..1114562155 100644 --- a/consensus/state_processing/src/state_advance.rs +++ b/consensus/state_processing/src/state_advance.rs @@ -77,6 +77,11 @@ pub fn partial_state_advance( // (all-zeros) state root. let mut initial_state_root = Some(if state.slot() > state.latest_block_header().slot { state_root_opt.unwrap_or_else(Hash256::zero) + } else if state.slot() == state.latest_block_header().slot + && !state.latest_block_header().state_root.is_zero() + { + // Post-Gloas Full state case. + state.latest_block_header().state_root } else { state_root_opt.ok_or(Error::StateRootNotProvided)? }); diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index aeb3c18d95..b6218ba64d 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -14,6 +14,7 @@ use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ + ExecutionBlockHash, block::{ BLOB_KZG_COMMITMENTS_INDEX, BeaconBlock, BeaconBlockAltair, BeaconBlockBase, BeaconBlockBellatrix, BeaconBlockBodyBellatrix, BeaconBlockBodyCapella, @@ -365,6 +366,32 @@ impl> SignedBeaconBlock format_kzg_commitments(commitments.as_ref()) } + + /// Convenience accessor for the block's bid's `block_hash`. + /// + /// This method returns an error prior to Gloas. + pub fn payload_bid_block_hash(&self) -> Result { + self.message() + .body() + .signed_execution_payload_bid() + .map(|bid| bid.message.block_hash) + } + + /// Check if the `parent_hash` in this block's `signed_payload_bid` matches `parent_block_hash`. + /// + /// This function is useful post-Gloas for determining if the parent block is full, *without* + /// necessarily needing access to a beacon state. The passed in `parent_block_hash` MUST be the + /// `block_hash` from the parent beacon block's bid. If the parent beacon state is available + /// this can alternatively be fetched from `state.latest_payload_bid`. + /// + /// This function returns `false` for all blocks prior to Gloas. + pub fn is_parent_block_full(&self, parent_block_hash: ExecutionBlockHash) -> bool { + let Ok(signed_payload_bid) = self.message().body().signed_execution_payload_bid() else { + // Prior to Gloas. + return false; + }; + signed_payload_bid.message.parent_block_hash == parent_block_hash + } } // We can convert pre-Bellatrix blocks without payloads into blocks with payloads. diff --git a/consensus/types/src/execution/mod.rs b/consensus/types/src/execution/mod.rs index a3d4ed8730..591be32b24 100644 --- a/consensus/types/src/execution/mod.rs +++ b/consensus/types/src/execution/mod.rs @@ -12,6 +12,7 @@ mod payload; mod signed_bls_to_execution_change; mod signed_execution_payload_bid; mod signed_execution_payload_envelope; +mod state_payload_status; pub use bls_to_execution_change::BlsToExecutionChange; pub use eth1_data::Eth1Data; @@ -41,3 +42,4 @@ pub use payload::{ pub use signed_bls_to_execution_change::SignedBlsToExecutionChange; pub use signed_execution_payload_bid::SignedExecutionPayloadBid; pub use signed_execution_payload_envelope::SignedExecutionPayloadEnvelope; +pub use state_payload_status::StatePayloadStatus; diff --git a/consensus/types/src/execution/state_payload_status.rs b/consensus/types/src/execution/state_payload_status.rs new file mode 100644 index 0000000000..1661be6060 --- /dev/null +++ b/consensus/types/src/execution/state_payload_status.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +/// Payload status as it applies to a `BeaconState` post-Gloas. +/// +/// A state can either be a post-state for a block (in which case we call it `Pending`) or a +/// payload envelope (`Full`). When handling states it is often necessary to know which of these +/// two variants is required. +/// +/// Note that states at skipped slots could be either `Pending` or `Full`, depending on whether +/// the payload for the most-recently applied block was also applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StatePayloadStatus { + /// For states produced by `process_block` executed on a `BeaconBlock`. + Pending, + /// For states produced by `process_execution_payload` on a `ExecutionPayloadEnvelope`. + Full, +} diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index bd67f469d2..34cfd0ca1c 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -36,7 +36,7 @@ use crate::{ execution::{ Eth1Data, ExecutionPayloadHeaderBellatrix, ExecutionPayloadHeaderCapella, ExecutionPayloadHeaderDeneb, ExecutionPayloadHeaderElectra, ExecutionPayloadHeaderFulu, - ExecutionPayloadHeaderRef, ExecutionPayloadHeaderRefMut, + ExecutionPayloadHeaderRef, ExecutionPayloadHeaderRefMut, StatePayloadStatus, }, fork::{Fork, ForkName, ForkVersionDecode, InconsistentFork, map_fork_name}, light_client::consts::{ @@ -1266,6 +1266,24 @@ impl BeaconState { } } + /// Determine the payload status of this state. + /// + /// Prior to Gloas this is always `Pending`. + /// + /// Post-Gloas, the definition of the `StatePayloadStatus` is: + /// + /// - `Full` if this state is the result of envelope processing. + /// - `Pending` if this state is the result of block processing. + pub fn payload_status(&self) -> StatePayloadStatus { + if !self.fork_name_unchecked().gloas_enabled() { + StatePayloadStatus::Pending + } else if self.is_parent_block_full() { + StatePayloadStatus::Full + } else { + StatePayloadStatus::Pending + } + } + /// Return `true` if the validator who produced `slot_signature` is eligible to aggregate. /// /// Spec v0.12.1 From a36b7f3ddbf4bd8456403ef1f945403c7a23affa Mon Sep 17 00:00:00 2001 From: lystopad Date: Thu, 12 Mar 2026 00:03:05 +0000 Subject: [PATCH 065/189] Schedule Fulu fork for Chiado testnet (#8954) Co-Authored-By: Oleksandr Lystopad --- .../built_in_network_configs/chiado/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/eth2_network_config/built_in_network_configs/chiado/config.yaml b/common/eth2_network_config/built_in_network_configs/chiado/config.yaml index f0c04d891a..e1eb022cc9 100644 --- a/common/eth2_network_config/built_in_network_configs/chiado/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/chiado/config.yaml @@ -49,7 +49,7 @@ ELECTRA_FORK_VERSION: 0x0500006f ELECTRA_FORK_EPOCH: 948224 # Thu Mar 6 2025 09:43:40 GMT+0000 # Fulu FULU_FORK_VERSION: 0x0600006f -FULU_FORK_EPOCH: 18446744073709551615 +FULU_FORK_EPOCH: 1353216 # Mon Mar 16 2026 09:33:00 UTC # Gloas GLOAS_FORK_VERSION: 0x0700006f GLOAS_FORK_EPOCH: 18446744073709551615 From e1e97e6df069a67bb687fd02829ac53b6950d378 Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:11:37 +0800 Subject: [PATCH 066/189] Fix proposer lookahead endpoint JSON return type (#8970) Co-Authored-By: Tan Chee Keong --- beacon_node/http_api/src/beacon/states.rs | 4 ++-- beacon_node/http_api/tests/tests.rs | 5 +++-- common/eth2/src/lib.rs | 2 +- common/eth2/src/types.rs | 9 +++++++++ 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/beacon_node/http_api/src/beacon/states.rs b/beacon_node/http_api/src/beacon/states.rs index 02ac3f4da7..84ef3c1f26 100644 --- a/beacon_node/http_api/src/beacon/states.rs +++ b/beacon_node/http_api/src/beacon/states.rs @@ -9,7 +9,7 @@ use crate::version::{ use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, WhenSlotSkipped}; use eth2::types::{ self as api_types, ValidatorBalancesRequestBody, ValidatorId, ValidatorIdentitiesRequestBody, - ValidatorsRequestBody, + ValidatorIndexData, ValidatorsRequestBody, }; use ssz::Encode; use std::sync::Arc; @@ -213,7 +213,7 @@ pub fn get_beacon_state_proposer_lookahead( ResponseIncludesVersion::Yes(fork_name), execution_optimistic, finalized, - data, + ValidatorIndexData(data), ) .map(|res| warp::reply::json(&res).into_response()), } diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index a97ce01ac1..aed7a6b200 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -1430,10 +1430,11 @@ impl ApiTester { } let state = state_opt.as_mut().expect("result should be none"); - let expected = state.proposer_lookahead().unwrap(); + let expected = state.proposer_lookahead().unwrap().to_vec(); let response = result.unwrap(); - assert_eq!(response.data(), &expected.to_vec()); + // Compare Vec directly, not Vec + assert_eq!(response.data().0, expected); // Check that the version header is returned in the response let fork_name = state.fork_name(&self.chain.spec).unwrap(); diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 5547ced491..af87af14ba 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -904,7 +904,7 @@ impl BeaconNodeHttpClient { pub async fn get_beacon_states_proposer_lookahead( &self, state_id: StateId, - ) -> Result>>, Error> { + ) -> Result>, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 2f86170812..94dff95bc6 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -708,6 +708,15 @@ pub struct DataColumnIndicesQuery { #[serde(transparent)] pub struct ValidatorIndexData(#[serde(with = "serde_utils::quoted_u64_vec")] pub Vec); +impl<'de, T> ContextDeserialize<'de, T> for ValidatorIndexData { + fn context_deserialize(deserializer: D, _context: T) -> Result + where + D: Deserializer<'de>, + { + Self::deserialize(deserializer) + } +} + /// Borrowed variant of `ValidatorIndexData`, for serializing/sending. #[derive(Clone, Copy, Serialize)] #[serde(transparent)] From 4b3a9d3d10a6181a1a1588880de133457eb90816 Mon Sep 17 00:00:00 2001 From: Shane K Moore <41407272+shane-moore@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:53:32 -0700 Subject: [PATCH 067/189] Refactor/stream vc vote publishing (#8880) Changes four `ValidatorStore` batch signing methods to return `impl Stream` instead of `Future`. Services consume the stream and publish each batch as it arrives. No behavioral change for lh since `LighthouseValidatorStore` wraps everything in `stream::once` Also replaces anonymous tuples in method signatures with named structs Co-Authored-By: shane-moore Co-Authored-By: Michael Sproul Co-Authored-By: Mac L --- Cargo.lock | 1 + testing/web3signer_tests/src/lib.rs | 66 ++- .../http_api/src/tests/keystores.rs | 43 +- .../lighthouse_validator_store/src/lib.rs | 496 ++++++++++++------ .../src/attestation_service.rs | 348 ++++++------ .../src/sync_committee_service.rs | 239 ++++----- validator_client/validator_store/Cargo.toml | 1 + validator_client/validator_store/src/lib.rs | 89 ++-- 8 files changed, 740 insertions(+), 543 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ca12dce46..1d187d1c68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9710,6 +9710,7 @@ version = "0.1.0" dependencies = [ "bls", "eth2", + "futures", "slashing_protection", "types", ] diff --git a/testing/web3signer_tests/src/lib.rs b/testing/web3signer_tests/src/lib.rs index 4b9432b67b..1f36f8d4ce 100644 --- a/testing/web3signer_tests/src/lib.rs +++ b/testing/web3signer_tests/src/lib.rs @@ -25,6 +25,7 @@ mod tests { use eth2_keystore::KeystoreBuilder; use eth2_network_config::Eth2NetworkConfig; use fixed_bytes::FixedBytesExtended; + use futures::StreamExt; use initialized_validators::{ InitializedValidators, load_pem_certificate, load_pkcs12_identity, }; @@ -50,7 +51,7 @@ mod tests { use types::{attestation::AttestationBase, *}; use url::Url; use validator_store::{ - Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore, + AttestationToSign, Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore, }; /// If the we are unable to reach the Web3Signer HTTP API within this time out then we will @@ -654,13 +655,14 @@ mod tests { .await .assert_signatures_match("attestation", |pubkey, validator_store| async move { let attestation = get_attestation(); - validator_store - .sign_attestations(vec![(0, pubkey, 0, attestation)]) - .await - .unwrap() - .pop() - .unwrap() - .1 + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap().unwrap().pop().unwrap().1 }) .await .assert_signatures_match("signed_aggregate", |pubkey, validator_store| async move { @@ -879,22 +881,28 @@ mod tests { .await .assert_signatures_match("first_attestation", |pubkey, validator_store| async move { let attestation = first_attestation(); - validator_store - .sign_attestations(vec![(0, pubkey, 0, attestation)]) - .await - .unwrap() - .pop() - .unwrap() - .1 + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap().unwrap().pop().unwrap().1 }) .await .assert_slashable_attestation_should_sign( "double_vote_attestation", move |pubkey, validator_store| async move { let attestation = double_vote_attestation(); - validator_store - .sign_attestations(vec![(0, pubkey, 0, attestation)]) - .await + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap() }, slashable_message_should_sign, ) @@ -903,9 +911,14 @@ mod tests { "surrounding_attestation", move |pubkey, validator_store| async move { let attestation = surrounding_attestation(); - validator_store - .sign_attestations(vec![(0, pubkey, 0, attestation)]) - .await + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap() }, slashable_message_should_sign, ) @@ -914,9 +927,14 @@ mod tests { "surrounded_attestation", move |pubkey, validator_store| async move { let attestation = surrounded_attestation(); - validator_store - .sign_attestations(vec![(0, pubkey, 0, attestation)]) - .await + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey, + validator_committee_index: 0, + attestation, + }]); + tokio::pin!(stream); + stream.next().await.unwrap() }, slashable_message_should_sign, ) diff --git a/validator_client/http_api/src/tests/keystores.rs b/validator_client/http_api/src/tests/keystores.rs index 601b2f1666..eb35075526 100644 --- a/validator_client/http_api/src/tests/keystores.rs +++ b/validator_client/http_api/src/tests/keystores.rs @@ -9,6 +9,7 @@ use eth2::lighthouse_vc::{ types::Web3SignerValidatorRequest, }; use fixed_bytes::FixedBytesExtended; +use futures::StreamExt; use itertools::Itertools; use lighthouse_validator_store::DEFAULT_GAS_LIMIT; use rand::rngs::StdRng; @@ -19,6 +20,7 @@ use std::{collections::HashMap, path::Path}; use tokio::runtime::Handle; use typenum::Unsigned; use types::{Address, attestation::AttestationBase}; +use validator_store::AttestationToSign; use validator_store::ValidatorStore; use zeroize::Zeroizing; @@ -1101,11 +1103,16 @@ async fn generic_migration_test( // Sign attestations on VC1. for (validator_index, attestation) in first_vc_attestations { let public_key = keystore_pubkey(&keystores[validator_index]); - let safe_attestations = tester1 + let stream = tester1 .validator_store - .sign_attestations(vec![(0, public_key, 0, attestation.clone())]) - .await - .unwrap(); + .sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey: public_key, + validator_committee_index: 0, + attestation: attestation.clone(), + }]); + tokio::pin!(stream); + let safe_attestations = stream.next().await.unwrap().unwrap(); assert_eq!(safe_attestations.len(), 1); // Compare data only, ignoring signatures which are added during signing. assert_eq!(safe_attestations[0].1.data(), attestation.data()); @@ -1184,10 +1191,16 @@ async fn generic_migration_test( // Sign attestations on the second VC. for (validator_index, attestation, should_succeed) in second_vc_attestations { let public_key = keystore_pubkey(&keystores[validator_index]); - let result = tester2 + let stream = tester2 .validator_store - .sign_attestations(vec![(0, public_key, 0, attestation.clone())]) - .await; + .sign_attestations(vec![AttestationToSign { + validator_index: 0, + pubkey: public_key, + validator_committee_index: 0, + attestation: attestation.clone(), + }]); + tokio::pin!(stream); + let result = stream.next().await.unwrap(); match result { Ok(safe_attestations) => { if should_succeed { @@ -1331,14 +1344,14 @@ async fn delete_concurrent_with_signing() { for j in 0..num_attestations { let att = make_attestation(j, j + 1); for (validator_index, public_key) in thread_pubkeys.iter().enumerate() { - let _ = validator_store - .sign_attestations(vec![( - validator_index as u64, - *public_key, - 0, - att.clone(), - )]) - .await; + let stream = validator_store.sign_attestations(vec![AttestationToSign { + validator_index: validator_index as u64, + pubkey: *public_key, + validator_committee_index: 0, + attestation: att.clone(), + }]); + tokio::pin!(stream); + let _ = stream.next().await; } } }); diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index 7806482ffb..e8c1cfbc43 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -2,7 +2,7 @@ use account_utils::validator_definitions::{PasswordStorage, ValidatorDefinition} use bls::{PublicKeyBytes, Signature}; use doppelganger_service::DoppelgangerService; use eth2::types::PublishBlockRequest; -use futures::future::join_all; +use futures::{Stream, future::join_all, stream}; use initialized_validators::InitializedValidators; use logging::crit; use parking_lot::{Mutex, RwLock}; @@ -17,7 +17,7 @@ use std::marker::PhantomData; use std::path::Path; use std::sync::Arc; use task_executor::TaskExecutor; -use tracing::{error, info, instrument, warn}; +use tracing::{Instrument, debug, error, info, info_span, instrument, warn}; use types::{ AbstractExecPayload, Address, AggregateAndProof, Attestation, BeaconBlock, BlindedPayload, ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, ExecutionPayloadEnvelope, Fork, @@ -28,7 +28,8 @@ use types::{ ValidatorRegistrationData, VoluntaryExit, graffiti::GraffitiString, }; use validator_store::{ - DoppelgangerStatus, Error as ValidatorStoreError, ProposalData, SignedBlock, UnsignedBlock, + AggregateToSign, AttestationToSign, ContributionToSign, DoppelgangerStatus, + Error as ValidatorStoreError, ProposalData, SignedBlock, SyncMessageToSign, UnsignedBlock, ValidatorStore, }; @@ -691,6 +692,119 @@ impl LighthouseValidatorStore { Ok(safe_attestations) } + + /// Signs an `AggregateAndProof` for a given validator. + /// + /// The resulting `SignedAggregateAndProof` is sent on the aggregation channel and cannot be + /// modified by actors other than the signing validator. + pub async fn produce_signed_aggregate_and_proof( + &self, + validator_pubkey: PublicKeyBytes, + aggregator_index: u64, + aggregate: Attestation, + selection_proof: SelectionProof, + ) -> Result, Error> { + let signing_epoch = aggregate.data().target.epoch; + let signing_context = self.signing_context(Domain::AggregateAndProof, signing_epoch); + + let message = + AggregateAndProof::from_attestation(aggregator_index, aggregate, selection_proof); + + let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?; + let signature = signing_method + .get_signature::>( + SignableMessage::SignedAggregateAndProof(message.to_ref()), + signing_context, + &self.spec, + &self.task_executor, + ) + .await?; + + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_AGGREGATES_TOTAL, + &[validator_metrics::SUCCESS], + ); + + Ok(SignedAggregateAndProof::from_aggregate_and_proof( + message, signature, + )) + } + + pub async fn produce_sync_committee_signature( + &self, + slot: Slot, + beacon_block_root: Hash256, + validator_index: u64, + validator_pubkey: &PublicKeyBytes, + ) -> Result { + let signing_epoch = slot.epoch(E::slots_per_epoch()); + let signing_context = self.signing_context(Domain::SyncCommittee, signing_epoch); + + // Bypass `with_validator_signing_method`: sync committee messages are not slashable. + let signing_method = self.doppelganger_bypassed_signing_method(*validator_pubkey)?; + + let signature = signing_method + .get_signature::>( + SignableMessage::SyncCommitteeSignature { + beacon_block_root, + slot, + }, + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_SYNC_COMMITTEE_MESSAGES_TOTAL, + &[validator_metrics::SUCCESS], + ); + + Ok(SyncCommitteeMessage { + slot, + beacon_block_root, + validator_index, + signature, + }) + } + + pub async fn produce_signed_contribution_and_proof( + &self, + aggregator_index: u64, + aggregator_pubkey: PublicKeyBytes, + contribution: SyncCommitteeContribution, + selection_proof: SyncSelectionProof, + ) -> Result, Error> { + let signing_epoch = contribution.slot.epoch(E::slots_per_epoch()); + let signing_context = self.signing_context(Domain::ContributionAndProof, signing_epoch); + + // Bypass `with_validator_signing_method`: sync committee messages are not slashable. + let signing_method = self.doppelganger_bypassed_signing_method(aggregator_pubkey)?; + + let message = ContributionAndProof { + aggregator_index, + contribution, + selection_proof: selection_proof.into(), + }; + + let signature = signing_method + .get_signature::>( + SignableMessage::SignedContributionAndProof(&message), + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + validator_metrics::inc_counter_vec( + &validator_metrics::SIGNED_SYNC_COMMITTEE_CONTRIBUTIONS_TOTAL, + &[validator_metrics::SUCCESS], + ); + + Ok(SignedContributionAndProof { message, signature }) + } } impl ValidatorStore for LighthouseValidatorStore { @@ -882,72 +996,83 @@ impl ValidatorStore for LighthouseValidatorS } } - async fn sign_attestations( + fn sign_attestations( self: &Arc, - mut attestations: Vec<(u64, PublicKeyBytes, usize, Attestation)>, - ) -> Result)>, Error> { - // Sign all attestations concurrently. - let signing_futures = - attestations - .iter_mut() - .map(|(_, pubkey, validator_committee_index, attestation)| { + mut attestations: Vec>, + ) -> impl Stream)>, Error>> + Send { + let store = self.clone(); + stream::once(async move { + // Sign all attestations concurrently. + let signing_futures = attestations.iter_mut().map( + |AttestationToSign { + pubkey, + validator_committee_index, + attestation, + .. + }| { let pubkey = *pubkey; let validator_committee_index = *validator_committee_index; + let store = store.clone(); async move { - self.sign_attestation_no_slashing_protection( - pubkey, - validator_committee_index, - attestation, - ) - .await + store + .sign_attestation_no_slashing_protection( + pubkey, + validator_committee_index, + attestation, + ) + .await } - }); + }, + ); - // Execute all signing in parallel. - let results: Vec<_> = join_all(signing_futures).await; + // Execute all signing in parallel. + let results: Vec<_> = join_all(signing_futures).await; - // Collect successfully signed attestations and log errors. - let mut signed_attestations = Vec::with_capacity(attestations.len()); - for (result, (validator_index, pubkey, _, attestation)) in - results.into_iter().zip(attestations.into_iter()) - { - match result { - Ok(()) => { - signed_attestations.push((validator_index, attestation, pubkey)); - } - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - warn!( - info = "a validator may have recently been removed from this VC", - ?pubkey, - "Missing pubkey for attestation" - ); - } - Err(e) => { - crit!( - error = ?e, - "Failed to sign attestation" - ); + // Collect successfully signed attestations and log errors. + let mut signed_attestations = Vec::with_capacity(attestations.len()); + for (result, att) in results.into_iter().zip(attestations.into_iter()) { + match result { + Ok(()) => { + signed_attestations.push(( + att.validator_index, + att.attestation, + att.pubkey, + )); + } + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + warn!( + info = "a validator may have recently been removed from this VC", + ?pubkey, + "Missing pubkey for attestation" + ); + } + Err(e) => { + crit!( + error = ?e, + "Failed to sign attestation" + ); + } } } - } - if signed_attestations.is_empty() { - return Ok(vec![]); - } + if signed_attestations.is_empty() { + return Ok(vec![]); + } - // Check slashing protection and insert into database. Use a dedicated blocking thread - // to avoid clogging the async executor with blocking database I/O. - let validator_store = self.clone(); - let safe_attestations = self - .task_executor - .spawn_blocking_handle( - move || validator_store.slashing_protect_attestations(signed_attestations), - "slashing_protect_attestations", - ) - .ok_or(Error::ExecutorError)? - .await - .map_err(|_| Error::ExecutorError)??; - Ok(safe_attestations) + // Check slashing protection and insert into database. Use a dedicated blocking + // thread to avoid clogging the async executor with blocking database I/O. + let validator_store = store.clone(); + let safe_attestations = store + .task_executor + .spawn_blocking_handle( + move || validator_store.slashing_protect_attestations(signed_attestations), + "slashing_protect_attestations", + ) + .ok_or(Error::ExecutorError)? + .await + .map_err(|_| Error::ExecutorError)??; + Ok(safe_attestations) + }) } async fn sign_validator_registration_data( @@ -979,43 +1104,6 @@ impl ValidatorStore for LighthouseValidatorS }) } - /// Signs an `AggregateAndProof` for a given validator. - /// - /// The resulting `SignedAggregateAndProof` is sent on the aggregation channel and cannot be - /// modified by actors other than the signing validator. - async fn produce_signed_aggregate_and_proof( - &self, - validator_pubkey: PublicKeyBytes, - aggregator_index: u64, - aggregate: Attestation, - selection_proof: SelectionProof, - ) -> Result, Error> { - let signing_epoch = aggregate.data().target.epoch; - let signing_context = self.signing_context(Domain::AggregateAndProof, signing_epoch); - - let message = - AggregateAndProof::from_attestation(aggregator_index, aggregate, selection_proof); - - let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?; - let signature = signing_method - .get_signature::>( - SignableMessage::SignedAggregateAndProof(message.to_ref()), - signing_context, - &self.spec, - &self.task_executor, - ) - .await?; - - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_AGGREGATES_TOTAL, - &[validator_metrics::SUCCESS], - ); - - Ok(SignedAggregateAndProof::from_aggregate_and_proof( - message, signature, - )) - } - /// Produces a `SelectionProof` for the `slot`, signed by with corresponding secret key to /// `validator_pubkey`. async fn produce_selection_proof( @@ -1090,80 +1178,172 @@ impl ValidatorStore for LighthouseValidatorS Ok(signature.into()) } - async fn produce_sync_committee_signature( - &self, - slot: Slot, - beacon_block_root: Hash256, - validator_index: u64, - validator_pubkey: &PublicKeyBytes, - ) -> Result { - let signing_epoch = slot.epoch(E::slots_per_epoch()); - let signing_context = self.signing_context(Domain::SyncCommittee, signing_epoch); - - // Bypass `with_validator_signing_method`: sync committee messages are not slashable. - let signing_method = self.doppelganger_bypassed_signing_method(*validator_pubkey)?; - - let signature = signing_method - .get_signature::>( - SignableMessage::SyncCommitteeSignature { - beacon_block_root, - slot, + fn sign_aggregate_and_proofs( + self: &Arc, + aggregates: Vec>, + ) -> impl Stream>, Error>> + Send { + let store = self.clone(); + let count = aggregates.len(); + stream::once(async move { + let signing_futures = aggregates.into_iter().map( + |AggregateToSign { + pubkey, + aggregator_index, + aggregate, + selection_proof, + }| { + let store = store.clone(); + async move { + let result = store + .produce_signed_aggregate_and_proof( + pubkey, + aggregator_index, + aggregate, + selection_proof, + ) + .await; + (pubkey, result) + } }, - signing_context, - &self.spec, - &self.task_executor, - ) - .await - .map_err(Error::SpecificError)?; + ); - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_SYNC_COMMITTEE_MESSAGES_TOTAL, - &[validator_metrics::SUCCESS], - ); + let results = join_all(signing_futures) + .instrument(info_span!("sign_aggregates", count)) + .await; - Ok(SyncCommitteeMessage { - slot, - beacon_block_root, - validator_index, - signature, + let mut signed = Vec::with_capacity(results.len()); + for (pubkey, result) in results { + match result { + Ok(agg) => signed.push(agg), + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently + // removed via the API. + debug!(?pubkey, "Missing pubkey for aggregate"); + } + Err(e) => { + crit!(error = ?e, pubkey = ?pubkey, "Failed to sign aggregate"); + } + } + } + Ok(signed) }) } - async fn produce_signed_contribution_and_proof( - &self, - aggregator_index: u64, - aggregator_pubkey: PublicKeyBytes, - contribution: SyncCommitteeContribution, - selection_proof: SyncSelectionProof, - ) -> Result, Error> { - let signing_epoch = contribution.slot.epoch(E::slots_per_epoch()); - let signing_context = self.signing_context(Domain::ContributionAndProof, signing_epoch); + fn sign_sync_committee_signatures( + self: &Arc, + messages: Vec, + ) -> impl Stream, Error>> + Send { + let store = self.clone(); + let count = messages.len(); + stream::once(async move { + let signing_futures = messages.into_iter().map( + |SyncMessageToSign { + slot, + beacon_block_root, + validator_index, + pubkey, + }| { + let store = store.clone(); + async move { + let result = store + .produce_sync_committee_signature( + slot, + beacon_block_root, + validator_index, + &pubkey, + ) + .await; + (pubkey, validator_index, slot, result) + } + }, + ); - // Bypass `with_validator_signing_method`: sync committee messages are not slashable. - let signing_method = self.doppelganger_bypassed_signing_method(aggregator_pubkey)?; + let results = join_all(signing_futures) + .instrument(info_span!("sign_sync_signatures", count)) + .await; - let message = ContributionAndProof { - aggregator_index, - contribution, - selection_proof: selection_proof.into(), - }; + let mut signed = Vec::with_capacity(results.len()); + for (_pubkey, validator_index, slot, result) in results { + match result { + Ok(sig) => signed.push(sig), + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently + // removed via the API. + debug!( + ?pubkey, + validator_index, + %slot, + "Missing pubkey for sync committee signature" + ); + } + Err(e) => { + crit!( + validator_index, + %slot, + error = ?e, + "Failed to sign sync committee signature" + ); + } + } + } + Ok(signed) + }) + } - let signature = signing_method - .get_signature::>( - SignableMessage::SignedContributionAndProof(&message), - signing_context, - &self.spec, - &self.task_executor, - ) - .await - .map_err(Error::SpecificError)?; + fn sign_sync_committee_contributions( + self: &Arc, + contributions: Vec>, + ) -> impl Stream>, Error>> + Send { + let store = self.clone(); + let count = contributions.len(); + stream::once(async move { + let signing_futures = contributions.into_iter().map( + |ContributionToSign { + aggregator_index, + aggregator_pubkey, + contribution, + selection_proof, + }| { + let store = store.clone(); + let slot = contribution.slot; + async move { + let result = store + .produce_signed_contribution_and_proof( + aggregator_index, + aggregator_pubkey, + contribution, + selection_proof, + ) + .await; + (slot, result) + } + }, + ); - validator_metrics::inc_counter_vec( - &validator_metrics::SIGNED_SYNC_COMMITTEE_CONTRIBUTIONS_TOTAL, - &[validator_metrics::SUCCESS], - ); + let results = join_all(signing_futures) + .instrument(info_span!("sign_sync_contributions", count)) + .await; - Ok(SignedContributionAndProof { message, signature }) + let mut signed = Vec::with_capacity(results.len()); + for (slot, result) in results { + match result { + Ok(contribution) => signed.push(contribution), + Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { + // A pubkey can be missing when a validator was recently + // removed via the API. + debug!(?pubkey, %slot, "Missing pubkey for sync contribution"); + } + Err(e) => { + crit!( + %slot, + error = ?e, + "Unable to sign sync committee contribution" + ); + } + } + } + Ok(signed) + }) } /// Prune the slashing protection database so that it remains performant. diff --git a/validator_client/validator_services/src/attestation_service.rs b/validator_client/validator_services/src/attestation_service.rs index a9d5283312..fe808efd88 100644 --- a/validator_client/validator_services/src/attestation_service.rs +++ b/validator_client/validator_services/src/attestation_service.rs @@ -1,6 +1,6 @@ use crate::duties_service::{DutiesService, DutyAndProof}; use beacon_node_fallback::{ApiTopic, BeaconNodeFallback, beacon_head_monitor::HeadEvent}; -use futures::future::join_all; +use futures::StreamExt; use logging::crit; use slot_clock::SlotClock; use std::collections::HashMap; @@ -13,7 +13,7 @@ use tokio::time::{Duration, Instant, sleep, sleep_until}; use tracing::{Instrument, debug, error, info, info_span, instrument, warn}; use tree_hash::TreeHash; use types::{Attestation, AttestationData, ChainSpec, CommitteeIndex, EthSpec, Hash256, Slot}; -use validator_store::{Error as ValidatorStoreError, ValidatorStore}; +use validator_store::{AggregateToSign, AttestationToSign, ValidatorStore}; /// Builds an `AttestationService`. #[derive(Default)] @@ -560,12 +560,12 @@ impl AttestationService AttestationService(attestation_data.slot); - let single_attestations = safe_attestations - .iter() - .filter_map(|(i, a)| { - match a.to_single_attestation_with_attester_index(*i) { - Ok(a) => Some(a), - Err(e) => { - // This shouldn't happen unless BN and VC are out of sync with - // respect to the Electra fork. - error!( - error = ?e, + // Publish each batch as it arrives from the stream. + let mut received_non_empty_batch = false; + while let Some(result) = attestation_stream.next().await { + match result { + Ok(batch) if !batch.is_empty() => { + received_non_empty_batch = true; + + let single_attestations = batch + .iter() + .filter_map(|(attester_index, attestation)| { + match attestation + .to_single_attestation_with_attester_index(*attester_index) + { + Ok(single_attestation) => Some(single_attestation), + Err(e) => { + // This shouldn't happen unless BN and VC are out of sync with + // respect to the Electra fork. + error!( + error = ?e, + committee_index = attestation_data.index, + slot = slot.as_u64(), + "type" = "unaggregated", + "Unable to convert to SingleAttestation" + ); + None + } + } + }) + .collect::>(); + let single_attestations = &single_attestations; + let validator_indices = single_attestations + .iter() + .map(|att| att.attester_index) + .collect::>(); + let published_count = single_attestations.len(); + + // Post the attestations to the BN. + match self + .beacon_nodes + .request(ApiTopic::Attestations, |beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::ATTESTATION_SERVICE_TIMES, + &[validator_metrics::ATTESTATIONS_HTTP_POST], + ); + + beacon_node + .post_beacon_pool_attestations_v2::( + single_attestations.clone(), + fork_name, + ) + .await + }) + .instrument(info_span!("publish_attestations", count = published_count)) + .await + { + Ok(()) => info!( + count = published_count, + validator_indices = ?validator_indices, + head_block = ?attestation_data.beacon_block_root, + committee_index = attestation_data.index, + slot = attestation_data.slot.as_u64(), + "type" = "unaggregated", + "Successfully published attestations" + ), + Err(e) => error!( + error = %e, committee_index = attestation_data.index, slot = slot.as_u64(), "type" = "unaggregated", - "Unable to convert to SingleAttestation" - ); - None + "Unable to publish attestations" + ), } } - }) - .collect::>(); - let single_attestations = &single_attestations; - let validator_indices = single_attestations - .iter() - .map(|att| att.attester_index) - .collect::>(); - let published_count = single_attestations.len(); + Err(e) => { + crit!(error = ?e, "Failed to sign attestations"); + } + _ => {} + } + } - // Post the attestations to the BN. - match self - .beacon_nodes - .request(ApiTopic::Attestations, |beacon_node| async move { - let _timer = validator_metrics::start_timer_vec( - &validator_metrics::ATTESTATION_SERVICE_TIMES, - &[validator_metrics::ATTESTATIONS_HTTP_POST], - ); - - beacon_node - .post_beacon_pool_attestations_v2::( - single_attestations.clone(), - fork_name, - ) - .await - }) - .instrument(info_span!("publish_attestations", count = published_count)) - .await - { - Ok(()) => info!( - count = published_count, - validator_indices = ?validator_indices, - head_block = ?attestation_data.beacon_block_root, - committee_index = attestation_data.index, - slot = attestation_data.slot.as_u64(), - "type" = "unaggregated", - "Successfully published attestations" - ), - Err(e) => error!( - error = %e, - committee_index = attestation_data.index, - slot = slot.as_u64(), - "type" = "unaggregated", - "Unable to publish attestations" - ), + if !received_non_empty_batch { + warn!("No attestations were published"); } Ok(()) @@ -725,113 +737,103 @@ impl AttestationService(attestation_data, &self.chain_spec) { - crit!("Inconsistent validator duties during signing"); - return None; - } - - match self - .validator_store - .produce_signed_aggregate_and_proof( - duty.pubkey, - duty.validator_index, - aggregated_attestation.clone(), - selection_proof.clone(), - ) - .await - { - Ok(aggregate) => Some(aggregate), - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - // A pubkey can be missing when a validator was recently - // removed via the API. - debug!(?pubkey, "Missing pubkey for aggregate"); - None - } - Err(e) => { - crit!( - error = ?e, - pubkey = ?duty.pubkey, - "Failed to sign aggregate" - ); - None - } - } - }); - - // Execute all the futures in parallel, collecting any successful results. - let aggregator_count = validator_duties + // Build the batch of aggregates to sign. + let aggregates_to_sign: Vec<_> = validator_duties .iter() - .filter(|d| d.selection_proof.is_some()) - .count(); - let signed_aggregate_and_proofs = join_all(signing_futures) - .instrument(info_span!("sign_aggregates", count = aggregator_count)) - .await - .into_iter() - .flatten() - .collect::>(); + .filter_map(|duty_and_proof| { + let duty = &duty_and_proof.duty; + let selection_proof = duty_and_proof.selection_proof.as_ref()?; - if !signed_aggregate_and_proofs.is_empty() { - let signed_aggregate_and_proofs_slice = signed_aggregate_and_proofs.as_slice(); - match self - .beacon_nodes - .first_success(|beacon_node| async move { - let _timer = validator_metrics::start_timer_vec( - &validator_metrics::ATTESTATION_SERVICE_TIMES, - &[validator_metrics::AGGREGATES_HTTP_POST], - ); - if fork_name.electra_enabled() { - beacon_node - .post_validator_aggregate_and_proof_v2( - signed_aggregate_and_proofs_slice, - fork_name, - ) - .await - } else { - beacon_node - .post_validator_aggregate_and_proof_v1( - signed_aggregate_and_proofs_slice, - ) - .await - } + if !duty.match_attestation_data::(attestation_data, &self.chain_spec) { + crit!("Inconsistent validator duties during signing"); + return None; + } + + Some(AggregateToSign { + pubkey: duty.pubkey, + aggregator_index: duty.validator_index, + aggregate: aggregated_attestation.clone(), + selection_proof: selection_proof.clone(), }) - .instrument(info_span!( - "publish_aggregates", - count = signed_aggregate_and_proofs.len() - )) - .await - { - Ok(()) => { - for signed_aggregate_and_proof in signed_aggregate_and_proofs { - let attestation = signed_aggregate_and_proof.message().aggregate(); - info!( - aggregator = signed_aggregate_and_proof.message().aggregator_index(), - signatures = attestation.num_set_aggregation_bits(), - head_block = format!("{:?}", attestation.data().beacon_block_root), - committee_index = attestation.committee_index(), - slot = attestation.data().slot.as_u64(), - "type" = "aggregated", - "Successfully published attestation" - ); + }) + .collect(); + + // Sign aggregates. Returns a stream of batches. + let aggregate_stream = self + .validator_store + .sign_aggregate_and_proofs(aggregates_to_sign); + tokio::pin!(aggregate_stream); + + // Publish each batch as it arrives from the stream. + while let Some(result) = aggregate_stream.next().await { + match result { + Ok(batch) if !batch.is_empty() => { + let signed_aggregate_and_proofs = batch.as_slice(); + match self + .beacon_nodes + .first_success(|beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::ATTESTATION_SERVICE_TIMES, + &[validator_metrics::AGGREGATES_HTTP_POST], + ); + if fork_name.electra_enabled() { + beacon_node + .post_validator_aggregate_and_proof_v2( + signed_aggregate_and_proofs, + fork_name, + ) + .await + } else { + beacon_node + .post_validator_aggregate_and_proof_v1( + signed_aggregate_and_proofs, + ) + .await + } + }) + .instrument(info_span!( + "publish_aggregates", + count = signed_aggregate_and_proofs.len() + )) + .await + { + Ok(()) => { + for signed_aggregate_and_proof in signed_aggregate_and_proofs { + let attestation = signed_aggregate_and_proof.message().aggregate(); + info!( + aggregator = + signed_aggregate_and_proof.message().aggregator_index(), + signatures = attestation.num_set_aggregation_bits(), + head_block = + format!("{:?}", attestation.data().beacon_block_root), + committee_index = attestation.committee_index(), + slot = attestation.data().slot.as_u64(), + "type" = "aggregated", + "Successfully published attestation" + ); + } + } + Err(e) => { + for signed_aggregate_and_proof in signed_aggregate_and_proofs { + let attestation = &signed_aggregate_and_proof.message().aggregate(); + crit!( + error = %e, + aggregator = signed_aggregate_and_proof + .message() + .aggregator_index(), + committee_index = attestation.committee_index(), + slot = attestation.data().slot.as_u64(), + "type" = "aggregated", + "Failed to publish attestation" + ); + } + } } } Err(e) => { - for signed_aggregate_and_proof in signed_aggregate_and_proofs { - let attestation = &signed_aggregate_and_proof.message().aggregate(); - crit!( - error = %e, - aggregator = signed_aggregate_and_proof.message().aggregator_index(), - committee_index = attestation.committee_index(), - slot = attestation.data().slot.as_u64(), - "type" = "aggregated", - "Failed to publish attestation" - ); - } + crit!(error = ?e, "Failed to sign aggregates"); } + _ => {} } } diff --git a/validator_client/validator_services/src/sync_committee_service.rs b/validator_client/validator_services/src/sync_committee_service.rs index 59e8524a1a..26ce052ea0 100644 --- a/validator_client/validator_services/src/sync_committee_service.rs +++ b/validator_client/validator_services/src/sync_committee_service.rs @@ -2,8 +2,8 @@ use crate::duties_service::DutiesService; use beacon_node_fallback::{ApiTopic, BeaconNodeFallback}; use bls::PublicKeyBytes; use eth2::types::BlockId; +use futures::StreamExt; use futures::future::FutureExt; -use futures::future::join_all; use logging::crit; use slot_clock::SlotClock; use std::collections::HashMap; @@ -17,7 +17,7 @@ use types::{ ChainSpec, EthSpec, Hash256, Slot, SyncCommitteeSubscription, SyncContributionData, SyncDuty, SyncSelectionProof, SyncSubnetId, }; -use validator_store::{Error as ValidatorStoreError, ValidatorStore}; +use validator_store::{ContributionToSign, SyncMessageToSign, ValidatorStore}; pub const SUBSCRIPTION_LOOKAHEAD_EPOCHS: u64 = 4; @@ -247,78 +247,57 @@ impl SyncCommitteeService, ) -> Result<(), ()> { - // Create futures to produce sync committee signatures. - let signature_futures = validator_duties.iter().map(|duty| async move { - match self - .validator_store - .produce_sync_committee_signature( - slot, - beacon_block_root, - duty.validator_index, - &duty.pubkey, - ) - .await - { - Ok(signature) => Some(signature), - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - // A pubkey can be missing when a validator was recently - // removed via the API. - debug!( - ?pubkey, - validator_index = duty.validator_index, - %slot, - "Missing pubkey for sync committee signature" - ); - None + let messages_to_sign: Vec<_> = validator_duties + .iter() + .map(|duty| SyncMessageToSign { + slot, + beacon_block_root, + validator_index: duty.validator_index, + pubkey: duty.pubkey, + }) + .collect(); + + let signature_stream = self + .validator_store + .sign_sync_committee_signatures(messages_to_sign); + tokio::pin!(signature_stream); + + while let Some(result) = signature_stream.next().await { + match result { + Ok(committee_signatures) if !committee_signatures.is_empty() => { + let committee_signatures = &committee_signatures; + match self + .beacon_nodes + .request(ApiTopic::SyncCommittee, |beacon_node| async move { + beacon_node + .post_beacon_pool_sync_committee_signatures(committee_signatures) + .await + }) + .instrument(info_span!( + "publish_sync_signatures", + count = committee_signatures.len() + )) + .await + { + Ok(()) => info!( + count = committee_signatures.len(), + head_block = ?beacon_block_root, + %slot, + "Successfully published sync committee messages" + ), + Err(e) => error!( + %slot, + error = %e, + "Unable to publish sync committee messages" + ), + } } Err(e) => { - crit!( - validator_index = duty.validator_index, - %slot, - error = ?e, - "Failed to sign sync committee signature" - ); - None + crit!(%slot, error = ?e, "Failed to sign sync committee signatures"); } + _ => {} } - }); - - // Execute all the futures in parallel, collecting any successful results. - let committee_signatures = &join_all(signature_futures) - .instrument(info_span!( - "sign_sync_signatures", - count = validator_duties.len() - )) - .await - .into_iter() - .flatten() - .collect::>(); - - self.beacon_nodes - .request(ApiTopic::SyncCommittee, |beacon_node| async move { - beacon_node - .post_beacon_pool_sync_committee_signatures(committee_signatures) - .await - }) - .instrument(info_span!( - "publish_sync_signatures", - count = committee_signatures.len() - )) - .await - .map_err(|e| { - error!( - %slot, - error = %e, - "Unable to publish sync committee messages" - ); - })?; - - info!( - count = committee_signatures.len(), - head_block = ?beacon_block_root, - %slot, - "Successfully published sync committee messages" - ); + } Ok(()) } @@ -389,77 +368,61 @@ impl SyncCommitteeService Some(signed_contribution), - Err(ValidatorStoreError::UnknownPubkey(pubkey)) => { - // A pubkey can be missing when a validator was recently - // removed via the API. - debug!(?pubkey, %slot, "Missing pubkey for sync contribution"); - None - } - Err(e) => { - crit!( + let contributions_to_sign: Vec<_> = subnet_aggregators + .into_iter() + .map( + |(aggregator_index, aggregator_pk, selection_proof)| ContributionToSign { + aggregator_index, + aggregator_pubkey: aggregator_pk, + contribution: contribution.clone(), + selection_proof, + }, + ) + .collect(); + + let contribution_stream = self + .validator_store + .sign_sync_committee_contributions(contributions_to_sign); + tokio::pin!(contribution_stream); + + while let Some(result) = contribution_stream.next().await { + match result { + Ok(signed_contributions) if !signed_contributions.is_empty() => { + let signed_contributions = &signed_contributions; + // Publish to the beacon node. + match self + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .post_validator_contribution_and_proofs(signed_contributions) + .await + }) + .instrument(info_span!( + "publish_sync_contributions", + count = signed_contributions.len() + )) + .await + { + Ok(()) => info!( + subnet = %subnet_id, + beacon_block_root = %beacon_block_root, + num_signers = contribution.aggregation_bits.num_set_bits(), %slot, - error = ?e, - "Unable to sign sync committee contribution" - ); - None + "Successfully published sync contributions" + ), + Err(e) => error!( + %slot, + error = %e, + "Unable to publish signed contributions and proofs" + ), } } - }, - ); - - // Execute all the futures in parallel, collecting any successful results. - let signed_contributions = &join_all(signature_futures) - .instrument(info_span!( - "sign_sync_contributions", - count = aggregator_count - )) - .await - .into_iter() - .flatten() - .collect::>(); - - // Publish to the beacon node. - self.beacon_nodes - .first_success(|beacon_node| async move { - beacon_node - .post_validator_contribution_and_proofs(signed_contributions) - .await - }) - .instrument(info_span!( - "publish_sync_contributions", - count = signed_contributions.len() - )) - .await - .map_err(|e| { - error!( - %slot, - error = %e, - "Unable to publish signed contributions and proofs" - ); - })?; - - info!( - subnet = %subnet_id, - beacon_block_root = %beacon_block_root, - num_signers = contribution.aggregation_bits.num_set_bits(), - %slot, - "Successfully published sync contributions" - ); + Err(e) => { + crit!(%slot, error = ?e, "Failed to sign sync committee contributions"); + } + _ => {} + } + } Ok(()) } diff --git a/validator_client/validator_store/Cargo.toml b/validator_client/validator_store/Cargo.toml index 8b1879c837..2c6a68d494 100644 --- a/validator_client/validator_store/Cargo.toml +++ b/validator_client/validator_store/Cargo.toml @@ -7,5 +7,6 @@ authors = ["Sigma Prime "] [dependencies] bls = { workspace = true } eth2 = { workspace = true } +futures = { workspace = true } slashing_protection = { workspace = true } types = { workspace = true } diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index 87ab669e8d..da0b33de18 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -1,5 +1,6 @@ use bls::{PublicKeyBytes, Signature}; use eth2::types::{FullBlockContents, PublishBlockRequest}; +use futures::Stream; use slashing_protection::NotSafe; use std::fmt::Debug; use std::future::Future; @@ -32,6 +33,38 @@ impl From for Error { } } +/// Input for batch attestation signing +pub struct AttestationToSign { + pub validator_index: u64, + pub pubkey: PublicKeyBytes, + pub validator_committee_index: usize, + pub attestation: Attestation, +} + +/// Input for batch aggregate signing +pub struct AggregateToSign { + pub pubkey: PublicKeyBytes, + pub aggregator_index: u64, + pub aggregate: Attestation, + pub selection_proof: SelectionProof, +} + +/// Input for batch sync committee message signing +pub struct SyncMessageToSign { + pub slot: Slot, + pub beacon_block_root: Hash256, + pub validator_index: u64, + pub pubkey: PublicKeyBytes, +} + +/// Input for batch sync committee contribution signing +pub struct ContributionToSign { + pub aggregator_index: u64, + pub aggregator_pubkey: PublicKeyBytes, + pub contribution: SyncCommitteeContribution, + pub selection_proof: SyncSelectionProof, +} + /// A helper struct, used for passing data from the validator store to services. pub struct ProposalData { pub validator_index: Option, @@ -106,13 +139,9 @@ pub trait ValidatorStore: Send + Sync { /// Sign a batch of `attestations` and apply slashing protection to them. /// - /// Only successfully signed attestations that pass slashing protection are returned, along with - /// the validator index of the signer. Eventually this will be replaced by `SingleAttestation` - /// use. - /// - /// Input: - /// - /// * Vec of (validator_index, pubkey, validator_committee_index, attestation). + /// Returns a stream of batches of successfully signed attestations. Each batch contains + /// attestations that passed slashing protection, along with the validator index of the signer. + /// Eventually this will be replaced by `SingleAttestation` use. /// /// Output: /// @@ -120,26 +149,14 @@ pub trait ValidatorStore: Send + Sync { #[allow(clippy::type_complexity)] fn sign_attestations( self: &Arc, - attestations: Vec<(u64, PublicKeyBytes, usize, Attestation)>, - ) -> impl Future)>, Error>> + Send; + attestations: Vec>, + ) -> impl Stream)>, Error>> + Send; fn sign_validator_registration_data( &self, validator_registration_data: ValidatorRegistrationData, ) -> impl Future>> + Send; - /// Signs an `AggregateAndProof` for a given validator. - /// - /// The resulting `SignedAggregateAndProof` is sent on the aggregation channel and cannot be - /// modified by actors other than the signing validator. - fn produce_signed_aggregate_and_proof( - &self, - validator_pubkey: PublicKeyBytes, - aggregator_index: u64, - aggregate: Attestation, - selection_proof: SelectionProof, - ) -> impl Future, Error>> + Send; - /// Produces a `SelectionProof` for the `slot`, signed by with corresponding secret key to /// `validator_pubkey`. fn produce_selection_proof( @@ -156,21 +173,23 @@ pub trait ValidatorStore: Send + Sync { subnet_id: SyncSubnetId, ) -> impl Future>> + Send; - fn produce_sync_committee_signature( - &self, - slot: Slot, - beacon_block_root: Hash256, - validator_index: u64, - validator_pubkey: &PublicKeyBytes, - ) -> impl Future>> + Send; + /// Sign a batch of aggregate and proofs and return results as a stream of batches. + fn sign_aggregate_and_proofs( + self: &Arc, + aggregates: Vec>, + ) -> impl Stream>, Error>> + Send; - fn produce_signed_contribution_and_proof( - &self, - aggregator_index: u64, - aggregator_pubkey: PublicKeyBytes, - contribution: SyncCommitteeContribution, - selection_proof: SyncSelectionProof, - ) -> impl Future, Error>> + Send; + /// Sign a batch of sync committee messages and return results as a stream of batches. + fn sign_sync_committee_signatures( + self: &Arc, + messages: Vec, + ) -> impl Stream, Error>> + Send; + + /// Sign a batch of sync committee contributions and return results as a stream of batches. + fn sign_sync_committee_contributions( + self: &Arc, + contributions: Vec>, + ) -> impl Stream>, Error>> + Send; /// Prune the slashing protection database so that it remains performant. /// From 53a711956eb5c5ffeef277b2a13850bd4911946b Mon Sep 17 00:00:00 2001 From: Akihito Nakano Date: Sat, 14 Mar 2026 03:27:15 +0900 Subject: [PATCH 068/189] Fix flaky `test_same_subnet_unsubscription` (#8932) Co-Authored-By: figtracer <1gusredo@gmail.com> Co-Authored-By: ackintosh --- beacon_node/network/src/subnet_service/mod.rs | 7 ---- .../network/src/subnet_service/tests/mod.rs | 34 +++++++++---------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/beacon_node/network/src/subnet_service/mod.rs b/beacon_node/network/src/subnet_service/mod.rs index be491e56d3..008e7ab9ac 100644 --- a/beacon_node/network/src/subnet_service/mod.rs +++ b/beacon_node/network/src/subnet_service/mod.rs @@ -198,13 +198,6 @@ impl SubnetService { self.permanent_attestation_subscriptions.iter() } - /// Returns whether we are subscribed to a subnet for testing purposes. - #[cfg(test)] - pub(crate) fn is_subscribed(&self, subnet: &Subnet) -> bool { - self.subscriptions.contains_key(subnet) - || self.permanent_attestation_subscriptions.contains(subnet) - } - /// Returns whether we are subscribed to a permanent subnet for testing purposes. #[cfg(test)] pub(crate) fn is_subscribed_permanent(&self, subnet: &Subnet) -> bool { diff --git a/beacon_node/network/src/subnet_service/tests/mod.rs b/beacon_node/network/src/subnet_service/tests/mod.rs index bee6569b7b..619154d738 100644 --- a/beacon_node/network/src/subnet_service/tests/mod.rs +++ b/beacon_node/network/src/subnet_service/tests/mod.rs @@ -335,28 +335,26 @@ mod test { // submit the subscriptions subnet_service.validator_subscriptions(vec![sub1, sub2].into_iter()); - // Unsubscription event should happen at slot 2 (since subnet id's are the same, unsubscription event should be at higher slot + 1) - let expected = SubnetServiceMessage::Subscribe(Subnet::Attestation(subnet_id1)); + let subnet = Subnet::Attestation(subnet_id1); - if subnet_service.is_subscribed(&Subnet::Attestation(subnet_id1)) { - // If we are permanently subscribed to this subnet, we won't see a subscribe message - let _ = get_events_until_num_slots(&mut subnet_service, None, 1).await; + if subnet_service.is_subscribed_permanent(&subnet) { + // If permanently subscribed, no Subscribe/Unsubscribe events will be generated + let events = get_events_until_num_slots(&mut subnet_service, None, 3).await; + assert!(events.is_empty()); } else { - let subscription = get_events_until_num_slots(&mut subnet_service, None, 1).await; - assert_eq!(subscription, [expected]); + // Wait 1 slot: expect a single Subscribe event (no duplicate for the same subnet). + let events = get_events_until_num_slots(&mut subnet_service, None, 1).await; + assert_eq!(events, [SubnetServiceMessage::Subscribe(subnet)]); + + // Wait for the Unsubscribe event after subscription_slot2 expires. + // Use a longer timeout because the test doesn't start exactly at a slot + // boundary, so the previous 1-slot wait may end partway through slot 1, + // leaving insufficient time to catch the Unsubscribe within another 1 slot. + let events = get_events_until_num_slots(&mut subnet_service, Some(1), 3).await; + assert_eq!(events, [SubnetServiceMessage::Unsubscribe(subnet)]); } - // Get event for 1 more slot duration, we should get the unsubscribe event now. - let unsubscribe_event = get_events_until_num_slots(&mut subnet_service, None, 1).await; - - // If the long lived and short lived subnets are different, we should get an unsubscription - // event. - let expected = SubnetServiceMessage::Unsubscribe(Subnet::Attestation(subnet_id1)); - if !subnet_service.is_subscribed(&Subnet::Attestation(subnet_id1)) { - assert_eq!([expected], unsubscribe_event[..]); - } - - // Should no longer be subscribed to any short lived subnets after unsubscription. + // Should no longer be subscribed to any short lived subnets after unsubscription. assert_eq!(subnet_service.subscriptions().count(), 0); } From 02137492f30276619dbb764f4fada34c9d72cd21 Mon Sep 17 00:00:00 2001 From: ethDreamer <37123614+ethDreamer@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:22:25 -0500 Subject: [PATCH 069/189] Fix intermittent simulator test failures (#8983) Fixes intermittent simulator test failures with error: `Head not synced for node 2. Found 127; Should be 128` Modify the delayed node in `basic_sim` to join earlier, giving it sufficient time to discover peers and form a proper gossip mesh before the sync verification check. **Change:** Delayed node now joins at `END_EPOCH - 3` (epoch 13) instead of `END_EPOCH - 1` (epoch 15). Co-Authored-By: Mark Mackey Co-Authored-By: ethDreamer <37123614+ethDreamer@users.noreply.github.com> --- testing/simulator/src/basic_sim.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/simulator/src/basic_sim.rs b/testing/simulator/src/basic_sim.rs index a9d0a0756b..79581ee529 100644 --- a/testing/simulator/src/basic_sim.rs +++ b/testing/simulator/src/basic_sim.rs @@ -363,7 +363,7 @@ pub fn run_basic_sim(matches: &ArgMatches) -> Result<(), String> { network_1.add_beacon_node_with_delay( beacon_config.clone(), mock_execution_config.clone(), - END_EPOCH - 1, + END_EPOCH - 3, slot_duration, slots_per_epoch ), From 6ca610d918e8a12946c7c9baaeb4bcbfbc3429d5 Mon Sep 17 00:00:00 2001 From: ethDreamer <37123614+ethDreamer@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:22:29 -0500 Subject: [PATCH 070/189] Breakup RPCBlock into LookupBlock & RangeSyncBlock (#8860) Co-Authored-By: Mark Mackey --- beacon_node/beacon_chain/src/beacon_chain.rs | 8 +- .../beacon_chain/src/block_verification.rs | 97 +++++---- .../src/block_verification_types.rs | 201 +++++++++--------- .../src/data_availability_checker.rs | 13 +- beacon_node/beacon_chain/src/test_utils.rs | 180 ++++++++-------- .../tests/attestation_production.rs | 37 +--- .../beacon_chain/tests/blob_verification.rs | 7 +- .../beacon_chain/tests/block_verification.rs | 169 +++++---------- .../beacon_chain/tests/column_verification.rs | 16 +- .../tests/payload_invalidation.rs | 48 ++--- beacon_node/beacon_chain/tests/store_tests.rs | 53 ++--- beacon_node/http_api/src/publish_blocks.rs | 16 +- .../src/network_beacon_processor/mod.rs | 11 +- .../network_beacon_processor/sync_methods.rs | 51 ++--- .../src/network_beacon_processor/tests.rs | 32 +-- .../network/src/sync/backfill_sync/mod.rs | 6 +- beacon_node/network/src/sync/batch.rs | 4 +- .../src/sync/block_sidecar_coupling.rs | 28 +-- .../network/src/sync/network_context.rs | 19 +- .../network/src/sync/range_sync/chain.rs | 6 +- .../network/src/sync/range_sync/range.rs | 4 +- beacon_node/network/src/sync/tests/lookups.rs | 96 ++++----- beacon_node/network/src/sync/tests/mod.rs | 6 +- beacon_node/network/src/sync/tests/range.rs | 20 +- testing/ef_tests/src/cases/fork_choice.rs | 46 ++-- 25 files changed, 505 insertions(+), 669 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index ab2097e001..20af7b4630 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -13,7 +13,7 @@ use crate::block_verification::{ signature_verify_chain_segment, verify_header_signature, }; use crate::block_verification_types::{ - AsBlock, AvailableExecutedBlock, BlockImportData, ExecutedBlock, RpcBlock, + AsBlock, AvailableExecutedBlock, BlockImportData, ExecutedBlock, RangeSyncBlock, }; pub use crate::canonical_head::CanonicalHead; use crate::chain_config::ChainConfig; @@ -137,7 +137,7 @@ use types::*; pub type ForkChoiceError = fork_choice::Error; /// Alias to appease clippy. -type HashBlockTuple = (Hash256, RpcBlock); +type HashBlockTuple = (Hash256, RangeSyncBlock); // These keys are all zero because they get stored in different columns, see `DBColumn` type. pub const BEACON_CHAIN_DB_KEY: Hash256 = Hash256::ZERO; @@ -2746,7 +2746,7 @@ impl BeaconChain { /// This method is potentially long-running and should not run on the core executor. pub fn filter_chain_segment( self: &Arc, - chain_segment: Vec>, + chain_segment: Vec>, ) -> Result>, Box> { // This function will never import any blocks. let imported_blocks = vec![]; @@ -2855,7 +2855,7 @@ impl BeaconChain { /// `Self::process_block`. pub async fn process_chain_segment( self: &Arc, - chain_segment: Vec>, + chain_segment: Vec>, notify_execution_layer: NotifyExecutionLayer, ) -> ChainSegmentResult { for block in chain_segment.iter() { diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 1be9bd4181..06ec26185f 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -50,7 +50,7 @@ use crate::beacon_snapshot::PreProcessingSnapshot; use crate::blob_verification::GossipBlobError; -use crate::block_verification_types::{AsBlock, BlockImportData, RpcBlock}; +use crate::block_verification_types::{AsBlock, BlockImportData, LookupBlock, RangeSyncBlock}; use crate::data_availability_checker::{ AvailabilityCheckError, AvailableBlock, AvailableBlockData, MaybeAvailableBlock, }; @@ -585,7 +585,7 @@ pub(crate) fn process_block_slash_info( - mut chain_segment: Vec<(Hash256, RpcBlock)>, + mut chain_segment: Vec<(Hash256, RangeSyncBlock)>, chain: &BeaconChain, ) -> Result>, BlockError> { if chain_segment.is_empty() { @@ -616,24 +616,14 @@ pub fn signature_verify_chain_segment( let consensus_context = ConsensusContext::new(block.slot()).set_current_block_root(block_root); - match block { - RpcBlock::FullyAvailable(available_block) => { - available_blocks.push(available_block.clone()); - signature_verified_blocks.push(SignatureVerifiedBlock { - block: MaybeAvailableBlock::Available(available_block), - block_root, - parent: None, - consensus_context, - }); - } - RpcBlock::BlockOnly { .. } => { - // RangeSync and BackfillSync already ensure that the chain segment is fully available - // so this shouldn't be possible in practice. - return Err(BlockError::InternalError( - "Chain segment is not fully available".to_string(), - )); - } - } + let available_block = block.into_available_block(); + available_blocks.push(available_block.clone()); + signature_verified_blocks.push(SignatureVerifiedBlock { + block: MaybeAvailableBlock::Available(available_block), + block_root, + parent: None, + consensus_context, + }); } chain @@ -1300,11 +1290,11 @@ impl IntoExecutionPendingBlock for SignatureVerifiedBloc } } -impl IntoExecutionPendingBlock for RpcBlock { +impl IntoExecutionPendingBlock for RangeSyncBlock { /// Verifies the `SignedBeaconBlock` by first transforming it into a `SignatureVerifiedBlock` /// and then using that implementation of `IntoExecutionPendingBlock` to complete verification. #[instrument( - name = "rpc_block_into_execution_pending_block_slashable", + name = "range_sync_block_into_execution_pending_block_slashable", level = "debug" skip_all, )] @@ -1318,24 +1308,51 @@ impl IntoExecutionPendingBlock for RpcBlock let block_root = check_block_relevancy(self.as_block(), block_root, chain) .map_err(|e| BlockSlashInfo::SignatureNotChecked(self.signed_block_header(), e))?; - let maybe_available_block = match &self { - RpcBlock::FullyAvailable(available_block) => { - chain - .data_availability_checker - .verify_kzg_for_available_block(available_block) - .map_err(|e| { - BlockSlashInfo::SignatureNotChecked( - self.signed_block_header(), - BlockError::AvailabilityCheck(e), - ) - })?; - MaybeAvailableBlock::Available(available_block.clone()) - } - // No need to perform KZG verification unless we have a fully available block - RpcBlock::BlockOnly { block, block_root } => MaybeAvailableBlock::AvailabilityPending { - block_root: *block_root, - block: block.clone(), - }, + let available_block = self.into_available_block(); + chain + .data_availability_checker + .verify_kzg_for_available_block(&available_block) + .map_err(|e| { + BlockSlashInfo::SignatureNotChecked( + available_block.as_block().signed_block_header(), + BlockError::AvailabilityCheck(e), + ) + })?; + let maybe_available_block = MaybeAvailableBlock::Available(available_block); + SignatureVerifiedBlock::check_slashable(maybe_available_block, block_root, chain)? + .into_execution_pending_block_slashable(block_root, chain, notify_execution_layer) + } + + fn block(&self) -> &SignedBeaconBlock { + self.as_block() + } + + fn block_cloned(&self) -> Arc> { + self.block_cloned() + } +} + +impl IntoExecutionPendingBlock for LookupBlock { + /// Verifies the `SignedBeaconBlock` by first transforming it into a `SignatureVerifiedBlock` + /// and then using that implementation of `IntoExecutionPendingBlock` to complete verification. + #[instrument( + name = "lookup_block_into_execution_pending_block_slashable", + level = "debug" + skip_all, + )] + fn into_execution_pending_block_slashable( + self, + block_root: Hash256, + chain: &Arc>, + notify_execution_layer: NotifyExecutionLayer, + ) -> Result, BlockSlashInfo> { + // Perform an early check to prevent wasting time on irrelevant blocks. + let block_root = check_block_relevancy(self.as_block(), block_root, chain) + .map_err(|e| BlockSlashInfo::SignatureNotChecked(self.signed_block_header(), e))?; + + let maybe_available_block = MaybeAvailableBlock::AvailabilityPending { + block_root, + block: self.block_cloned(), }; SignatureVerifiedBlock::check_slashable(maybe_available_block, block_root, chain)? diff --git a/beacon_node/beacon_chain/src/block_verification_types.rs b/beacon_node/beacon_chain/src/block_verification_types.rs index f98cd40d08..be73ef15d7 100644 --- a/beacon_node/beacon_chain/src/block_verification_types.rs +++ b/beacon_node/beacon_chain/src/block_verification_types.rs @@ -13,76 +13,70 @@ use types::{ SignedBeaconBlock, SignedBeaconBlockHeader, Slot, }; -/// A block that has been received over RPC. It has 2 internal variants: -/// -/// 1. `FullyAvailable`: A fully available block. This can either be a pre-deneb block, a -/// post-Deneb block with blobs, a post-Fulu block with the columns the node is required to custody, -/// or a post-Deneb block that doesn't require blobs/columns. Hence, it is fully self contained w.r.t -/// verification. i.e. this block has all the required data to get verified and imported into fork choice. -/// -/// 2. `BlockOnly`: This is a post-deneb block that requires blobs to be considered fully available. -#[derive(Clone, Educe)] -#[educe(Hash(bound(E: EthSpec)))] -pub enum RpcBlock { - FullyAvailable(AvailableBlock), - BlockOnly { - block: Arc>, - block_root: Hash256, - }, +/// A wrapper around a `SignedBeaconBlock`. This varaint is constructed +/// when lookup sync only fetches a single block. It does not contain +/// any blobs or data columns. +pub struct LookupBlock { + block: Arc>, + block_root: Hash256, } -impl Debug for RpcBlock { +impl LookupBlock { + pub fn new(block: Arc>) -> Self { + let block_root = block.canonical_root(); + Self { block, block_root } + } + + pub fn block(&self) -> &SignedBeaconBlock { + &self.block + } + + pub fn block_root(&self) -> Hash256 { + self.block_root + } + + pub fn block_cloned(&self) -> Arc> { + self.block.clone() + } +} + +/// A fully available block that has been constructed by range sync. +/// The block contains all the data required to import into fork choice. +/// This includes any and all blobs/columns required, including zero if +/// none are required. This can happen if the block is pre-deneb or if +/// it's simply past the DA boundary. +#[derive(Clone, Educe)] +#[educe(Hash(bound(E: EthSpec)))] +pub struct RangeSyncBlock { + block: AvailableBlock, +} + +impl Debug for RangeSyncBlock { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "RpcBlock({:?})", self.block_root()) } } -impl RpcBlock { +impl RangeSyncBlock { pub fn block_root(&self) -> Hash256 { - match self { - RpcBlock::FullyAvailable(available_block) => available_block.block_root(), - RpcBlock::BlockOnly { block_root, .. } => *block_root, - } + self.block.block_root() } pub fn as_block(&self) -> &SignedBeaconBlock { - match self { - RpcBlock::FullyAvailable(available_block) => available_block.block(), - RpcBlock::BlockOnly { block, .. } => block, - } + self.block.block() } pub fn block_cloned(&self) -> Arc> { - match self { - RpcBlock::FullyAvailable(available_block) => available_block.block_cloned(), - RpcBlock::BlockOnly { block, .. } => block.clone(), - } + self.block.block_cloned() } - pub fn block_data(&self) -> Option<&AvailableBlockData> { - match self { - RpcBlock::FullyAvailable(available_block) => Some(available_block.data()), - RpcBlock::BlockOnly { .. } => None, - } + pub fn block_data(&self) -> &AvailableBlockData { + self.block.data() } } -impl RpcBlock { - /// Constructs an `RpcBlock` from a block and optional availability data. - /// - /// This function creates an RpcBlock which can be in one of two states: - /// - `FullyAvailable`: When `block_data` is provided, the block contains all required - /// data for verification. - /// - `BlockOnly`: When `block_data` is `None`, the block may still need additional - /// data to be considered fully available (used during block lookups or when blobs - /// will arrive separately). - /// - /// # Validation - /// - /// When `block_data` is provided, this function validates that: - /// - Block data is not provided when not required. - /// - Required blobs are present and match the expected count. - /// - Required custody columns are included based on the nodes custody requirements. +impl RangeSyncBlock { + /// Constructs an `RangeSyncBlock` from a block and availability data. /// /// # Errors /// @@ -92,62 +86,41 @@ impl RpcBlock { /// - `MissingCustodyColumns`: Block requires custody columns but they are incomplete. pub fn new( block: Arc>, - block_data: Option>, + block_data: AvailableBlockData, da_checker: &DataAvailabilityChecker, spec: Arc, ) -> Result where T: BeaconChainTypes, { - match block_data { - Some(block_data) => Ok(RpcBlock::FullyAvailable(AvailableBlock::new( - block, block_data, da_checker, spec, - )?)), - None => Ok(RpcBlock::BlockOnly { - block_root: block.canonical_root(), - block, - }), - } + let available_block = AvailableBlock::new(block, block_data, da_checker, spec)?; + Ok(Self { + block: available_block, + }) } #[allow(clippy::type_complexity)] - pub fn deconstruct( - self, - ) -> ( - Hash256, - Arc>, - Option>, - ) { - match self { - RpcBlock::FullyAvailable(available_block) => { - let (block_root, block, block_data) = available_block.deconstruct(); - (block_root, block, Some(block_data)) - } - RpcBlock::BlockOnly { block, block_root } => (block_root, block, None), - } + pub fn deconstruct(self) -> (Hash256, Arc>, AvailableBlockData) { + self.block.deconstruct() } pub fn n_blobs(&self) -> usize { - if let Some(block_data) = self.block_data() { - match block_data { - AvailableBlockData::NoData | AvailableBlockData::DataColumns(_) => 0, - AvailableBlockData::Blobs(blobs) => blobs.len(), - } - } else { - 0 + match self.block_data() { + AvailableBlockData::NoData | AvailableBlockData::DataColumns(_) => 0, + AvailableBlockData::Blobs(blobs) => blobs.len(), } } pub fn n_data_columns(&self) -> usize { - if let Some(block_data) = self.block_data() { - match block_data { - AvailableBlockData::NoData | AvailableBlockData::Blobs(_) => 0, - AvailableBlockData::DataColumns(columns) => columns.len(), - } - } else { - 0 + match self.block_data() { + AvailableBlockData::NoData | AvailableBlockData::Blobs(_) => 0, + AvailableBlockData::DataColumns(columns) => columns.len(), } } + + pub fn into_available_block(self) -> AvailableBlock { + self.block + } } /// A block that has gone through all pre-deneb block processing checks including block processing @@ -412,7 +385,7 @@ impl AsBlock for AvailableBlock { } } -impl AsBlock for RpcBlock { +impl AsBlock for RangeSyncBlock { fn slot(&self) -> Slot { self.as_block().slot() } @@ -432,24 +405,42 @@ impl AsBlock for RpcBlock { self.as_block().message() } fn as_block(&self) -> &SignedBeaconBlock { - match self { - Self::BlockOnly { - block, - block_root: _, - } => block, - Self::FullyAvailable(available_block) => available_block.block(), - } + self.block.as_block() } fn block_cloned(&self) -> Arc> { - match self { - RpcBlock::FullyAvailable(available_block) => available_block.block_cloned(), - RpcBlock::BlockOnly { - block, - block_root: _, - } => block.clone(), - } + self.block.block_cloned() } fn canonical_root(&self) -> Hash256 { - self.as_block().canonical_root() + self.block.block_root() + } +} + +impl AsBlock for LookupBlock { + fn slot(&self) -> Slot { + self.block().slot() + } + fn epoch(&self) -> Epoch { + self.block().epoch() + } + fn parent_root(&self) -> Hash256 { + self.block().parent_root() + } + fn state_root(&self) -> Hash256 { + self.block().state_root() + } + fn signed_block_header(&self) -> SignedBeaconBlockHeader { + self.block().signed_block_header() + } + fn message(&self) -> BeaconBlockRef<'_, E> { + self.block().message() + } + fn as_block(&self) -> &SignedBeaconBlock { + self.block() + } + fn block_cloned(&self) -> Arc> { + self.block_cloned() + } + fn canonical_root(&self) -> Hash256 { + self.block_root } } diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index e266e02f7f..4372efa809 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -891,7 +891,7 @@ impl MaybeAvailableBlock { mod test { use super::*; use crate::CustodyContext; - use crate::block_verification_types::RpcBlock; + use crate::block_verification_types::RangeSyncBlock; use crate::custody_context::NodeCustodyType; use crate::data_column_verification::CustodyDataColumn; use crate::test_utils::{ @@ -1085,7 +1085,7 @@ mod test { /// Regression test for KZG verification truncation bug (https://github.com/sigp/lighthouse/pull/7927) #[test] - fn verify_kzg_for_rpc_blocks_should_not_truncate_data_columns_fulu() { + fn verify_kzg_for_range_sync_blocks_should_not_truncate_data_columns_fulu() { let spec = Arc::new(ForkName::Fulu.make_genesis_spec(E::default_spec())); let mut rng = StdRng::seed_from_u64(0xDEADBEEF0BAD5EEDu64); let da_checker = new_da_checker(spec.clone()); @@ -1128,17 +1128,14 @@ mod test { let block_data = AvailableBlockData::new_with_data_columns(custody_columns); let da_checker = Arc::new(new_da_checker(spec.clone())); - RpcBlock::new(Arc::new(block), Some(block_data), &da_checker, spec.clone()) + RangeSyncBlock::new(Arc::new(block), block_data, &da_checker, spec.clone()) .expect("should create RPC block with custody columns") }) .collect::>(); let available_blocks = blocks_with_columns - .iter() - .filter_map(|block| match block { - RpcBlock::FullyAvailable(available_block) => Some(available_block.clone()), - RpcBlock::BlockOnly { .. } => None, - }) + .into_iter() + .map(|block| block.into_available_block()) .collect::>(); // WHEN verifying all blocks together (totalling 256 data columns) diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 4bc5bb21d3..c53c29438e 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1,5 +1,5 @@ use crate::blob_verification::GossipVerifiedBlob; -use crate::block_verification_types::{AsBlock, AvailableBlockData, RpcBlock}; +use crate::block_verification_types::{AsBlock, AvailableBlockData, LookupBlock, RangeSyncBlock}; use crate::custody_context::NodeCustodyType; use crate::data_availability_checker::DataAvailabilityChecker; use crate::graffiti_calculator::GraffitiSettings; @@ -823,20 +823,20 @@ where mock_builder_server } - pub fn get_head_block(&self) -> RpcBlock { + pub fn get_head_block(&self) -> RangeSyncBlock { let block = self.chain.head_beacon_block(); let block_root = block.canonical_root(); - self.build_rpc_block_from_store_blobs(Some(block_root), block) + self.build_range_sync_block_from_store_blobs(Some(block_root), block) } - pub fn get_full_block(&self, block_root: &Hash256) -> RpcBlock { + pub fn get_full_block(&self, block_root: &Hash256) -> RangeSyncBlock { let block = self .chain .get_blinded_block(block_root) .unwrap() .unwrap_or_else(|| panic!("block root does not exist in harness {block_root:?}")); let full_block = self.chain.store.make_full_block(block_root, block).unwrap(); - self.build_rpc_block_from_store_blobs(Some(*block_root), Arc::new(full_block)) + self.build_range_sync_block_from_store_blobs(Some(*block_root), Arc::new(full_block)) } pub fn get_all_validators(&self) -> Vec { @@ -1340,15 +1340,12 @@ where let signed_block = self.sign_beacon_block(block, state); let block_root = signed_block.canonical_root(); - let rpc_block = RpcBlock::BlockOnly { - block_root, - block: Arc::new(signed_block), - }; + let lookup_block = LookupBlock::new(Arc::new(signed_block)); self.chain.slot_clock.set_slot(slot.as_u64()); self.chain .process_block( block_root, - rpc_block, + lookup_block, NotifyExecutionLayer::No, BlockImportSource::Lookup, || Ok(()), @@ -2607,20 +2604,33 @@ where .blob_kzg_commitments() .is_ok_and(|c| !c.is_empty()); let is_available = !has_blob_commitments || blob_items.is_some(); + let block_hash: SignedBeaconBlockHash = if !is_available { + self.chain + .process_block( + block_root, + LookupBlock::new(block), + NotifyExecutionLayer::Yes, + BlockImportSource::Lookup, + || Ok(()), + ) + .await? + .try_into() + .expect("block blobs are available") + } else { + let range_sync_block = self.build_range_sync_block_from_blobs(block, blob_items)?; + self.chain + .process_block( + block_root, + range_sync_block, + NotifyExecutionLayer::Yes, + BlockImportSource::RangeSync, + || Ok(()), + ) + .await? + .try_into() + .expect("block blobs are available") + }; - let rpc_block = self.build_rpc_block_from_blobs(block, blob_items, is_available)?; - let block_hash: SignedBeaconBlockHash = self - .chain - .process_block( - block_root, - rpc_block, - NotifyExecutionLayer::Yes, - BlockImportSource::RangeSync, - || Ok(()), - ) - .await? - .try_into() - .expect("block blobs are available"); self.chain.recompute_head_at_current_slot().await; Ok(block_hash) } @@ -2640,19 +2650,33 @@ where .blob_kzg_commitments() .is_ok_and(|c| !c.is_empty()); let is_available = !has_blob_commitments || blob_items.is_some(); - let rpc_block = self.build_rpc_block_from_blobs(block, blob_items, is_available)?; - let block_hash: SignedBeaconBlockHash = self - .chain - .process_block( - block_root, - rpc_block, - NotifyExecutionLayer::Yes, - BlockImportSource::RangeSync, - || Ok(()), - ) - .await? - .try_into() - .expect("block blobs are available"); + let block_hash: SignedBeaconBlockHash = if is_available { + let range_sync_block = self.build_range_sync_block_from_blobs(block, blob_items)?; + self.chain + .process_block( + block_root, + range_sync_block, + NotifyExecutionLayer::Yes, + BlockImportSource::RangeSync, + || Ok(()), + ) + .await? + .try_into() + .expect("block blobs are available") + } else { + self.chain + .process_block( + block_root, + LookupBlock::new(block), + NotifyExecutionLayer::Yes, + BlockImportSource::Lookup, + || Ok(()), + ) + .await? + .try_into() + .expect("block blobs are available") + }; + self.chain.recompute_head_at_current_slot().await; Ok(block_hash) } @@ -2735,13 +2759,13 @@ where state_root } - /// Builds an `Rpc` block from a `SignedBeaconBlock` and blobs or data columns retrieved from + /// Builds a `RangeSyncBlock` from a `SignedBeaconBlock` and blobs or data columns retrieved from /// the database. - pub fn build_rpc_block_from_store_blobs( + pub fn build_range_sync_block_from_store_blobs( &self, block_root: Option, block: Arc>, - ) -> RpcBlock { + ) -> RangeSyncBlock { let block_root = block_root.unwrap_or_else(|| get_block_root(&block)); let has_blobs = block .message() @@ -2749,9 +2773,9 @@ where .blob_kzg_commitments() .is_ok_and(|c| !c.is_empty()); if !has_blobs { - return RpcBlock::new( + return RangeSyncBlock::new( block, - Some(AvailableBlockData::NoData), + AvailableBlockData::NoData, &self.chain.data_availability_checker, self.chain.spec.clone(), ) @@ -2768,9 +2792,9 @@ where .unwrap(); let custody_columns = columns.into_iter().collect::>(); let block_data = AvailableBlockData::new_with_data_columns(custody_columns); - RpcBlock::new( + RangeSyncBlock::new( block, - Some(block_data), + block_data, &self.chain.data_availability_checker, self.chain.spec.clone(), ) @@ -2783,9 +2807,9 @@ where AvailableBlockData::NoData }; - RpcBlock::new( + RangeSyncBlock::new( block, - Some(block_data), + block_data, &self.chain.data_availability_checker, self.chain.spec.clone(), ) @@ -2793,18 +2817,17 @@ where } } - /// Builds an `RpcBlock` from a `SignedBeaconBlock` and `BlobsList`. - pub fn build_rpc_block_from_blobs( + /// Builds a `RangeSyncBlock` from a `SignedBeaconBlock` and `BlobsList`. + pub fn build_range_sync_block_from_blobs( &self, block: Arc>>, blob_items: Option<(KzgProofs, BlobsList)>, - is_available: bool, - ) -> Result, BlockError> { + ) -> Result, BlockError> { Ok(if self.spec.is_peer_das_enabled_for_epoch(block.epoch()) { let epoch = block.slot().epoch(E::slots_per_epoch()); let sampling_columns = self.chain.sampling_columns_for_epoch(epoch); - if blob_items.is_some_and(|(_, blobs)| !blobs.is_empty()) { + if blob_items.is_some_and(|(kzg_proofs, _)| !kzg_proofs.is_empty()) { // Note: this method ignores the actual custody columns and just take the first // `sampling_column_count` for testing purpose only, because the chain does not // currently have any knowledge of the columns being custodied. @@ -2812,33 +2835,17 @@ where .into_iter() .filter(|d| sampling_columns.contains(d.index())) .collect::>(); - if is_available { - let block_data = AvailableBlockData::new_with_data_columns(columns); - RpcBlock::new( - block, - Some(block_data), - &self.chain.data_availability_checker, - self.chain.spec.clone(), - )? - } else { - RpcBlock::new( - block, - None, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - )? - } - } else if is_available { - RpcBlock::new( + let block_data = AvailableBlockData::new_with_data_columns(columns); + RangeSyncBlock::new( block, - Some(AvailableBlockData::NoData), + block_data, &self.chain.data_availability_checker, self.chain.spec.clone(), )? } else { - RpcBlock::new( + RangeSyncBlock::new( block, - None, + AvailableBlockData::NoData, &self.chain.data_availability_checker, self.chain.spec.clone(), )? @@ -2850,27 +2857,18 @@ where }) .transpose() .unwrap(); - if is_available { - let block_data = if let Some(blobs) = blobs { - AvailableBlockData::new_with_blobs(blobs) - } else { - AvailableBlockData::NoData - }; - - RpcBlock::new( - block, - Some(block_data), - &self.chain.data_availability_checker, - self.chain.spec.clone(), - )? + let block_data = if let Some(blobs) = blobs { + AvailableBlockData::new_with_blobs(blobs) } else { - RpcBlock::new( - block, - None, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - )? - } + AvailableBlockData::NoData + }; + + RangeSyncBlock::new( + block, + block_data, + &self.chain.data_availability_checker, + self.chain.spec.clone(), + )? }) } diff --git a/beacon_node/beacon_chain/tests/attestation_production.rs b/beacon_node/beacon_chain/tests/attestation_production.rs index a1922f32a4..bca60d27cd 100644 --- a/beacon_node/beacon_chain/tests/attestation_production.rs +++ b/beacon_node/beacon_chain/tests/attestation_production.rs @@ -1,7 +1,6 @@ #![cfg(not(debug_assertions))] use beacon_chain::attestation_simulator::produce_unaggregated_attestation; -use beacon_chain::block_verification_types::RpcBlock; use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::test_utils::{AttestationStrategy, BeaconChainHarness, BlockStrategy}; use beacon_chain::validator_monitor::UNAGGREGATED_ATTESTATION_LAG_SLOTS; @@ -223,19 +222,9 @@ async fn produces_attestations() { assert_eq!(data.target.epoch, state.current_epoch(), "bad target epoch"); assert_eq!(data.target.root, target_root, "bad target root"); - let rpc_block = - harness.build_rpc_block_from_store_blobs(Some(block_root), Arc::new(block.clone())); - - let available_block = match rpc_block { - RpcBlock::FullyAvailable(available_block) => { - chain - .data_availability_checker - .verify_kzg_for_available_block(&available_block) - .unwrap(); - available_block - } - RpcBlock::BlockOnly { .. } => panic!("block should be available"), - }; + let range_sync_block = harness + .build_range_sync_block_from_store_blobs(Some(block_root), Arc::new(block.clone())); + let available_block = range_sync_block.into_available_block(); let early_attestation = { let proto_block = chain @@ -292,20 +281,12 @@ async fn early_attester_cache_old_request() { .get_block(&head.beacon_block_root) .unwrap(); - let rpc_block = harness - .build_rpc_block_from_store_blobs(Some(head.beacon_block_root), head.beacon_block.clone()); - - let available_block = match rpc_block { - RpcBlock::FullyAvailable(available_block) => { - harness - .chain - .data_availability_checker - .verify_kzg_for_available_block(&available_block) - .unwrap(); - available_block - } - RpcBlock::BlockOnly { .. } => panic!("block should be available"), - }; + let available_block = harness + .build_range_sync_block_from_store_blobs( + Some(head.beacon_block_root), + head.beacon_block.clone(), + ) + .into_available_block(); harness .chain diff --git a/beacon_node/beacon_chain/tests/blob_verification.rs b/beacon_node/beacon_chain/tests/blob_verification.rs index ee61177b2a..0ee9a7dba6 100644 --- a/beacon_node/beacon_chain/tests/blob_verification.rs +++ b/beacon_node/beacon_chain/tests/blob_verification.rs @@ -5,7 +5,7 @@ use beacon_chain::test_utils::{ }; use beacon_chain::{ AvailabilityProcessingStatus, BlockError, ChainConfig, InvalidSignature, NotifyExecutionLayer, - block_verification_types::AsBlock, + block_verification_types::{AsBlock, LookupBlock}, }; use bls::{Keypair, Signature}; use logging::create_test_tracing_subscriber; @@ -76,14 +76,11 @@ async fn rpc_blobs_with_invalid_header_signature() { // Process the block without blobs so that it doesn't become available. harness.advance_slot(); - let rpc_block = harness - .build_rpc_block_from_blobs(signed_block.clone(), None, false) - .unwrap(); let availability = harness .chain .process_block( block_root, - rpc_block, + LookupBlock::new(signed_block.clone()), NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), diff --git a/beacon_node/beacon_chain/tests/block_verification.rs b/beacon_node/beacon_chain/tests/block_verification.rs index e385e0dc48..8981b20a55 100644 --- a/beacon_node/beacon_chain/tests/block_verification.rs +++ b/beacon_node/beacon_chain/tests/block_verification.rs @@ -1,6 +1,6 @@ #![cfg(not(debug_assertions))] // TODO(gloas) we probably need similar test for payload envelope verification -use beacon_chain::block_verification_types::{AsBlock, ExecutedBlock, RpcBlock}; +use beacon_chain::block_verification_types::{AsBlock, ExecutedBlock, LookupBlock, RangeSyncBlock}; use beacon_chain::data_availability_checker::{AvailabilityCheckError, AvailableBlockData}; use beacon_chain::data_column_verification::CustodyDataColumn; use beacon_chain::{ @@ -13,7 +13,7 @@ use beacon_chain::{ }; use beacon_chain::{ BeaconSnapshot, BlockError, ChainConfig, ChainSegmentResult, IntoExecutionPendingBlock, - InvalidSignature, NotifyExecutionLayer, signature_verify_chain_segment, + InvalidSignature, NotifyExecutionLayer, }; use bls::{AggregateSignature, Keypair, Signature}; use fixed_bytes::FixedBytesExtended; @@ -136,7 +136,7 @@ fn chain_segment_blocks( chain_segment: &[BeaconSnapshot], chain_segment_sidecars: &[Option>], chain: Arc>, -) -> Vec> +) -> Vec> where T: BeaconChainTypes, { @@ -145,25 +145,25 @@ where .zip(chain_segment_sidecars.iter()) .map(|(snapshot, data_sidecars)| { let block = snapshot.beacon_block.clone(); - build_rpc_block(block, data_sidecars, chain.clone()) + build_range_sync_block(block, data_sidecars, chain.clone()) }) .collect() } -fn build_rpc_block( +fn build_range_sync_block( block: Arc>, data_sidecars: &Option>, chain: Arc>, -) -> RpcBlock +) -> RangeSyncBlock where T: BeaconChainTypes, { match data_sidecars { Some(DataSidecars::Blobs(blobs)) => { let block_data = AvailableBlockData::new_with_blobs(blobs.clone()); - RpcBlock::new( + RangeSyncBlock::new( block, - Some(block_data), + block_data, &chain.data_availability_checker, chain.spec.clone(), ) @@ -176,17 +176,17 @@ where .map(|c| c.as_data_column().clone()) .collect::>(), ); - RpcBlock::new( + RangeSyncBlock::new( block, - Some(block_data), + block_data, &chain.data_availability_checker, chain.spec.clone(), ) .unwrap() } - None => RpcBlock::new( + None => RangeSyncBlock::new( block, - Some(AvailableBlockData::NoData), + AvailableBlockData::NoData, &chain.data_availability_checker, chain.spec.clone(), ) @@ -301,7 +301,7 @@ fn update_data_column_signed_header( async fn chain_segment_full_segment() { let harness = get_harness(VALIDATOR_COUNT, NodeCustodyType::Fullnode); let (chain_segment, chain_segment_blobs) = get_chain_segment().await; - let blocks: Vec> = + let blocks: Vec> = chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) .into_iter() .collect(); @@ -339,7 +339,7 @@ async fn chain_segment_full_segment() { async fn chain_segment_varying_chunk_size() { let (chain_segment, chain_segment_blobs) = get_chain_segment().await; let harness = get_harness(VALIDATOR_COUNT, NodeCustodyType::Fullnode); - let blocks: Vec> = + let blocks: Vec> = chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) .into_iter() .collect(); @@ -384,7 +384,7 @@ async fn chain_segment_non_linear_parent_roots() { /* * Test with a block removed. */ - let mut blocks: Vec> = + let mut blocks: Vec> = chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) .into_iter() .collect(); @@ -405,7 +405,7 @@ async fn chain_segment_non_linear_parent_roots() { /* * Test with a modified parent root. */ - let mut blocks: Vec> = + let mut blocks: Vec> = chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) .into_iter() .collect(); @@ -413,9 +413,9 @@ async fn chain_segment_non_linear_parent_roots() { let (mut block, signature) = blocks[3].as_block().clone().deconstruct(); *block.parent_root_mut() = Hash256::zero(); - blocks[3] = RpcBlock::new( + blocks[3] = RangeSyncBlock::new( Arc::new(SignedBeaconBlock::from_block(block, signature)), - blocks[3].block_data().cloned(), + blocks[3].block_data().clone(), &harness.chain.data_availability_checker, harness.spec.clone(), ) @@ -447,15 +447,15 @@ async fn chain_segment_non_linear_slots() { * Test where a child is lower than the parent. */ - let mut blocks: Vec> = + let mut blocks: Vec> = chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) .into_iter() .collect(); let (mut block, signature) = blocks[3].as_block().clone().deconstruct(); *block.slot_mut() = Slot::new(0); - blocks[3] = RpcBlock::new( + blocks[3] = RangeSyncBlock::new( Arc::new(SignedBeaconBlock::from_block(block, signature)), - blocks[3].block_data().cloned(), + blocks[3].block_data().clone(), &harness.chain.data_availability_checker, harness.spec.clone(), ) @@ -477,15 +477,15 @@ async fn chain_segment_non_linear_slots() { * Test where a child is equal to the parent. */ - let mut blocks: Vec> = + let mut blocks: Vec> = chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) .into_iter() .collect(); let (mut block, signature) = blocks[3].as_block().clone().deconstruct(); *block.slot_mut() = blocks[2].slot(); - blocks[3] = RpcBlock::new( + blocks[3] = RangeSyncBlock::new( Arc::new(SignedBeaconBlock::from_block(block, signature)), - blocks[3].block_data().cloned(), + blocks[3].block_data().clone(), &harness.chain.data_availability_checker, harness.chain.spec.clone(), ) @@ -512,11 +512,11 @@ async fn assert_invalid_signature( snapshots: &[BeaconSnapshot], item: &str, ) { - let blocks: Vec> = snapshots + let blocks: Vec> = snapshots .iter() .zip(chain_segment_blobs.iter()) .map(|(snapshot, blobs)| { - build_rpc_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) + build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) }) .collect(); @@ -543,7 +543,7 @@ async fn assert_invalid_signature( .take(block_index) .zip(chain_segment_blobs.iter()) .map(|(snapshot, blobs)| { - build_rpc_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) + build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) }) .collect(); // We don't care if this fails, we just call this to ensure that all prior blocks have been @@ -558,7 +558,7 @@ async fn assert_invalid_signature( .chain .process_block( snapshots[block_index].beacon_block.canonical_root(), - build_rpc_block( + build_range_sync_block( snapshots[block_index].beacon_block.clone(), &chain_segment_blobs[block_index], harness.chain.clone(), @@ -620,7 +620,7 @@ async fn invalid_signature_gossip_block() { .take(block_index) .zip(chain_segment_blobs.iter()) .map(|(snapshot, blobs)| { - build_rpc_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) + build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) }) .collect(); harness @@ -630,18 +630,12 @@ async fn invalid_signature_gossip_block() { .into_block_error() .expect("should import all blocks prior to the one being tested"); let signed_block = SignedBeaconBlock::from_block(block, junk_signature()); - let rpc_block = RpcBlock::new( - Arc::new(signed_block), - None, - &harness.chain.data_availability_checker, - harness.spec.clone(), - ) - .unwrap(); + let lookup_block = LookupBlock::new(Arc::new(signed_block)); let process_res = harness .chain .process_block( - rpc_block.block_root(), - rpc_block, + lookup_block.block_root(), + lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -675,11 +669,11 @@ async fn invalid_signature_block_proposal() { block.clone(), junk_signature(), )); - let blocks: Vec> = snapshots + let blocks: Vec> = snapshots .iter() .zip(chain_segment_blobs.iter()) .map(|(snapshot, blobs)| { - build_rpc_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) + build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) }) .collect::>(); // Ensure the block will be rejected if imported in a chain segment. @@ -994,11 +988,11 @@ async fn invalid_signature_deposit() { Arc::new(SignedBeaconBlock::from_block(block, signature)); update_parent_roots(&mut snapshots, &mut chain_segment_blobs); update_proposal_signatures(&mut snapshots, &harness); - let blocks: Vec> = snapshots + let blocks: Vec> = snapshots .iter() .zip(chain_segment_blobs.iter()) .map(|(snapshot, blobs)| { - build_rpc_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) + build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) }) .collect(); assert!( @@ -1641,9 +1635,9 @@ async fn add_base_block_to_altair_chain() { )); // Ensure that it would be impossible to import via `BeaconChain::process_block`. - let base_rpc_block = RpcBlock::new( + let base_range_sync_block = RangeSyncBlock::new( Arc::new(base_block.clone()), - None, + AvailableBlockData::NoData, &harness.chain.data_availability_checker, harness.spec.clone(), ) @@ -1652,8 +1646,8 @@ async fn add_base_block_to_altair_chain() { harness .chain .process_block( - base_rpc_block.block_root(), - base_rpc_block, + base_range_sync_block.block_root(), + base_range_sync_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -1672,9 +1666,9 @@ async fn add_base_block_to_altair_chain() { .chain .process_chain_segment( vec![ - RpcBlock::new( + RangeSyncBlock::new( Arc::new(base_block), - None, + AvailableBlockData::NoData, &harness.chain.data_availability_checker, harness.spec.clone() ) @@ -1792,19 +1786,13 @@ async fn add_altair_block_to_base_chain() { )); // Ensure that it would be impossible to import via `BeaconChain::process_block`. - let altair_rpc_block = RpcBlock::new( - Arc::new(altair_block.clone()), - None, - &harness.chain.data_availability_checker, - harness.spec.clone(), - ) - .unwrap(); + let altair_lookup_block = LookupBlock::new(Arc::new(altair_block.clone())); assert!(matches!( harness .chain .process_block( - altair_rpc_block.block_root(), - altair_rpc_block, + altair_lookup_block.block_root(), + altair_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -1823,9 +1811,9 @@ async fn add_altair_block_to_base_chain() { .chain .process_chain_segment( vec![ - RpcBlock::new( + RangeSyncBlock::new( Arc::new(altair_block), - None, + AvailableBlockData::NoData, &harness.chain.data_availability_checker, harness.spec.clone() ) @@ -1891,18 +1879,18 @@ async fn import_duplicate_block_unrealized_justification() { // Create two verified variants of the block, representing the same block being processed in // parallel. let notify_execution_layer = NotifyExecutionLayer::Yes; - let rpc_block = RpcBlock::new( + let range_sync_block = RangeSyncBlock::new( block.clone(), - Some(AvailableBlockData::NoData), + AvailableBlockData::NoData, &harness.chain.data_availability_checker, harness.spec.clone(), ) .unwrap(); - let verified_block1 = rpc_block + let verified_block1 = range_sync_block .clone() .into_execution_pending_block(block_root, chain, notify_execution_layer) .unwrap(); - let verified_block2 = rpc_block + let verified_block2 = range_sync_block .into_execution_pending_block(block_root, chain, notify_execution_layer) .unwrap(); @@ -1972,48 +1960,9 @@ async fn import_execution_pending_block( } } -// Test that `signature_verify_chain_segment` errors with a chain segment of mixed `FullyAvailable` -// and `BlockOnly` RpcBlocks. This situation should never happen in production. -#[tokio::test] -async fn signature_verify_mixed_rpc_block_variants() { - let (snapshots, data_sidecars) = get_chain_segment().await; - let snapshots: Vec<_> = snapshots.into_iter().take(10).collect(); - let data_sidecars: Vec<_> = data_sidecars.into_iter().take(10).collect(); - - let harness = get_harness(VALIDATOR_COUNT, NodeCustodyType::Fullnode); - - let mut chain_segment = Vec::new(); - - for (i, (snapshot, blobs)) in snapshots.iter().zip(data_sidecars.iter()).enumerate() { - let block = snapshot.beacon_block.clone(); - let block_root = snapshot.beacon_block_root; - - // Alternate between FullyAvailable and BlockOnly - let rpc_block = if i % 2 == 0 { - // FullyAvailable - with blobs/columns if needed - build_rpc_block(block, blobs, harness.chain.clone()) - } else { - // BlockOnly - no data - RpcBlock::new( - block, - None, - &harness.chain.data_availability_checker, - harness.chain.spec.clone(), - ) - .unwrap() - }; - - chain_segment.push((block_root, rpc_block)); - } - - // This should error because `signature_verify_chain_segment` expects a list - // of `RpcBlock::FullyAvailable`. - assert!(signature_verify_chain_segment(chain_segment.clone(), &harness.chain).is_err()); -} - // Test that RpcBlock::new() rejects blocks when blob count doesn't match expected. #[tokio::test] -async fn rpc_block_construction_fails_with_wrong_blob_count() { +async fn range_sync_block_construction_fails_with_wrong_blob_count() { let spec = test_spec::(); if !spec.fork_name_at_slot::(Slot::new(0)).deneb_enabled() @@ -2064,9 +2013,9 @@ async fn rpc_block_construction_fails_with_wrong_blob_count() { let block_data = AvailableBlockData::new_with_blobs(wrong_blobs); // Try to create RpcBlock with wrong blob count - let result = RpcBlock::new( + let result = RangeSyncBlock::new( Arc::new(block), - Some(block_data), + block_data, &harness.chain.data_availability_checker, harness.chain.spec.clone(), ); @@ -2086,7 +2035,7 @@ async fn rpc_block_construction_fails_with_wrong_blob_count() { // Test that RpcBlock::new() rejects blocks when custody columns are incomplete. #[tokio::test] -async fn rpc_block_rejects_missing_custody_columns() { +async fn range_sync_block_rejects_missing_custody_columns() { let spec = test_spec::(); if !spec.fork_name_at_slot::(Slot::new(0)).fulu_enabled() { @@ -2139,9 +2088,9 @@ async fn rpc_block_rejects_missing_custody_columns() { let block_data = AvailableBlockData::new_with_data_columns(incomplete_columns); // Try to create RpcBlock with incomplete custody columns - let result = RpcBlock::new( + let result = RangeSyncBlock::new( Arc::new(block), - Some(block_data), + block_data, &harness.chain.data_availability_checker, harness.chain.spec.clone(), ); @@ -2227,9 +2176,9 @@ async fn rpc_block_allows_construction_past_da_boundary() { // Try to create RpcBlock with NoData for a block past DA boundary // This should succeed since columns are not expected for blocks past DA boundary - let result = RpcBlock::new( + let result = RangeSyncBlock::new( Arc::new(block), - Some(AvailableBlockData::NoData), + AvailableBlockData::NoData, &harness.chain.data_availability_checker, harness.chain.spec.clone(), ); diff --git a/beacon_node/beacon_chain/tests/column_verification.rs b/beacon_node/beacon_chain/tests/column_verification.rs index 9941c957e2..6114bd7f45 100644 --- a/beacon_node/beacon_chain/tests/column_verification.rs +++ b/beacon_node/beacon_chain/tests/column_verification.rs @@ -7,7 +7,7 @@ use beacon_chain::test_utils::{ }; use beacon_chain::{ AvailabilityProcessingStatus, BlockError, ChainConfig, InvalidSignature, NotifyExecutionLayer, - block_verification_types::AsBlock, + block_verification_types::{AsBlock, LookupBlock}, }; use bls::{Keypair, Signature}; use logging::create_test_tracing_subscriber; @@ -80,16 +80,13 @@ async fn rpc_columns_with_invalid_header_signature() { // Process the block without blobs so that it doesn't become available. harness.advance_slot(); - let rpc_block = harness - .build_rpc_block_from_blobs(signed_block.clone(), None, false) - .unwrap(); let availability = harness .chain .process_block( block_root, - rpc_block, + LookupBlock::new(signed_block.clone()), NotifyExecutionLayer::Yes, - BlockImportSource::RangeSync, + BlockImportSource::Lookup, || Ok(()), ) .await @@ -169,16 +166,13 @@ async fn verify_header_signature_fork_block_bug() { // The block will be accepted but won't become the head because it's not fully available. // This keeps the head at the pre-fork state (Electra). harness.advance_slot(); - let rpc_block = harness - .build_rpc_block_from_blobs(signed_block.clone(), None, false) - .expect("Should build RPC block"); let availability = harness .chain .process_block( block_root, - rpc_block, + LookupBlock::new(signed_block.clone()), NotifyExecutionLayer::Yes, - BlockImportSource::RangeSync, + BlockImportSource::Lookup, || Ok(()), ) .await diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index bcc50990ec..3ed8f59838 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1,7 +1,7 @@ #![cfg(not(debug_assertions))] #![allow(clippy::result_large_err)] -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::{ BeaconChainError, BlockError, ChainConfig, ExecutionPayloadError, INVALID_JUSTIFIED_PAYLOAD_SHUTDOWN_REASON, NotifyExecutionLayer, StateSkipConfig, @@ -686,19 +686,13 @@ async fn invalidates_all_descendants() { assert_eq!(fork_parent_state.slot(), fork_parent_slot); let ((fork_block, _), _fork_post_state) = rig.harness.make_block(fork_parent_state, fork_slot).await; - let fork_rpc_block = RpcBlock::new( - fork_block.clone(), - None, - &rig.harness.chain.data_availability_checker, - rig.harness.chain.spec.clone(), - ) - .unwrap(); + let fork_lookup_block = LookupBlock::new(fork_block.clone()); let fork_block_root = rig .harness .chain .process_block( - fork_rpc_block.block_root(), - fork_rpc_block, + fork_lookup_block.block_root(), + fork_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -796,19 +790,13 @@ async fn switches_heads() { let ((fork_block, _), _fork_post_state) = rig.harness.make_block(fork_parent_state, fork_slot).await; let fork_parent_root = fork_block.parent_root(); - let fork_rpc_block = RpcBlock::new( - fork_block.clone(), - None, - &rig.harness.chain.data_availability_checker, - rig.harness.chain.spec.clone(), - ) - .unwrap(); + let fork_lookup_block = LookupBlock::new(fork_block.clone()); let fork_block_root = rig .harness .chain .process_block( - fork_rpc_block.block_root(), - fork_rpc_block, + fork_lookup_block.block_root(), + fork_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -1086,15 +1074,9 @@ async fn invalid_parent() { )); // Ensure the block built atop an invalid payload is invalid for import. - let rpc_block = RpcBlock::new( - block.clone(), - None, - &rig.harness.chain.data_availability_checker, - rig.harness.chain.spec.clone(), - ) - .unwrap(); + let lookup_block = LookupBlock::new(block.clone()); assert!(matches!( - rig.harness.chain.process_block(rpc_block.block_root(), rpc_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, + rig.harness.chain.process_block(lookup_block.block_root(), lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), ).await, Err(BlockError::ParentExecutionPayloadInvalid { parent_root: invalid_root }) @@ -1348,18 +1330,12 @@ async fn recover_from_invalid_head_by_importing_blocks() { } = InvalidHeadSetup::new().await; // Import the fork block, it should become the head. - let fork_rpc_block = RpcBlock::new( - fork_block.clone(), - None, - &rig.harness.chain.data_availability_checker, - rig.harness.chain.spec.clone(), - ) - .unwrap(); + let fork_lookup_block = LookupBlock::new(fork_block.clone()); rig.harness .chain .process_block( - fork_rpc_block.block_root(), - fork_rpc_block, + fork_lookup_block.block_root(), + fork_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index a70ad89ca9..89c28cca37 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -2,7 +2,7 @@ #![allow(clippy::result_large_err)] use beacon_chain::attestation_verification::Error as AttnError; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::builder::BeaconChainBuilder; use beacon_chain::custody_context::CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS; use beacon_chain::data_availability_checker::AvailableBlock; @@ -3144,7 +3144,10 @@ async fn weak_subjectivity_sync_test( beacon_chain .process_block( full_block_root, - harness.build_rpc_block_from_store_blobs(Some(block_root), Arc::new(full_block)), + harness.build_range_sync_block_from_store_blobs( + Some(block_root), + Arc::new(full_block), + ), NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -3214,20 +3217,16 @@ async fn weak_subjectivity_sync_test( .expect("should get block") .expect("should get block"); - let rpc_block = - harness.build_rpc_block_from_store_blobs(Some(block_root), Arc::new(full_block)); + let range_sync_block = harness + .build_range_sync_block_from_store_blobs(Some(block_root), Arc::new(full_block)); - match rpc_block { - RpcBlock::FullyAvailable(available_block) => { - harness - .chain - .data_availability_checker - .verify_kzg_for_available_block(&available_block) - .expect("should verify kzg"); - available_blocks.push(available_block); - } - RpcBlock::BlockOnly { .. } => panic!("Should be an available block"), - } + let fully_available_block = range_sync_block.into_available_block(); + harness + .chain + .data_availability_checker + .verify_kzg_for_available_block(&fully_available_block) + .expect("should verify kzg"); + available_blocks.push(fully_available_block); } // Corrupt the signature on the 1st block to ensure that the backfill processor is checking @@ -3798,19 +3797,13 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { assert_eq!(split.block_root, valid_fork_block.parent_root()); assert_ne!(split.state_root, unadvanced_split_state_root); - let invalid_fork_rpc_block = RpcBlock::new( - invalid_fork_block.clone(), - None, - &harness.chain.data_availability_checker, - harness.spec.clone(), - ) - .unwrap(); + let invalid_fork_lookup_block = LookupBlock::new(invalid_fork_block.clone()); // Applying the invalid block should fail. let err = harness .chain .process_block( - invalid_fork_rpc_block.block_root(), - invalid_fork_rpc_block, + invalid_fork_lookup_block.block_root(), + invalid_fork_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), @@ -3820,18 +3813,12 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { assert!(matches!(err, BlockError::WouldRevertFinalizedSlot { .. })); // Applying the valid block should succeed, but it should not become head. - let valid_fork_rpc_block = RpcBlock::new( - valid_fork_block.clone(), - None, - &harness.chain.data_availability_checker, - harness.spec.clone(), - ) - .unwrap(); + let valid_fork_lookup_block = LookupBlock::new(valid_fork_block.clone()); harness .chain .process_block( - valid_fork_rpc_block.block_root(), - valid_fork_rpc_block, + valid_fork_lookup_block.block_root(), + valid_fork_lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::Lookup, || Ok(()), diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index bbf92a4dda..43dfbeb836 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -2,7 +2,7 @@ use crate::metrics; use std::future::Future; use beacon_chain::blob_verification::{GossipBlobError, GossipVerifiedBlob}; -use beacon_chain::block_verification_types::{AsBlock, RpcBlock}; +use beacon_chain::block_verification_types::{AsBlock, LookupBlock}; use beacon_chain::data_column_verification::GossipVerifiedDataColumn; use beacon_chain::validator_monitor::{get_block_delay_ms, timestamp_now}; use beacon_chain::{ @@ -311,19 +311,11 @@ pub async fn publish_block>( slot = %block.slot(), "Block previously seen" ); - let Ok(rpc_block) = RpcBlock::new( - block.clone(), - None, - &chain.data_availability_checker, - chain.spec.clone(), - ) else { - return Err(warp_utils::reject::custom_bad_request( - "Unable to construct rpc block".to_string(), - )); - }; + // try to reprocess as a lookup (single) block and let sync take care of missing components + let lookup_block = LookupBlock::new(block.clone()); let import_result = Box::pin(chain.process_block( block_root, - rpc_block, + lookup_block, NotifyExecutionLayer::Yes, BlockImportSource::HttpApi, publish_fn, diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index 357d6c08fd..e40eacce08 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -1,7 +1,8 @@ use crate::sync::manager::BlockProcessType; use crate::{service::NetworkMessage, sync::manager::SyncMessage}; use beacon_chain::blob_verification::{GossipBlobError, observe_gossip_blob}; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use beacon_chain::data_column_verification::{GossipDataColumnError, observe_gossip_data_column}; use beacon_chain::fetch_blobs::{ EngineGetBlobsOutput, FetchEngineBlobError, fetch_and_process_engine_blobs, @@ -517,14 +518,14 @@ impl NetworkBeaconProcessor { /// Create a new `Work` event for some block, where the result from computation (if any) is /// sent to the other side of `result_tx`. - pub fn send_rpc_beacon_block( + pub fn send_lookup_beacon_block( self: &Arc, block_root: Hash256, - block: RpcBlock, + block: LookupBlock, seen_timestamp: Duration, process_type: BlockProcessType, ) -> Result<(), Error> { - let process_fn = self.clone().generate_rpc_beacon_block_process_fn( + let process_fn = self.clone().generate_lookup_beacon_block_process_fn( block_root, block, seen_timestamp, @@ -610,7 +611,7 @@ impl NetworkBeaconProcessor { pub fn send_chain_segment( self: &Arc, process_id: ChainSegmentProcessId, - blocks: Vec>, + blocks: Vec>, ) -> Result<(), Error> { debug!(blocks = blocks.len(), id = ?process_id, "Batch sending for process"); let processor = self.clone(); 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 629a42c688..f7fbce8e56 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -6,7 +6,8 @@ use crate::sync::{ ChainId, manager::{BlockProcessType, SyncMessage}, }; -use beacon_chain::block_verification_types::{AsBlock, RpcBlock}; +use beacon_chain::block_verification_types::LookupBlock; +use beacon_chain::block_verification_types::{AsBlock, RangeSyncBlock}; use beacon_chain::data_availability_checker::AvailabilityCheckError; use beacon_chain::historical_data_columns::HistoricalDataColumnError; use beacon_chain::{ @@ -51,16 +52,16 @@ impl NetworkBeaconProcessor { /// /// This separate function was required to prevent a cycle during compiler /// type checking. - pub fn generate_rpc_beacon_block_process_fn( + pub fn generate_lookup_beacon_block_process_fn( self: Arc, block_root: Hash256, - block: RpcBlock, + block: LookupBlock, seen_timestamp: Duration, process_type: BlockProcessType, ) -> AsyncFn { let process_fn = async move { let duplicate_cache = self.duplicate_cache.clone(); - self.process_rpc_block( + self.process_lookup_block( block_root, block, seen_timestamp, @@ -73,15 +74,15 @@ impl NetworkBeaconProcessor { } /// Returns the `process_fn` and `ignore_fn` required when requeuing an RPC block. - pub fn generate_rpc_beacon_block_fns( + pub fn generate_lookup_beacon_block_fns( self: Arc, block_root: Hash256, - block: RpcBlock, + block: LookupBlock, seen_timestamp: Duration, process_type: BlockProcessType, ) -> (AsyncFn, BlockingFn) { // An async closure which will import the block. - let process_fn = self.clone().generate_rpc_beacon_block_process_fn( + let process_fn = self.clone().generate_lookup_beacon_block_process_fn( block_root, block, seen_timestamp, @@ -107,10 +108,10 @@ impl NetworkBeaconProcessor { skip_all, fields(?block_root), )] - pub async fn process_rpc_block( + pub async fn process_lookup_block( self: Arc>, block_root: Hash256, - block: RpcBlock, + block: LookupBlock, seen_timestamp: Duration, process_type: BlockProcessType, duplicate_cache: DuplicateCache, @@ -118,14 +119,14 @@ impl NetworkBeaconProcessor { // Check if the block is already being imported through another source let Some(handle) = duplicate_cache.check_and_insert(block_root) else { debug!( - action = "sending rpc block to reprocessing queue", + action = "sending lookup block to reprocessing queue", %block_root, ?process_type, "Gossip block is being processed" ); // Send message to work reprocess queue to retry the block - let (process_fn, ignore_fn) = self.clone().generate_rpc_beacon_block_fns( + let (process_fn, ignore_fn) = self.clone().generate_lookup_beacon_block_fns( block_root, block, seen_timestamp, @@ -160,7 +161,7 @@ impl NetworkBeaconProcessor { slot = %block.slot(), commitments_formatted, ?process_type, - "Processing RPC block" + "Processing Lookup block" ); let signed_beacon_block = block.block_cloned(); @@ -530,7 +531,7 @@ impl NetworkBeaconProcessor { pub async fn process_chain_segment( &self, process_id: ChainSegmentProcessId, - downloaded_blocks: Vec>, + downloaded_blocks: Vec>, ) { let ChainSegmentProcessId::RangeBatchId(chain_id, epoch) = process_id else { // This is a request from range sync, this should _never_ happen @@ -611,7 +612,7 @@ impl NetworkBeaconProcessor { pub fn process_chain_segment_backfill( &self, process_id: ChainSegmentProcessId, - downloaded_blocks: Vec>, + downloaded_blocks: Vec>, ) { let ChainSegmentProcessId::BackSyncBatchId(epoch) = process_id else { // this a request from RangeSync, this should _never_ happen @@ -682,7 +683,7 @@ impl NetworkBeaconProcessor { #[instrument(skip_all)] async fn process_blocks<'a>( &self, - downloaded_blocks: impl Iterator>, + downloaded_blocks: impl Iterator>, notify_execution_layer: NotifyExecutionLayer, ) -> (usize, Result<(), ChainSegmentFailed>) { let blocks: Vec<_> = downloaded_blocks.cloned().collect(); @@ -716,23 +717,13 @@ impl NetworkBeaconProcessor { #[instrument(skip_all)] fn process_backfill_blocks( &self, - downloaded_blocks: Vec>, + downloaded_blocks: Vec>, ) -> (usize, Result<(), ChainSegmentFailed>) { let total_blocks = downloaded_blocks.len(); - let mut available_blocks = vec![]; - - for downloaded_block in downloaded_blocks { - match downloaded_block { - RpcBlock::FullyAvailable(available_block) => available_blocks.push(available_block), - RpcBlock::BlockOnly { .. } => return ( - 0, - Err(ChainSegmentFailed { - peer_action: None, - message: "Invalid downloaded_blocks segment. All downloaded blocks must be fully available".to_string() - }) - ), - } - } + let available_blocks = downloaded_blocks + .into_iter() + .map(|block| block.into_available_block()) + .collect::>(); match self .chain diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index 4b0ca0d46c..5fa8c729cb 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -8,7 +8,7 @@ use crate::{ service::NetworkMessage, sync::{SyncMessage, manager::BlockProcessType}, }; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::data_column_verification::validate_data_column_sidecar_for_gossip_fulu; use beacon_chain::kzg_utils::blobs_to_data_column_sidecars; @@ -437,36 +437,24 @@ impl TestRig { } } - pub fn enqueue_rpc_block(&self) { + pub fn enqueue_lookup_block(&self) { let block_root = self.next_block.canonical_root(); self.network_beacon_processor - .send_rpc_beacon_block( + .send_lookup_beacon_block( block_root, - RpcBlock::new( - self.next_block.clone(), - None, - &self._harness.chain.data_availability_checker, - self._harness.spec.clone(), - ) - .unwrap(), + LookupBlock::new(self.next_block.clone()), std::time::Duration::default(), BlockProcessType::SingleBlock { id: 0 }, ) .unwrap(); } - pub fn enqueue_single_lookup_rpc_block(&self) { + pub fn enqueue_single_lookup_block(&self) { let block_root = self.next_block.canonical_root(); self.network_beacon_processor - .send_rpc_beacon_block( + .send_lookup_beacon_block( block_root, - RpcBlock::new( - self.next_block.clone(), - None, - &self._harness.chain.data_availability_checker, - self._harness.spec.clone(), - ) - .unwrap(), + LookupBlock::new(self.next_block.clone()), std::time::Duration::default(), BlockProcessType::SingleBlock { id: 1 }, ) @@ -1305,7 +1293,7 @@ async fn attestation_to_unknown_block_processed(import_method: BlockImportMethod } } BlockImportMethod::Rpc => { - rig.enqueue_rpc_block(); + rig.enqueue_lookup_block(); events.push(WorkType::RpcBlock); if num_blobs > 0 { rig.enqueue_single_lookup_rpc_blobs(); @@ -1391,7 +1379,7 @@ async fn aggregate_attestation_to_unknown_block(import_method: BlockImportMethod } } BlockImportMethod::Rpc => { - rig.enqueue_rpc_block(); + rig.enqueue_lookup_block(); events.push(WorkType::RpcBlock); if num_blobs > 0 { rig.enqueue_single_lookup_rpc_blobs(); @@ -1585,7 +1573,7 @@ async fn test_rpc_block_reprocessing() { let next_block_root = rig.next_block.canonical_root(); // Insert the next block into the duplicate cache manually let handle = rig.duplicate_cache.check_and_insert(next_block_root); - rig.enqueue_single_lookup_rpc_block(); + rig.enqueue_single_lookup_block(); rig.assert_event_journal_completes(&[WorkType::RpcBlock]) .await; diff --git a/beacon_node/network/src/sync/backfill_sync/mod.rs b/beacon_node/network/src/sync/backfill_sync/mod.rs index 801c9eca4d..0f80138d24 100644 --- a/beacon_node/network/src/sync/backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/backfill_sync/mod.rs @@ -19,7 +19,7 @@ use crate::sync::manager::BatchProcessResult; use crate::sync::network_context::{ RangeRequestId, RpcRequestSendError, RpcResponseError, SyncNetworkContext, }; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use beacon_chain::{BeaconChain, BeaconChainTypes}; use lighthouse_network::service::api_types::Id; use lighthouse_network::types::{BackFillState, NetworkGlobals}; @@ -55,7 +55,7 @@ const MAX_BATCH_DOWNLOAD_ATTEMPTS: u8 = 10; /// after `MAX_BATCH_PROCESSING_ATTEMPTS` times, it is considered faulty. const MAX_BATCH_PROCESSING_ATTEMPTS: u8 = 10; -type RpcBlocks = Vec>; +type RpcBlocks = Vec>; type BackFillBatchInfo = BatchInfo, RpcBlocks>; @@ -390,7 +390,7 @@ impl BackFillSync { batch_id: BatchId, peer_id: &PeerId, request_id: Id, - blocks: Vec>, + blocks: Vec>, ) -> Result { // check if we have this batch let Some(batch) = self.batches.get_mut(&batch_id) else { diff --git a/beacon_node/network/src/sync/batch.rs b/beacon_node/network/src/sync/batch.rs index e87ffd119e..10af1bf503 100644 --- a/beacon_node/network/src/sync/batch.rs +++ b/beacon_node/network/src/sync/batch.rs @@ -1,4 +1,4 @@ -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use educe::Educe; use lighthouse_network::PeerId; use lighthouse_network::rpc::methods::BlocksByRangeRequest; @@ -449,7 +449,7 @@ impl BatchInfo { } // BatchInfo implementations for RangeSync -impl BatchInfo>> { +impl BatchInfo>> { /// Returns a BlocksByRange request associated with the batch. pub fn to_blocks_by_range_request(&self) -> (BlocksByRangeRequest, ByRangeRequestType) { ( diff --git a/beacon_node/network/src/sync/block_sidecar_coupling.rs b/beacon_node/network/src/sync/block_sidecar_coupling.rs index a287771854..98cf3e0a1f 100644 --- a/beacon_node/network/src/sync/block_sidecar_coupling.rs +++ b/beacon_node/network/src/sync/block_sidecar_coupling.rs @@ -1,6 +1,6 @@ use beacon_chain::{ BeaconChainTypes, - block_verification_types::{AvailableBlockData, RpcBlock}, + block_verification_types::{AvailableBlockData, RangeSyncBlock}, data_availability_checker::DataAvailabilityChecker, data_column_verification::CustodyDataColumn, get_block_root, @@ -200,7 +200,7 @@ impl RangeBlockComponentsRequest { &mut self, da_checker: Arc>, spec: Arc, - ) -> Option>, CouplingError>> + ) -> Option>, CouplingError>> where T: BeaconChainTypes, { @@ -288,7 +288,7 @@ impl RangeBlockComponentsRequest { blobs: Vec>>, da_checker: Arc>, spec: Arc, - ) -> Result>, CouplingError> + ) -> Result>, CouplingError> where T: BeaconChainTypes, { @@ -335,7 +335,7 @@ impl RangeBlockComponentsRequest { })?; let block_data = AvailableBlockData::new_with_blobs(blobs); responses.push( - RpcBlock::new(block, Some(block_data), &da_checker, spec.clone()) + RangeSyncBlock::new(block, block_data, &da_checker, spec.clone()) .map_err(|e| CouplingError::BlobPeerFailure(format!("{e:?}")))?, ) } @@ -360,7 +360,7 @@ impl RangeBlockComponentsRequest { attempt: usize, da_checker: Arc>, spec: Arc, - ) -> Result>, CouplingError> + ) -> Result>, CouplingError> where T: BeaconChainTypes, { @@ -388,12 +388,12 @@ impl RangeBlockComponentsRequest { // Now iterate all blocks ensuring that the block roots of each block and data column match, // plus we have columns for our custody requirements - let mut rpc_blocks = Vec::with_capacity(blocks.len()); + let mut range_sync_blocks = Vec::with_capacity(blocks.len()); let exceeded_retries = attempt >= MAX_COLUMN_RETRIES; for block in blocks { let block_root = get_block_root(&block); - rpc_blocks.push(if block.num_expected_blobs() > 0 { + range_sync_blocks.push(if block.num_expected_blobs() > 0 { let Some(mut data_columns_by_index) = data_columns_by_block.remove(&block_root) else { let responsible_peers = column_to_peer.iter().map(|c| (*c.0, *c.1)).collect(); @@ -441,11 +441,11 @@ impl RangeBlockComponentsRequest { let block_data = AvailableBlockData::new_with_data_columns(custody_columns.iter().map(|c| c.as_data_column().clone()).collect::>()); - RpcBlock::new(block, Some(block_data), &da_checker, spec.clone()) + RangeSyncBlock::new(block, block_data, &da_checker, spec.clone()) .map_err(|e| CouplingError::InternalError(format!("{:?}", e)))? } else { // Block has no data, expects zero columns - RpcBlock::new(block, Some(AvailableBlockData::NoData), &da_checker, spec.clone()) + RangeSyncBlock::new(block, AvailableBlockData::NoData, &da_checker, spec.clone()) .map_err(|e| CouplingError::InternalError(format!("{:?}", e)))? }); } @@ -458,7 +458,7 @@ impl RangeBlockComponentsRequest { debug!(?remaining_roots, "Not all columns consumed for block"); } - Ok(rpc_blocks) + Ok(range_sync_blocks) } } @@ -947,7 +947,7 @@ mod tests { } let result: Result< - Vec>, + Vec>, crate::sync::block_sidecar_coupling::CouplingError, > = info.responses(da_checker.clone(), spec.clone()).unwrap(); assert!(result.is_err()); @@ -981,10 +981,10 @@ mod tests { // WHEN: Attempting to get responses again let result = info.responses(da_checker, spec).unwrap(); - // THEN: Should succeed with complete RPC blocks + // THEN: Should succeed with complete RangeSync blocks assert!(result.is_ok()); - let rpc_blocks = result.unwrap(); - assert_eq!(rpc_blocks.len(), 2); + let range_sync_blocks = result.unwrap(); + assert_eq!(range_sync_blocks.len(), 2); } #[test] diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index 7e2c0d9a94..ff630bb470 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -17,7 +17,8 @@ use crate::sync::block_lookups::SingleLookupId; use crate::sync::block_sidecar_coupling::CouplingError; use crate::sync::network_context::requests::BlobsByRootSingleBlockRequest; use crate::sync::range_data_column_batch_request::RangeDataColumnBatchRequest; -use beacon_chain::block_verification_types::{AsBlock, RpcBlock}; +use beacon_chain::block_verification_types::LookupBlock; +use beacon_chain::block_verification_types::{AsBlock, RangeSyncBlock}; use beacon_chain::{BeaconChain, BeaconChainTypes, BlockProcessStatus, EngineState}; use custody::CustodyRequestResult; use fnv::FnvHashMap; @@ -735,7 +736,7 @@ impl SyncNetworkContext { &mut self, id: ComponentsByRangeRequestId, range_block_component: RangeBlockComponent, - ) -> Option>, RpcResponseError>> { + ) -> Option>, RpcResponseError>> { let Entry::Occupied(mut entry) = self.components_by_range_requests.entry(id) else { metrics::inc_counter_vec(&metrics::SYNC_UNKNOWN_NETWORK_REQUESTS, &["range_blocks"]); return None; @@ -1588,21 +1589,15 @@ impl SyncNetworkContext { .beacon_processor_if_enabled() .ok_or(SendErrorProcessor::ProcessorNotAvailable)?; - let block = RpcBlock::new( - block, - None, - &self.chain.data_availability_checker, - self.chain.spec.clone(), - ) - .map_err(|_| SendErrorProcessor::SendError)?; + let lookup_block = LookupBlock::new(block); - debug!(block = ?block_root, block_slot = %block.slot(), id, "Sending block for processing"); + debug!(block = ?block_root, block_slot = %lookup_block.slot(), id, "Sending block for processing"); // Lookup sync event safety: If `beacon_processor.send_rpc_beacon_block` returns Ok() sync // must receive a single `SyncMessage::BlockComponentProcessed` with this process type beacon_processor - .send_rpc_beacon_block( + .send_lookup_beacon_block( block_root, - block, + lookup_block, seen_timestamp, BlockProcessType::SingleBlock { id }, ) diff --git a/beacon_node/network/src/sync/range_sync/chain.rs b/beacon_node/network/src/sync/range_sync/chain.rs index e3ff638121..d533d8ed0d 100644 --- a/beacon_node/network/src/sync/range_sync/chain.rs +++ b/beacon_node/network/src/sync/range_sync/chain.rs @@ -10,7 +10,7 @@ use crate::sync::block_sidecar_coupling::CouplingError; use crate::sync::network_context::{RangeRequestId, RpcRequestSendError, RpcResponseError}; use crate::sync::{BatchProcessResult, network_context::SyncNetworkContext}; use beacon_chain::BeaconChainTypes; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use lighthouse_network::service::api_types::Id; use lighthouse_network::{PeerAction, PeerId}; use logging::crit; @@ -40,7 +40,7 @@ const BATCH_BUFFER_SIZE: u8 = 5; /// and continued is now in an inconsistent state. pub type ProcessingResult = Result; -type RpcBlocks = Vec>; +type RpcBlocks = Vec>; type RangeSyncBatchInfo = BatchInfo, RpcBlocks>; type RangeSyncBatches = BTreeMap>; @@ -273,7 +273,7 @@ impl SyncingChain { batch_id: BatchId, peer_id: &PeerId, request_id: Id, - blocks: Vec>, + blocks: Vec>, ) -> ProcessingResult { let _guard = self.span.clone().entered(); // check if we have this batch diff --git a/beacon_node/network/src/sync/range_sync/range.rs b/beacon_node/network/src/sync/range_sync/range.rs index 9fd72ac98a..6509ac3cb3 100644 --- a/beacon_node/network/src/sync/range_sync/range.rs +++ b/beacon_node/network/src/sync/range_sync/range.rs @@ -47,7 +47,7 @@ use crate::status::ToStatusMessage; use crate::sync::BatchProcessResult; use crate::sync::batch::BatchId; use crate::sync::network_context::{RpcResponseError, SyncNetworkContext}; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use beacon_chain::{BeaconChain, BeaconChainTypes}; use lighthouse_network::rpc::GoodbyeReason; use lighthouse_network::service::api_types::Id; @@ -213,7 +213,7 @@ where chain_id: ChainId, batch_id: BatchId, request_id: Id, - blocks: Vec>, + blocks: Vec>, ) { // check if this chunk removes the chain match self.chains.call_by_id(chain_id, |chain| { diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index 769a11d976..cd872df887 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -7,6 +7,7 @@ use crate::sync::{ manager::{BlockProcessType, BlockProcessingResult, SyncManager}, }; use beacon_chain::blob_verification::KzgVerifiedBlob; +use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::{ AvailabilityProcessingStatus, BlockError, NotifyExecutionLayer, @@ -464,7 +465,7 @@ impl TestRig { panic!("Test consumer requested unknown block: {id:?}") }) .block_data() - .and_then(|d| d.blobs()) + .blobs() .unwrap_or_else(|| panic!("Block {id:?} has no blobs")) .iter() .find(|blob| blob.index == id.index) @@ -528,7 +529,7 @@ impl TestRig { panic!("Test consumer requested unknown block: {id:?}") }) .block_data() - .and_then(|d| d.data_columns()) + .data_columns() .unwrap_or_else(|| panic!("Block id {id:?} has no columns")); id.columns .iter() @@ -594,7 +595,7 @@ impl TestRig { // - Some blocks may not have blobs as the blob count is random let blobs = (req.start_slot..req.start_slot + req.count) .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) - .filter_map(|block| block.block_data().and_then(|d| d.blobs())) + .filter_map(|block| block.block_data().blobs()) .flat_map(|blobs| blobs.into_iter()) .collect::>(); self.send_rpc_blobs_response(req_id, peer_id, &blobs); @@ -610,7 +611,7 @@ impl TestRig { // - Some blocks may not have columns as the blob count is random let columns = (req.start_slot..req.start_slot + req.count) .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) - .filter_map(|block| block.block_data().and_then(|d| d.data_columns())) + .filter_map(|block| block.block_data().data_columns()) .flat_map(|columns| { columns .into_iter() @@ -786,10 +787,10 @@ impl TestRig { } fn corrupt_last_block_signature(&mut self) { - let rpc_block = self.get_last_block().clone(); - let mut block = (*rpc_block.block_cloned()).clone(); - let blobs = rpc_block.block_data().and_then(|d| d.blobs()); - let columns = rpc_block.block_data().and_then(|d| d.data_columns()); + let range_sync_block = self.get_last_block().clone(); + let mut block = (*range_sync_block.block_cloned()).clone(); + let blobs = range_sync_block.block_data().blobs(); + let columns = range_sync_block.block_data().data_columns(); *block.signature_mut() = self.valid_signature(); self.re_insert_block(Arc::new(block), blobs, columns); } @@ -801,15 +802,15 @@ impl TestRig { } fn corrupt_last_blob_proposer_signature(&mut self) { - let rpc_block = self.get_last_block().clone(); - let block = rpc_block.block_cloned(); - let mut blobs = rpc_block + let range_sync_block = self.get_last_block().clone(); + let block = range_sync_block.block_cloned(); + let mut blobs = range_sync_block .block_data() - .and_then(|d| d.blobs()) + .blobs() .expect("no blobs") .into_iter() .collect::>(); - let columns = rpc_block.block_data().and_then(|d| d.data_columns()); + let columns = range_sync_block.block_data().data_columns(); let first = blobs.first_mut().expect("empty blobs"); Arc::make_mut(first).signed_block_header.signature = self.valid_signature(); let max_blobs = @@ -822,15 +823,15 @@ impl TestRig { } fn corrupt_last_blob_kzg_proof(&mut self) { - let rpc_block = self.get_last_block().clone(); - let block = rpc_block.block_cloned(); - let mut blobs = rpc_block + let range_sync_block = self.get_last_block().clone(); + let block = range_sync_block.block_cloned(); + let mut blobs = range_sync_block .block_data() - .and_then(|d| d.blobs()) + .blobs() .expect("no blobs") .into_iter() .collect::>(); - let columns = rpc_block.block_data().and_then(|d| d.data_columns()); + let columns = range_sync_block.block_data().data_columns(); let first = blobs.first_mut().expect("empty blobs"); Arc::make_mut(first).kzg_proof = kzg::KzgProof::empty(); let max_blobs = @@ -843,12 +844,12 @@ impl TestRig { } fn corrupt_last_column_proposer_signature(&mut self) { - let rpc_block = self.get_last_block().clone(); - let block = rpc_block.block_cloned(); - let blobs = rpc_block.block_data().and_then(|d| d.blobs()); - let mut columns = rpc_block + let range_sync_block = self.get_last_block().clone(); + let block = range_sync_block.block_cloned(); + let blobs = range_sync_block.block_data().blobs(); + let mut columns = range_sync_block .block_data() - .and_then(|d| d.data_columns()) + .data_columns() .expect("no columns"); let first = columns.first_mut().expect("empty columns"); Arc::make_mut(first) @@ -859,12 +860,12 @@ impl TestRig { } fn corrupt_last_column_kzg_proof(&mut self) { - let rpc_block = self.get_last_block().clone(); - let block = rpc_block.block_cloned(); - let blobs = rpc_block.block_data().and_then(|d| d.blobs()); - let mut columns = rpc_block + let range_sync_block = self.get_last_block().clone(); + let block = range_sync_block.block_cloned(); + let blobs = range_sync_block.block_data().blobs(); + let mut columns = range_sync_block .block_data() - .and_then(|d| d.data_columns()) + .data_columns() .expect("no columns"); let first = columns.first_mut().expect("empty columns"); let column = Arc::make_mut(first); @@ -873,7 +874,7 @@ impl TestRig { self.re_insert_block(block, blobs, Some(columns)); } - fn get_last_block(&self) -> &RpcBlock { + fn get_last_block(&self) -> &RangeSyncBlock { let (_, last_block) = self .network_blocks_by_root .iter() @@ -893,13 +894,13 @@ impl TestRig { let block_root = block.canonical_root(); let block_slot = block.slot(); let block_data = if let Some(columns) = columns { - Some(AvailableBlockData::new_with_data_columns(columns)) + AvailableBlockData::new_with_data_columns(columns) } else if let Some(blobs) = blobs { - Some(AvailableBlockData::new_with_blobs(blobs)) + AvailableBlockData::new_with_blobs(blobs) } else { - Some(AvailableBlockData::NoData) + AvailableBlockData::NoData }; - let rpc_block = RpcBlock::new( + let range_sync_block = RangeSyncBlock::new( block, block_data, &self.harness.chain.data_availability_checker, @@ -907,8 +908,9 @@ impl TestRig { ) .unwrap(); self.network_blocks_by_slot - .insert(block_slot, rpc_block.clone()); - self.network_blocks_by_root.insert(block_root, rpc_block); + .insert(block_slot, range_sync_block.clone()); + self.network_blocks_by_root + .insert(block_root, range_sync_block); } /// Trigger a lookup with the last created block @@ -947,7 +949,7 @@ impl TestRig { /// Import a block directly into the chain without going through lookup sync async fn import_block_by_root(&mut self, block_root: Hash256) { - let rpc_block = self + let range_sync_block = self .network_blocks_by_root .get(&block_root) .unwrap_or_else(|| panic!("No block for root {block_root}")) @@ -957,9 +959,9 @@ impl TestRig { .chain .process_block( block_root, - rpc_block, + range_sync_block, NotifyExecutionLayer::Yes, - BlockImportSource::Gossip, + BlockImportSource::RangeSync, || Ok(()), ) .await @@ -979,7 +981,7 @@ impl TestRig { let blobs = self .get_last_block() .block_data() - .and_then(|d| d.blobs()) + .blobs() .expect("no blobs"); let blob = blobs.first().expect("empty blobs"); self.trigger_unknown_parent_blob(peer_id, blob.clone()); @@ -990,7 +992,7 @@ impl TestRig { let columns = self .get_last_block() .block_data() - .and_then(|d| d.data_columns()) + .data_columns() .expect("No data columns"); let column = columns.first().expect("empty columns"); self.trigger_unknown_parent_column(peer_id, column.clone()); @@ -1475,15 +1477,14 @@ impl TestRig { ) -> AvailabilityProcessingStatus { // Simulate importing block from another source. Don't use GossipVerified as it checks with // the clock, which does not match the timestamp in the payload. - let block_root = block.canonical_root(); - let rpc_block = RpcBlock::BlockOnly { block_root, block }; + let lookup_block = LookupBlock::new(block); self.harness .chain .process_block( - block_root, - rpc_block, + lookup_block.block_root(), + lookup_block, NotifyExecutionLayer::Yes, - BlockImportSource::Gossip, + BlockImportSource::Lookup, || Ok(()), ) .await @@ -2196,10 +2197,7 @@ async fn blobs_in_da_checker_skip_download() { }; r.build_chain(1).await; let block = r.get_last_block().clone(); - let blobs = block - .block_data() - .and_then(|d| d.blobs()) - .expect("block with no blobs"); + let blobs = block.block_data().blobs().expect("block with no blobs"); for blob in &blobs { r.insert_blob_to_da_checker(blob.clone()); } diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index f00cf5841d..6e948e4726 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -3,7 +3,7 @@ use crate::sync::SyncMessage; use crate::sync::block_lookups::BlockLookupsMetrics; use crate::sync::manager::SyncManager; use crate::sync::tests::lookups::SimulateConfig; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::RangeSyncBlock; use beacon_chain::builder::Witness; use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::test_utils::{BeaconChainHarness, EphemeralHarnessType}; @@ -77,8 +77,8 @@ struct TestRig { rng: ChaCha20Rng, fork_name: ForkName, /// 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>, + network_blocks_by_root: HashMap>, + network_blocks_by_slot: 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 67395ccd25..c19ee8eb6d 100644 --- a/beacon_node/network/src/sync/tests/range.rs +++ b/beacon_node/network/src/sync/tests/range.rs @@ -10,7 +10,7 @@ use beacon_chain::block_verification_types::AvailableBlockData; use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::data_column_verification::CustodyDataColumn; use beacon_chain::test_utils::{AttestationStrategy, BlockStrategy}; -use beacon_chain::{EngineState, NotifyExecutionLayer, block_verification_types::RpcBlock}; +use beacon_chain::{EngineState, NotifyExecutionLayer, block_verification_types::RangeSyncBlock}; use beacon_processor::WorkType; use lighthouse_network::rpc::RequestType; use lighthouse_network::rpc::methods::{ @@ -430,7 +430,7 @@ impl TestRig { .chain .process_block( block_root, - build_rpc_block(block.into(), &data_sidecars, self.harness.chain.clone()), + build_range_sync_block(block.into(), &data_sidecars, self.harness.chain.clone()), NotifyExecutionLayer::Yes, BlockImportSource::RangeSync, || Ok(()), @@ -443,17 +443,17 @@ impl TestRig { } } -fn build_rpc_block( +fn build_range_sync_block( block: Arc>, data_sidecars: &Option>, chain: Arc>, -) -> RpcBlock { +) -> RangeSyncBlock { match data_sidecars { Some(DataSidecars::Blobs(blobs)) => { let block_data = AvailableBlockData::new_with_blobs(blobs.clone()); - RpcBlock::new( + RangeSyncBlock::new( block, - Some(block_data), + block_data, &chain.data_availability_checker, chain.spec.clone(), ) @@ -466,18 +466,18 @@ fn build_rpc_block( .map(|c| c.as_data_column().clone()) .collect::>(), ); - RpcBlock::new( + RangeSyncBlock::new( block, - Some(block_data), + block_data, &chain.data_availability_checker, chain.spec.clone(), ) .unwrap() } // Block has no data, expects zero columns - None => RpcBlock::new( + None => RangeSyncBlock::new( block, - Some(AvailableBlockData::NoData), + AvailableBlockData::NoData, &chain.data_availability_checker, chain.spec.clone(), ) diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index ca77dc8d79..07a7d4c6b6 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -3,7 +3,7 @@ use crate::decode::{ssz_decode_file, ssz_decode_file_with, ssz_decode_state, yam use ::fork_choice::{PayloadVerificationStatus, ProposerHeadError}; use beacon_chain::beacon_proposer_cache::compute_proposer_duties_from_head; use beacon_chain::blob_verification::GossipBlobError; -use beacon_chain::block_verification_types::RpcBlock; +use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::chain_config::{ DEFAULT_RE_ORG_HEAD_THRESHOLD, DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, DEFAULT_RE_ORG_PARENT_THRESHOLD, DisallowedReOrgOffsets, @@ -561,21 +561,13 @@ impl Tester { let block = Arc::new(block); let result: Result, _> = self - .block_on_dangerous( - self.harness.chain.process_block( - block_root, - RpcBlock::new( - block.clone(), - None, - &self.harness.chain.data_availability_checker, - self.harness.chain.spec.clone(), - ) - .map_err(|e| Error::InternalError(format!("{:?}", e)))?, - NotifyExecutionLayer::Yes, - BlockImportSource::Lookup, - || Ok(()), - ), - )? + .block_on_dangerous(self.harness.chain.process_block( + block_root, + LookupBlock::new(block.clone()), + NotifyExecutionLayer::Yes, + BlockImportSource::Lookup, + || Ok(()), + ))? .map(|avail: AvailabilityProcessingStatus| avail.try_into()); let success = data_column_success && result.as_ref().is_ok_and(|inner| inner.is_ok()); if success != valid { @@ -659,21 +651,13 @@ impl Tester { let block = Arc::new(block); let result: Result, _> = self - .block_on_dangerous( - self.harness.chain.process_block( - block_root, - RpcBlock::new( - block.clone(), - None, - &self.harness.chain.data_availability_checker, - self.harness.chain.spec.clone(), - ) - .map_err(|e| Error::InternalError(format!("{:?}", e)))?, - NotifyExecutionLayer::Yes, - BlockImportSource::Lookup, - || Ok(()), - ), - )? + .block_on_dangerous(self.harness.chain.process_block( + block_root, + LookupBlock::new(block.clone()), + NotifyExecutionLayer::Yes, + BlockImportSource::Lookup, + || Ok(()), + ))? .map(|avail: AvailabilityProcessingStatus| avail.try_into()); let success = blob_success && result.as_ref().is_ok_and(|inner| inner.is_ok()); if success != valid { From 4eecca6da737e922c973516edb502772eba2b204 Mon Sep 17 00:00:00 2001 From: Mac L Date: Mon, 16 Mar 2026 07:53:22 +0300 Subject: [PATCH 071/189] Update `/rewards` endpoints to match spec (#8967) I believe one of our rewards endpoints is slightly out of spec. We do not return the `finalized` status for `post_beacon_rewards_attestations`. Additionally, the `eth2` client doesn't expect the correct wrapper types for some other endpoints. - Update `post_beacon_rewards_attestations` server implementation to match spec. - Update all three client functions in `eth2` to the correct wrapper type. - Add missing tests for `http_api` to detect any regressions. Co-Authored-By: Mac L --- beacon_node/http_api/src/lib.rs | 12 ++- beacon_node/http_api/tests/tests.rs | 118 ++++++++++++++++++++++++++-- common/eth2/src/lib.rs | 6 +- 3 files changed, 124 insertions(+), 12 deletions(-) diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 26bad809df..fc92128c91 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -1801,8 +1801,16 @@ pub fn serve( let execution_optimistic = chain.is_optimistic_or_invalid_head().unwrap_or_default(); - Ok(api_types::GenericResponse::from(attestation_rewards)) - .map(|resp| resp.add_execution_optimistic(execution_optimistic)) + let finalized = epoch + 2 + <= chain + .canonical_head + .cached_head() + .finalized_checkpoint() + .epoch; + + Ok(api_types::GenericResponse::from(attestation_rewards)).map(|resp| { + resp.add_execution_optimistic_finalized(execution_optimistic, finalized) + }) }) }, ); diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index aed7a6b200..c9086dd876 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -7195,15 +7195,16 @@ impl ApiTester { assert_eq!(result.execution_optimistic, Some(true)); } - async fn test_get_beacon_rewards_blocks_at_head(&self) -> StandardBlockReward { + async fn test_get_beacon_rewards_blocks_at_head( + &self, + ) -> ExecutionOptimisticFinalizedResponse { self.client .get_beacon_rewards_blocks(CoreBlockId::Head) .await .unwrap() - .data } - async fn test_beacon_block_rewards_electra(self) -> Self { + async fn test_beacon_block_rewards_fulu(self) -> Self { for _ in 0..E::slots_per_epoch() { let state = self.harness.get_current_state(); let slot = state.slot() + Slot::new(1); @@ -7217,8 +7218,80 @@ impl ApiTester { .compute_beacon_block_reward(signed_block.message(), &mut state) .unwrap(); self.harness.extend_slots(1).await; - let api_beacon_block_reward = self.test_get_beacon_rewards_blocks_at_head().await; - assert_eq!(beacon_block_reward, api_beacon_block_reward); + let response = self.test_get_beacon_rewards_blocks_at_head().await; + assert_eq!(response.execution_optimistic, Some(false)); + assert_eq!(response.finalized, Some(false)); + assert_eq!(beacon_block_reward, response.data); + } + self + } + + async fn test_get_beacon_rewards_sync_committee_at_head( + &self, + ) -> ExecutionOptimisticFinalizedResponse> { + self.client + .post_beacon_rewards_sync_committee(CoreBlockId::Head, &[]) + .await + .unwrap() + } + + async fn test_beacon_sync_committee_rewards_fulu(self) -> Self { + for _ in 0..E::slots_per_epoch() { + let state = self.harness.get_current_state(); + let slot = state.slot() + Slot::new(1); + + let ((signed_block, _maybe_blob_sidecars), mut state) = + self.harness.make_block_return_pre_state(state, slot).await; + + let mut expected_rewards = self + .harness + .chain + .compute_sync_committee_rewards(signed_block.message(), &mut state) + .unwrap(); + expected_rewards.sort_by_key(|r| r.validator_index); + + self.harness.extend_slots(1).await; + + let response = self.test_get_beacon_rewards_sync_committee_at_head().await; + assert_eq!(response.execution_optimistic, Some(false)); + assert_eq!(response.finalized, Some(false)); + let mut api_rewards = response.data; + api_rewards.sort_by_key(|r| r.validator_index); + assert_eq!(expected_rewards, api_rewards); + } + self + } + + async fn test_get_beacon_rewards_attestations( + &self, + epoch: Epoch, + ) -> ExecutionOptimisticFinalizedResponse { + self.client + .post_beacon_rewards_attestations(epoch, &[]) + .await + .unwrap() + } + + async fn test_beacon_attestation_rewards_fulu(self) -> Self { + // Check 3 epochs. + let num_epochs = 3; + for _ in 0..num_epochs { + self.harness + .extend_slots(E::slots_per_epoch() as usize) + .await; + + let epoch = self.chain.epoch().unwrap() - 1; + + let expected_rewards = self + .harness + .chain + .compute_attestation_rewards(epoch, vec![]) + .unwrap(); + + let response = self.test_get_beacon_rewards_attestations(epoch).await; + assert_eq!(response.execution_optimistic, Some(false)); + assert_eq!(response.finalized, Some(false)); + assert_eq!(expected_rewards, response.data); } self } @@ -8534,16 +8607,47 @@ async fn expected_withdrawals_valid_capella() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_beacon_rewards_blocks_electra() { +async fn get_beacon_rewards_blocks_fulu() { let mut config = ApiTesterConfig::default(); config.spec.altair_fork_epoch = Some(Epoch::new(0)); config.spec.bellatrix_fork_epoch = Some(Epoch::new(0)); config.spec.capella_fork_epoch = Some(Epoch::new(0)); config.spec.deneb_fork_epoch = Some(Epoch::new(0)); config.spec.electra_fork_epoch = Some(Epoch::new(0)); + config.spec.fulu_fork_epoch = Some(Epoch::new(0)); ApiTester::new_from_config(config) .await - .test_beacon_block_rewards_electra() + .test_beacon_block_rewards_fulu() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_beacon_rewards_sync_committee_fulu() { + let mut config = ApiTesterConfig::default(); + config.spec.altair_fork_epoch = Some(Epoch::new(0)); + config.spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + config.spec.capella_fork_epoch = Some(Epoch::new(0)); + config.spec.deneb_fork_epoch = Some(Epoch::new(0)); + config.spec.electra_fork_epoch = Some(Epoch::new(0)); + config.spec.fulu_fork_epoch = Some(Epoch::new(0)); + ApiTester::new_from_config(config) + .await + .test_beacon_sync_committee_rewards_fulu() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_beacon_rewards_attestations_fulu() { + let mut config = ApiTesterConfig::default(); + config.spec.altair_fork_epoch = Some(Epoch::new(0)); + config.spec.bellatrix_fork_epoch = Some(Epoch::new(0)); + config.spec.capella_fork_epoch = Some(Epoch::new(0)); + config.spec.deneb_fork_epoch = Some(Epoch::new(0)); + config.spec.electra_fork_epoch = Some(Epoch::new(0)); + config.spec.fulu_fork_epoch = Some(Epoch::new(0)); + ApiTester::new_from_config(config) + .await + .test_beacon_attestation_rewards_fulu() .await; } diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index af87af14ba..40c5ef58a6 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -1802,7 +1802,7 @@ impl BeaconNodeHttpClient { &self, block_id: BlockId, validators: &[ValidatorId], - ) -> Result>, Error> { + ) -> Result>, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() @@ -1819,7 +1819,7 @@ impl BeaconNodeHttpClient { pub async fn get_beacon_rewards_blocks( &self, block_id: BlockId, - ) -> Result, Error> { + ) -> Result, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() @@ -1837,7 +1837,7 @@ impl BeaconNodeHttpClient { &self, epoch: Epoch, validators: &[ValidatorId], - ) -> Result { + ) -> Result, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() From 95b99ee7247aa966aac48dc5e9bfb3d73db25b39 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 16 Mar 2026 22:40:22 +1100 Subject: [PATCH 072/189] Spec v1.7.0 alpha.3 (#8988) Update spec code for compliance with spec v1.7.0-alpha.3: https://github.com/ethereum/consensus-specs/releases/tag/v1.7.0-alpha.3 The actual consensus changes are minimal. There are few more changes that are only relevant to fork choice or P2P validation that we will pick up in future PRs. The change "Ignore beacon block if parent payload unknown" is currently covered in a hacky way by `load_parent` and can be improved once we have fork choice. The change "Add parent_block_root to bid filtering key" is relevant to bid gossip validation, which we don't have at all in unstable yet. Co-Authored-By: Michael Sproul --- .../beacon_chain/src/block_verification.rs | 1 + .../process_operations.rs | 139 +++++++++++++++++- consensus/types/src/core/consts.rs | 6 +- testing/ef_tests/Makefile | 2 +- testing/ef_tests/check_all_files_accessed.py | 2 + testing/ef_tests/download_test_vectors.sh | 7 +- testing/ef_tests/src/cases/operations.rs | 9 +- testing/ef_tests/src/handler.rs | 5 - 8 files changed, 151 insertions(+), 20 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 06ec26185f..802b090f6a 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -1940,6 +1940,7 @@ fn load_parent>( { if block.as_block().is_parent_block_full(parent_bid_block_hash) { // TODO(gloas): loading the envelope here is not very efficient + // TODO(gloas): check parent payload existence prior to this point? let envelope = chain.store.get_payload_envelope(&root)?.ok_or_else(|| { BeaconChainError::DBInconsistent(format!( "Missing envelope for parent block {root:?}", diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index 9743812632..ac64398655 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -4,7 +4,10 @@ use crate::common::{ get_attestation_participation_flag_indices, increase_balance, initiate_validator_exit, slash_validator, }; -use crate::per_block_processing::errors::{BlockProcessingError, IntoWithIndex}; +use crate::per_block_processing::builder::{ + convert_validator_index_to_builder_index, is_builder_index, +}; +use crate::per_block_processing::errors::{BlockProcessingError, ExitInvalid, IntoWithIndex}; use crate::per_block_processing::verify_payload_attestation::verify_payload_attestation; use bls::{PublicKeyBytes, SignatureBytes}; use ssz_types::FixedVector; @@ -507,7 +510,26 @@ pub fn process_exits( // Verify and apply each exit in series. We iterate in series because higher-index exits may // become invalid due to the application of lower-index ones. for (i, exit) in voluntary_exits.iter().enumerate() { - verify_exit(state, None, exit, verify_signatures, spec) + // Exits must specify an epoch when they become valid; they are not valid before then. + let current_epoch = state.current_epoch(); + if current_epoch < exit.message.epoch { + return Err(BlockOperationError::invalid(ExitInvalid::FutureEpoch { + state: current_epoch, + exit: exit.message.epoch, + }) + .into_with_index(i)); + } + + // [New in Gloas:EIP7732] + if state.fork_name_unchecked().gloas_enabled() + && is_builder_index(exit.message.validator_index) + { + process_builder_voluntary_exit(state, exit, verify_signatures, spec) + .map_err(|e| e.into_with_index(i))?; + continue; + } + + verify_exit(state, Some(current_epoch), exit, verify_signatures, spec) .map_err(|e| e.into_with_index(i))?; initiate_validator_exit(state, exit.message.validator_index as usize, spec)?; @@ -515,6 +537,87 @@ pub fn process_exits( Ok(()) } +/// Process a builder voluntary exit. [New in Gloas:EIP7732] +fn process_builder_voluntary_exit( + state: &mut BeaconState, + signed_exit: &SignedVoluntaryExit, + verify_signatures: VerifySignatures, + spec: &ChainSpec, +) -> Result<(), BlockOperationError> { + let builder_index = + convert_validator_index_to_builder_index(signed_exit.message.validator_index); + + let builder = state + .builders()? + .get(builder_index as usize) + .cloned() + .ok_or(BlockOperationError::invalid(ExitInvalid::ValidatorUnknown( + signed_exit.message.validator_index, + )))?; + + // Verify the builder is active + let finalized_epoch = state.finalized_checkpoint().epoch; + if !builder.is_active_at_finalized_epoch(finalized_epoch, spec) { + return Err(BlockOperationError::invalid(ExitInvalid::NotActive( + signed_exit.message.validator_index, + ))); + } + + // Only exit builder if it has no pending withdrawals in the queue + let pending_balance = state.get_pending_balance_to_withdraw_for_builder(builder_index)?; + if pending_balance != 0 { + return Err(BlockOperationError::invalid( + ExitInvalid::PendingWithdrawalInQueue(signed_exit.message.validator_index), + )); + } + + // Verify signature (using EIP-7044 domain: capella_fork_version for Deneb+) + if verify_signatures.is_true() { + let pubkey = builder.pubkey; + let domain = spec.compute_domain( + Domain::VoluntaryExit, + spec.capella_fork_version, + state.genesis_validators_root(), + ); + let message = signed_exit.message.signing_root(domain); + // TODO(gloas): use builder pubkey cache once available + let bls_pubkey = pubkey + .decompress() + .map_err(|_| BlockOperationError::invalid(ExitInvalid::BadSignature))?; + if !signed_exit.signature.verify(&bls_pubkey, message) { + return Err(BlockOperationError::invalid(ExitInvalid::BadSignature)); + } + } + + // Initiate builder exit + initiate_builder_exit(state, builder_index, spec)?; + + Ok(()) +} + +/// Initiate the exit of a builder. [New in Gloas:EIP7732] +fn initiate_builder_exit( + state: &mut BeaconState, + builder_index: u64, + spec: &ChainSpec, +) -> Result<(), BeaconStateError> { + let current_epoch = state.current_epoch(); + let builder = state + .builders_mut()? + .get_mut(builder_index as usize) + .ok_or(BeaconStateError::UnknownBuilder(builder_index))?; + + // Return if builder already initiated exit + if builder.withdrawable_epoch != spec.far_future_epoch { + return Ok(()); + } + + // Set builder exit epoch + builder.withdrawable_epoch = current_epoch.safe_add(spec.min_builder_withdrawability_delay)?; + + Ok(()) +} + /// Validates each `bls_to_execution_change` and updates the state /// /// Returns `Ok(())` if the validation and state updates completed successfully. Otherwise returns @@ -814,6 +917,30 @@ pub fn process_deposit_requests_post_gloas( Ok(()) } +/// Check if there is a pending deposit for a new validator with the given pubkey. +// TODO(gloas): cache the deposit signature validation or remove this loop entirely if possible, +// it is `O(n * m)` where `n` is max 8192 and `m` is max 128M. +fn is_pending_validator( + state: &BeaconState, + pubkey: &PublicKeyBytes, + spec: &ChainSpec, +) -> Result { + for deposit in state.pending_deposits()?.iter() { + if deposit.pubkey == *pubkey { + let deposit_data = DepositData { + pubkey: deposit.pubkey, + withdrawal_credentials: deposit.withdrawal_credentials, + amount: deposit.amount, + signature: deposit.signature.clone(), + }; + if is_valid_deposit_signature(&deposit_data, spec).is_ok() { + return Ok(true); + } + } + } + Ok(false) +} + pub fn process_deposit_request_post_gloas( state: &mut BeaconState, deposit_request: &DepositRequest, @@ -835,10 +962,14 @@ pub fn process_deposit_request_post_gloas( let validator_index = state.get_validator_index(&deposit_request.pubkey)?; let is_validator = validator_index.is_some(); - let is_builder_prefix = + let has_builder_prefix = is_builder_withdrawal_credential(deposit_request.withdrawal_credentials, spec); - if is_builder || (is_builder_prefix && !is_validator) { + if is_builder + || (has_builder_prefix + && !is_validator + && !is_pending_validator(state, &deposit_request.pubkey, spec)?) + { // Apply builder deposits immediately apply_deposit_for_builder( state, diff --git a/consensus/types/src/core/consts.rs b/consensus/types/src/core/consts.rs index 0d4c0591cb..049094da76 100644 --- a/consensus/types/src/core/consts.rs +++ b/consensus/types/src/core/consts.rs @@ -31,9 +31,9 @@ pub mod gloas { // Fork choice constants pub type PayloadStatus = u8; - pub const PAYLOAD_STATUS_PENDING: PayloadStatus = 0; - pub const PAYLOAD_STATUS_EMPTY: PayloadStatus = 1; - pub const PAYLOAD_STATUS_FULL: PayloadStatus = 2; + pub const PAYLOAD_STATUS_EMPTY: PayloadStatus = 0; + pub const PAYLOAD_STATUS_FULL: PayloadStatus = 1; + pub const PAYLOAD_STATUS_PENDING: PayloadStatus = 2; pub const ATTESTATION_TIMELINESS_INDEX: usize = 0; pub const PTC_TIMELINESS_INDEX: usize = 1; diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index fd8a3f6da0..48378a4c95 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.2 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.3 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index 782b554ff1..dd6be14306 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -47,6 +47,8 @@ excluded_paths = [ "bls12-381-tests/hash_to_G2", "tests/.*/eip7732", "tests/.*/eip7805", + # Heze fork is not implemented + "tests/.*/heze/.*", # TODO(gloas): remove these ignores as Gloas consensus is implemented "tests/.*/gloas/fork_choice/.*", # Ignore MatrixEntry SSZ tests for now. diff --git a/testing/ef_tests/download_test_vectors.sh b/testing/ef_tests/download_test_vectors.sh index ff5b61bb47..f91b2d1c38 100755 --- a/testing/ef_tests/download_test_vectors.sh +++ b/testing/ef_tests/download_test_vectors.sh @@ -10,7 +10,7 @@ if [[ "$version" == "nightly" || "$version" =~ ^nightly-[0-9]+$ ]]; then exit 1 fi - for cmd in unzip jq; do + for cmd in jq; do if ! command -v "${cmd}" >/dev/null 2>&1; then echo "Error ${cmd} is not installed" exit 1 @@ -48,13 +48,10 @@ if [[ "$version" == "nightly" || "$version" =~ ^nightly-[0-9]+$ ]]; then echo "Downloading artifact: ${name}" curl --progress-bar --location --show-error --retry 3 --retry-all-errors --fail \ -H "${auth_header}" -H "Accept: application/vnd.github+json" \ - --output "${name}.zip" "${url}" || { + --output "${name}" "${url}" || { echo "Failed to download ${name}" exit 1 } - - unzip -qo "${name}.zip" - rm -f "${name}.zip" done else for test in "${TESTS[@]}"; do diff --git a/testing/ef_tests/src/cases/operations.rs b/testing/ef_tests/src/cases/operations.rs index ca0124e1aa..798c66b666 100644 --- a/testing/ef_tests/src/cases/operations.rs +++ b/testing/ef_tests/src/cases/operations.rs @@ -716,8 +716,13 @@ impl> LoadCase for Operations { // Check BLS setting here before SSZ deserialization, as most types require signatures // to be valid. - let (operation, bls_error) = if metadata.bls_setting.unwrap_or_default().check().is_ok() { - match O::decode(&path.join(O::filename()), fork_name, spec) { + let operation_path = path.join(O::filename()); + let (operation, bls_error) = if !operation_path.is_file() { + // Some test cases (e.g. builder_voluntary_exit__success) have no operation file. + // TODO(gloas): remove this once the test vectors are fixed + (None, None) + } else if metadata.bls_setting.unwrap_or_default().check().is_ok() { + match O::decode(&operation_path, fork_name, spec) { Ok(op) => (Some(op), None), Err(Error::InvalidBLSInput(error)) => (None, Some(error)), Err(e) => return Err(e), diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index da3c5533b6..f8c16aec0b 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -537,11 +537,6 @@ impl Handler for RandomHandler { fn handler_name(&self) -> String { "random".into() } - - fn disabled_forks(&self) -> Vec { - // TODO(gloas): remove once we have Gloas random tests - vec![ForkName::Gloas] - } } #[derive(Educe)] From 17d183eb5bf1718054598d4fc91efd2c8ef33431 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Tue, 17 Mar 2026 16:35:05 +0900 Subject: [PATCH 073/189] Unknown block for envelope (#8992) Add a queue that allows us to reprocess an envelope when it arrives over gossip references a unknown block root. When the block is finally imported, we immediately reprocess the queued envelope. Note that we don't trigger a block lookup sync. Incoming attestations for this block root will already trigger a lookup for us. I think thats good enough Co-Authored-By: Eitan Seri- Levi --- beacon_node/beacon_processor/src/lib.rs | 33 +- .../src/scheduler/work_queue.rs | 5 + .../src/scheduler/work_reprocessing_queue.rs | 281 ++++++++++++++++++ .../gossip_methods.rs | 63 +++- .../src/network_beacon_processor/tests.rs | 5 + 5 files changed, 383 insertions(+), 4 deletions(-) diff --git a/beacon_node/beacon_processor/src/lib.rs b/beacon_node/beacon_processor/src/lib.rs index 33a00bfa49..c33f4840e0 100644 --- a/beacon_node/beacon_processor/src/lib.rs +++ b/beacon_node/beacon_processor/src/lib.rs @@ -41,7 +41,8 @@ pub use crate::scheduler::BeaconProcessorQueueLengths; use crate::scheduler::work_queue::WorkQueues; use crate::work_reprocessing_queue::{ - QueuedBackfillBatch, QueuedColumnReconstruction, QueuedGossipBlock, ReprocessQueueMessage, + QueuedBackfillBatch, QueuedColumnReconstruction, QueuedGossipBlock, QueuedGossipEnvelope, + ReprocessQueueMessage, }; use futures::stream::{Stream, StreamExt}; use futures::task::Poll; @@ -242,6 +243,18 @@ impl From for WorkEvent { process_fn, }, }, + ReadyWork::Envelope(QueuedGossipEnvelope { + beacon_block_slot, + beacon_block_root, + process_fn, + }) => Self { + drop_during_sync: false, + work: Work::DelayedImportEnvelope { + beacon_block_slot, + beacon_block_root, + process_fn, + }, + }, ReadyWork::RpcBlock(QueuedRpcBlock { beacon_block_root, process_fn, @@ -384,6 +397,11 @@ pub enum Work { beacon_block_root: Hash256, process_fn: AsyncFn, }, + DelayedImportEnvelope { + beacon_block_slot: Slot, + beacon_block_root: Hash256, + process_fn: AsyncFn, + }, GossipVoluntaryExit(BlockingFn), GossipProposerSlashing(BlockingFn), GossipAttesterSlashing(BlockingFn), @@ -447,6 +465,7 @@ pub enum WorkType { GossipBlobSidecar, GossipDataColumnSidecar, DelayedImportBlock, + DelayedImportEnvelope, GossipVoluntaryExit, GossipProposerSlashing, GossipAttesterSlashing, @@ -498,6 +517,7 @@ impl Work { Work::GossipBlobSidecar(_) => WorkType::GossipBlobSidecar, Work::GossipDataColumnSidecar(_) => WorkType::GossipDataColumnSidecar, Work::DelayedImportBlock { .. } => WorkType::DelayedImportBlock, + Work::DelayedImportEnvelope { .. } => WorkType::DelayedImportEnvelope, Work::GossipVoluntaryExit(_) => WorkType::GossipVoluntaryExit, Work::GossipProposerSlashing(_) => WorkType::GossipProposerSlashing, Work::GossipAttesterSlashing(_) => WorkType::GossipAttesterSlashing, @@ -793,6 +813,8 @@ impl BeaconProcessor { // on the delayed ones. } else if let Some(item) = work_queues.delayed_block_queue.pop() { Some(item) + } else if let Some(item) = work_queues.delayed_envelope_queue.pop() { + Some(item) // Check gossip blocks and payloads before gossip attestations, since a block might be // required to verify some attestations. } else if let Some(item) = work_queues.gossip_block_queue.pop() { @@ -1111,6 +1133,9 @@ impl BeaconProcessor { Work::DelayedImportBlock { .. } => { work_queues.delayed_block_queue.push(work, work_id) } + Work::DelayedImportEnvelope { .. } => { + work_queues.delayed_envelope_queue.push(work, work_id) + } Work::GossipVoluntaryExit { .. } => { work_queues.gossip_voluntary_exit_queue.push(work, work_id) } @@ -1238,6 +1263,7 @@ impl BeaconProcessor { work_queues.gossip_data_column_queue.len() } WorkType::DelayedImportBlock => work_queues.delayed_block_queue.len(), + WorkType::DelayedImportEnvelope => work_queues.delayed_envelope_queue.len(), WorkType::GossipVoluntaryExit => { work_queues.gossip_voluntary_exit_queue.len() } @@ -1435,6 +1461,11 @@ impl BeaconProcessor { beacon_block_slot: _, beacon_block_root: _, process_fn, + } + | Work::DelayedImportEnvelope { + beacon_block_slot: _, + beacon_block_root: _, + process_fn, } => task_spawner.spawn_async(process_fn), Work::RpcBlock { process_fn, diff --git a/beacon_node/beacon_processor/src/scheduler/work_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_queue.rs index 934659b304..e48c776b6d 100644 --- a/beacon_node/beacon_processor/src/scheduler/work_queue.rs +++ b/beacon_node/beacon_processor/src/scheduler/work_queue.rs @@ -127,6 +127,7 @@ pub struct BeaconProcessorQueueLengths { gossip_blob_queue: usize, gossip_data_column_queue: usize, delayed_block_queue: usize, + delayed_envelope_queue: usize, status_queue: usize, block_brange_queue: usize, block_broots_queue: usize, @@ -197,6 +198,7 @@ impl BeaconProcessorQueueLengths { gossip_blob_queue: 1024, gossip_data_column_queue: 1024, delayed_block_queue: 1024, + delayed_envelope_queue: 1024, status_queue: 1024, block_brange_queue: 1024, block_broots_queue: 1024, @@ -250,6 +252,7 @@ pub struct WorkQueues { pub gossip_blob_queue: FifoQueue>, pub gossip_data_column_queue: FifoQueue>, pub delayed_block_queue: FifoQueue>, + pub delayed_envelope_queue: FifoQueue>, pub status_queue: FifoQueue>, pub block_brange_queue: FifoQueue>, pub block_broots_queue: FifoQueue>, @@ -315,6 +318,7 @@ impl WorkQueues { let gossip_blob_queue = FifoQueue::new(queue_lengths.gossip_blob_queue); let gossip_data_column_queue = FifoQueue::new(queue_lengths.gossip_data_column_queue); let delayed_block_queue = FifoQueue::new(queue_lengths.delayed_block_queue); + let delayed_envelope_queue = FifoQueue::new(queue_lengths.delayed_envelope_queue); let status_queue = FifoQueue::new(queue_lengths.status_queue); let block_brange_queue = FifoQueue::new(queue_lengths.block_brange_queue); @@ -375,6 +379,7 @@ impl WorkQueues { gossip_blob_queue, gossip_data_column_queue, delayed_block_queue, + delayed_envelope_queue, status_queue, block_brange_queue, block_broots_queue, diff --git a/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs index c99388287c..38306b3bb6 100644 --- a/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs +++ b/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs @@ -35,6 +35,7 @@ use types::{EthSpec, Hash256, Slot}; const TASK_NAME: &str = "beacon_processor_reprocess_queue"; const GOSSIP_BLOCKS: &str = "gossip_blocks"; +const GOSSIP_ENVELOPES: &str = "gossip_envelopes"; const RPC_BLOCKS: &str = "rpc_blocks"; const ATTESTATIONS: &str = "attestations"; const ATTESTATIONS_PER_ROOT: &str = "attestations_per_root"; @@ -51,6 +52,10 @@ pub const QUEUED_ATTESTATION_DELAY: Duration = Duration::from_secs(12); /// For how long to queue light client updates for re-processing. pub const QUEUED_LIGHT_CLIENT_UPDATE_DELAY: Duration = Duration::from_secs(12); +/// Envelope timeout as a multiplier of slot duration. Envelopes waiting for their block will be +/// sent for processing after this many slots worth of time, even if the block hasn't arrived. +const QUEUED_ENVELOPE_DELAY_SLOTS: u32 = 1; + /// For how long to queue rpc blocks before sending them back for reprocessing. pub const QUEUED_RPC_BLOCK_DELAY: Duration = Duration::from_secs(4); @@ -65,6 +70,9 @@ pub const QUEUED_RECONSTRUCTION_DELAY: Duration = Duration::from_millis(150); /// it's nice to have extra protection. const MAXIMUM_QUEUED_BLOCKS: usize = 16; +/// Set an arbitrary upper-bound on the number of queued envelopes to avoid DoS attacks. +const MAXIMUM_QUEUED_ENVELOPES: usize = 16; + /// How many attestations we keep before new ones get dropped. const MAXIMUM_QUEUED_ATTESTATIONS: usize = 16_384; @@ -93,6 +101,8 @@ pub const RECONSTRUCTION_DEADLINE: (u64, u64) = (1, 4); pub enum ReprocessQueueMessage { /// A block that has been received early and we should queue for later processing. EarlyBlock(QueuedGossipBlock), + /// An execution payload envelope that references a block not yet in fork choice. + UnknownBlockForEnvelope(QueuedGossipEnvelope), /// A gossip block for hash `X` is being imported, we should queue the rpc block for the same /// hash until the gossip block is imported. RpcBlock(QueuedRpcBlock), @@ -120,6 +130,7 @@ pub enum ReprocessQueueMessage { /// Events sent by the scheduler once they are ready for re-processing. pub enum ReadyWork { Block(QueuedGossipBlock), + Envelope(QueuedGossipEnvelope), RpcBlock(QueuedRpcBlock), IgnoredRpcBlock(IgnoredRpcBlock), Unaggregate(QueuedUnaggregate), @@ -157,6 +168,13 @@ pub struct QueuedGossipBlock { pub process_fn: AsyncFn, } +/// An execution payload envelope that arrived early and has been queued for later import. +pub struct QueuedGossipEnvelope { + pub beacon_block_slot: Slot, + pub beacon_block_root: Hash256, + pub process_fn: AsyncFn, +} + /// A block that arrived for processing when the same block was being imported over gossip. /// It is queued for later import. pub struct QueuedRpcBlock { @@ -209,6 +227,8 @@ impl From for WorkEvent { enum InboundEvent { /// A gossip block that was queued for later processing and is ready for import. ReadyGossipBlock(QueuedGossipBlock), + /// An envelope whose block has been imported and is now ready for processing. + ReadyEnvelope(Hash256), /// A rpc block that was queued because the same gossip block was being imported /// will now be retried for import. ReadyRpcBlock(QueuedRpcBlock), @@ -234,6 +254,8 @@ struct ReprocessQueue { /* Queues */ /// Queue to manage scheduled early blocks. gossip_block_delay_queue: DelayQueue, + /// Queue to manage envelope timeouts (keyed by block root). + envelope_delay_queue: DelayQueue, /// Queue to manage scheduled early blocks. rpc_block_delay_queue: DelayQueue, /// Queue to manage scheduled attestations. @@ -246,6 +268,8 @@ struct ReprocessQueue { /* Queued items */ /// Queued blocks. queued_gossip_block_roots: HashSet, + /// Queued envelopes awaiting their block, keyed by block root. + awaiting_envelopes_per_root: HashMap, /// Queued aggregated attestations. queued_aggregates: FnvHashMap, /// Queued attestations. @@ -266,6 +290,7 @@ struct ReprocessQueue { next_attestation: usize, next_lc_update: usize, early_block_debounce: TimeLatch, + envelope_delay_debounce: TimeLatch, rpc_block_debounce: TimeLatch, attestation_delay_debounce: TimeLatch, lc_update_delay_debounce: TimeLatch, @@ -315,6 +340,13 @@ impl Stream for ReprocessQueue { Poll::Ready(None) | Poll::Pending => (), } + match self.envelope_delay_queue.poll_expired(cx) { + Poll::Ready(Some(block_root)) => { + return Poll::Ready(Some(InboundEvent::ReadyEnvelope(block_root.into_inner()))); + } + Poll::Ready(None) | Poll::Pending => (), + } + match self.rpc_block_delay_queue.poll_expired(cx) { Poll::Ready(Some(queued_block)) => { return Poll::Ready(Some(InboundEvent::ReadyRpcBlock(queued_block.into_inner()))); @@ -418,11 +450,13 @@ impl ReprocessQueue { work_reprocessing_rx, ready_work_tx, gossip_block_delay_queue: DelayQueue::new(), + envelope_delay_queue: DelayQueue::new(), rpc_block_delay_queue: DelayQueue::new(), attestations_delay_queue: DelayQueue::new(), lc_updates_delay_queue: DelayQueue::new(), column_reconstructions_delay_queue: DelayQueue::new(), queued_gossip_block_roots: HashSet::new(), + awaiting_envelopes_per_root: HashMap::new(), queued_lc_updates: FnvHashMap::default(), queued_aggregates: FnvHashMap::default(), queued_unaggregates: FnvHashMap::default(), @@ -433,6 +467,7 @@ impl ReprocessQueue { next_attestation: 0, next_lc_update: 0, early_block_debounce: TimeLatch::default(), + envelope_delay_debounce: TimeLatch::default(), rpc_block_debounce: TimeLatch::default(), attestation_delay_debounce: TimeLatch::default(), lc_update_delay_debounce: TimeLatch::default(), @@ -498,6 +533,52 @@ impl ReprocessQueue { } } } + // An envelope that references an unknown block. Queue it until the block is + // imported, or until the timeout expires. + InboundEvent::Msg(UnknownBlockForEnvelope(queued_envelope)) => { + let block_root = queued_envelope.beacon_block_root; + + // TODO(gloas): Perform lightweight pre-validation before queuing + // (e.g. verify builder signature) to prevent unsigned garbage from + // consuming queue slots. + + // Don't add the same envelope to the queue twice. This prevents DoS attacks. + if self.awaiting_envelopes_per_root.contains_key(&block_root) { + trace!( + ?block_root, + "Duplicate envelope for same block root, dropping" + ); + return; + } + + // When the queue is full, evict the oldest entry to make room for newer envelopes. + if self.awaiting_envelopes_per_root.len() >= MAXIMUM_QUEUED_ENVELOPES { + if self.envelope_delay_debounce.elapsed() { + warn!( + queue_size = MAXIMUM_QUEUED_ENVELOPES, + msg = "system resources may be saturated", + "Envelope delay queue is full, evicting oldest entry" + ); + } + if let Some(oldest_root) = + self.awaiting_envelopes_per_root.keys().next().copied() + && let Some((_envelope, delay_key)) = + self.awaiting_envelopes_per_root.remove(&oldest_root) + { + self.envelope_delay_queue.remove(&delay_key); + } + } + + // Register the timeout. + let delay_key = self.envelope_delay_queue.insert( + block_root, + self.slot_clock.slot_duration() * QUEUED_ENVELOPE_DELAY_SLOTS, + ); + + // Store the envelope keyed by block root. + self.awaiting_envelopes_per_root + .insert(block_root, (queued_envelope, delay_key)); + } // A rpc block arrived for processing at the same time when a gossip block // for the same block hash is being imported. We wait for `QUEUED_RPC_BLOCK_DELAY` // and then send the rpc block back for processing assuming the gossip import @@ -647,6 +728,23 @@ impl ReprocessQueue { block_root, parent_root, }) => { + // Unqueue the envelope we have for this root, if any. + if let Some((envelope, delay_key)) = + self.awaiting_envelopes_per_root.remove(&block_root) + { + self.envelope_delay_queue.remove(&delay_key); + if self + .ready_work_tx + .try_send(ReadyWork::Envelope(envelope)) + .is_err() + { + error!( + ?block_root, + "Failed to send envelope for reprocessing after block import" + ); + } + } + // Unqueue the attestations we have for this root, if any. if let Some(queued_ids) = self.awaiting_attestations_per_root.remove(&block_root) { let mut sent_count = 0; @@ -802,6 +900,25 @@ impl ReprocessQueue { error!("Failed to pop queued block"); } } + // An envelope's timeout has expired. Send it for processing regardless of + // whether the block has been imported. + InboundEvent::ReadyEnvelope(block_root) => { + if let Some((envelope, _delay_key)) = + self.awaiting_envelopes_per_root.remove(&block_root) + { + debug!( + ?block_root, + "Envelope timed out waiting for block, sending for processing" + ); + if self + .ready_work_tx + .try_send(ReadyWork::Envelope(envelope)) + .is_err() + { + error!(?block_root, "Failed to send envelope after timeout"); + } + } + } InboundEvent::ReadyAttestation(queued_id) => { metrics::inc_counter( &metrics::BEACON_PROCESSOR_REPROCESSING_QUEUE_EXPIRED_ATTESTATIONS, @@ -941,6 +1058,11 @@ impl ReprocessQueue { &[GOSSIP_BLOCKS], self.gossip_block_delay_queue.len() as i64, ); + metrics::set_gauge_vec( + &metrics::BEACON_PROCESSOR_REPROCESSING_QUEUE_TOTAL, + &[GOSSIP_ENVELOPES], + self.awaiting_envelopes_per_root.len() as i64, + ); metrics::set_gauge_vec( &metrics::BEACON_PROCESSOR_REPROCESSING_QUEUE_TOTAL, &[RPC_BLOCKS], @@ -1339,4 +1461,163 @@ mod tests { assert_eq!(reconstruction.block_root, block_root); } } + + // Test that envelopes are properly cleaned up from `awaiting_envelopes_per_root` on timeout. + #[tokio::test] + async fn prune_awaiting_envelopes_per_root() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + + // Pause time so it only advances manually + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + // Insert an envelope. + let msg = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root, + process_fn: Box::pin(async {}), + }); + + // Process the event to enter it into the delay queue. + queue.handle_message(InboundEvent::Msg(msg)); + + // Check that it is queued. + assert_eq!(queue.awaiting_envelopes_per_root.len(), 1); + assert!( + queue + .awaiting_envelopes_per_root + .contains_key(&beacon_block_root) + ); + + // Advance time to expire the envelope. + advance_time( + &queue.slot_clock, + queue.slot_clock.slot_duration() * QUEUED_ENVELOPE_DELAY_SLOTS * 2, + ) + .await; + let ready_msg = queue.next().await.unwrap(); + assert!(matches!(ready_msg, InboundEvent::ReadyEnvelope(_))); + queue.handle_message(ready_msg); + + // The entry for the block root should be gone. + assert!(queue.awaiting_envelopes_per_root.is_empty()); + } + + #[tokio::test] + async fn envelope_released_on_block_imported() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + + // Pause time so it only advances manually + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + let parent_root = Hash256::repeat_byte(0xab); + + // Insert an envelope. + let msg = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root, + process_fn: Box::pin(async {}), + }); + + // Process the event to enter it into the delay queue. + queue.handle_message(InboundEvent::Msg(msg)); + + // Check that it is queued. + assert_eq!(queue.awaiting_envelopes_per_root.len(), 1); + + // Simulate block import. + let imported = ReprocessQueueMessage::BlockImported { + block_root: beacon_block_root, + parent_root, + }; + queue.handle_message(InboundEvent::Msg(imported)); + + // The entry for the block root should be gone. + assert!(queue.awaiting_envelopes_per_root.is_empty()); + // Delay queue entry should also be cancelled. + assert_eq!(queue.envelope_delay_queue.len(), 0); + } + + #[tokio::test] + async fn envelope_dedup_drops_second() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + + // Pause time so it only advances manually + tokio::time::pause(); + + let beacon_block_root = Hash256::repeat_byte(0xaf); + + // Insert an envelope. + let msg1 = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root, + process_fn: Box::pin(async {}), + }); + let msg2 = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root, + process_fn: Box::pin(async {}), + }); + + // Process both events. + queue.handle_message(InboundEvent::Msg(msg1)); + queue.handle_message(InboundEvent::Msg(msg2)); + + // Only one should be queued. + assert_eq!(queue.awaiting_envelopes_per_root.len(), 1); + assert_eq!(queue.envelope_delay_queue.len(), 1); + } + + #[tokio::test] + async fn envelope_capacity_evicts_oldest() { + create_test_tracing_subscriber(); + + let mut queue = test_queue(); + + // Pause time so it only advances manually + tokio::time::pause(); + + // Fill the queue to capacity. + for i in 0..MAXIMUM_QUEUED_ENVELOPES { + let block_root = Hash256::repeat_byte(i as u8); + let msg = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root: block_root, + process_fn: Box::pin(async {}), + }); + queue.handle_message(InboundEvent::Msg(msg)); + } + assert_eq!( + queue.awaiting_envelopes_per_root.len(), + MAXIMUM_QUEUED_ENVELOPES + ); + + // One more should evict the oldest and insert the new one. + let overflow_root = Hash256::repeat_byte(0xff); + let msg = ReprocessQueueMessage::UnknownBlockForEnvelope(QueuedGossipEnvelope { + beacon_block_slot: Slot::new(1), + beacon_block_root: overflow_root, + process_fn: Box::pin(async {}), + }); + queue.handle_message(InboundEvent::Msg(msg)); + + // Queue should still be at capacity, with the new root present. + assert_eq!( + queue.awaiting_envelopes_per_root.len(), + MAXIMUM_QUEUED_ENVELOPES + ); + assert!( + queue + .awaiting_envelopes_per_root + .contains_key(&overflow_root) + ); + } } diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 3335315157..1f55d9a878 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -20,7 +20,9 @@ use beacon_chain::{ }; use beacon_chain::{ blob_verification::{GossipBlobError, GossipVerifiedBlob}, - payload_envelope_verification::gossip_verified_envelope::GossipVerifiedEnvelope, + payload_envelope_verification::{ + EnvelopeError, gossip_verified_envelope::GossipVerifiedEnvelope, + }, }; use beacon_processor::{Work, WorkEvent}; use lighthouse_network::{Client, MessageAcceptance, MessageId, PeerAction, PeerId, ReportSource}; @@ -49,8 +51,8 @@ use beacon_processor::work_reprocessing_queue::QueuedColumnReconstruction; use beacon_processor::{ DuplicateCache, GossipAggregatePackage, GossipAttestationBatch, work_reprocessing_queue::{ - QueuedAggregate, QueuedGossipBlock, QueuedLightClientUpdate, QueuedUnaggregate, - ReprocessQueueMessage, + QueuedAggregate, QueuedGossipBlock, QueuedGossipEnvelope, QueuedLightClientUpdate, + QueuedUnaggregate, ReprocessQueueMessage, }, }; @@ -3332,6 +3334,61 @@ impl NetworkBeaconProcessor { verified_envelope } + + Err(EnvelopeError::BlockRootUnknown { block_root }) => { + let envelope_slot = envelope.slot(); + + debug!( + ?block_root, + %envelope_slot, + "Envelope references unknown block, deferring to reprocess queue" + ); + + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + + let inner_self = self.clone(); + let chain = self.chain.clone(); + let process_fn = Box::pin(async move { + match chain.verify_envelope_for_gossip(envelope).await { + Ok(verified_envelope) => { + inner_self + .process_gossip_verified_execution_payload_envelope( + peer_id, + verified_envelope, + ) + .await; + } + Err(e) => { + debug!( + error = ?e, + "Deferred envelope failed verification" + ); + } + } + }); + + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(ReprocessQueueMessage::UnknownBlockForEnvelope( + QueuedGossipEnvelope { + beacon_block_slot: envelope_slot, + beacon_block_root: block_root, + process_fn, + }, + )), + }) + .is_err() + { + error!( + %envelope_slot, + ?block_root, + "Failed to defer envelope import" + ); + } + return None; + } // TODO(gloas) penalize peers accordingly Err(_) => return None, }; diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index 5fa8c729cb..c5ccbc2ae6 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -2090,3 +2090,8 @@ async fn test_data_columns_by_range_no_duplicates_with_skip_slots() { unique_roots.len(), ); } + +// TODO(ePBS): Add integration tests for envelope deferral (UnknownBlockForEnvelope): +// 1. Gossip envelope arrives before its block → queued via UnknownBlockForEnvelope +// 2. Block imported → envelope released and processed successfully +// 3. Timeout path → envelope released and re-verified From a965bfdf77a0b1a3cb2471b9df787edbe99779e8 Mon Sep 17 00:00:00 2001 From: Mac L Date: Wed, 18 Mar 2026 04:24:58 +0300 Subject: [PATCH 074/189] Remove `lighthouse/analysis` endpoints (#8968) Some of our custom `lighthouse/analysis` endpoints will require maintenance for the Gloas hard fork. We have decided instead to remove those endpoints. We don't utilize them internally and they have pretty limited utility and so we feel they are not worth maintaining. Remove `lighthouse/analysis/attestation_performance` and `lighthouse/analysis/block_packing_efficiency` endpoints. Co-Authored-By: Mac L --- .github/forbidden-files.txt | 4 + .../http_api/src/attestation_performance.rs | 217 --------- .../http_api/src/block_packing_efficiency.rs | 410 ------------------ beacon_node/http_api/src/lib.rs | 37 -- book/src/api_lighthouse.md | 120 ----- common/eth2/src/lighthouse.rs | 56 --- .../src/lighthouse/attestation_performance.rs | 39 -- .../lighthouse/block_packing_efficiency.rs | 34 -- testing/simulator/src/checks.rs | 46 +- 9 files changed, 26 insertions(+), 937 deletions(-) delete mode 100644 beacon_node/http_api/src/attestation_performance.rs delete mode 100644 beacon_node/http_api/src/block_packing_efficiency.rs delete mode 100644 common/eth2/src/lighthouse/attestation_performance.rs delete mode 100644 common/eth2/src/lighthouse/block_packing_efficiency.rs diff --git a/.github/forbidden-files.txt b/.github/forbidden-files.txt index a08a6b4e98..b070067350 100644 --- a/.github/forbidden-files.txt +++ b/.github/forbidden-files.txt @@ -6,5 +6,9 @@ beacon_node/beacon_chain/src/otb_verification_service.rs beacon_node/store/src/partial_beacon_state.rs beacon_node/store/src/consensus_context.rs beacon_node/beacon_chain/src/block_reward.rs +beacon_node/http_api/src/attestation_performance.rs +beacon_node/http_api/src/block_packing_efficiency.rs beacon_node/http_api/src/block_rewards.rs +common/eth2/src/lighthouse/attestation_performance.rs +common/eth2/src/lighthouse/block_packing_efficiency.rs common/eth2/src/lighthouse/block_rewards.rs diff --git a/beacon_node/http_api/src/attestation_performance.rs b/beacon_node/http_api/src/attestation_performance.rs deleted file mode 100644 index 05ed36e68b..0000000000 --- a/beacon_node/http_api/src/attestation_performance.rs +++ /dev/null @@ -1,217 +0,0 @@ -use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes}; -use eth2::lighthouse::{ - AttestationPerformance, AttestationPerformanceQuery, AttestationPerformanceStatistics, -}; -use state_processing::{ - BlockReplayError, BlockReplayer, per_epoch_processing::EpochProcessingSummary, -}; -use std::sync::Arc; -use types::{BeaconState, BeaconStateError, EthSpec, Hash256}; -use warp_utils::reject::{custom_bad_request, custom_server_error, unhandled_error}; - -const MAX_REQUEST_RANGE_EPOCHS: usize = 100; -const BLOCK_ROOT_CHUNK_SIZE: usize = 100; - -#[derive(Debug)] -// We don't use the inner values directly, but they're used in the Debug impl. -enum AttestationPerformanceError { - BlockReplay(#[allow(dead_code)] BlockReplayError), - BeaconState(#[allow(dead_code)] BeaconStateError), - UnableToFindValidator(#[allow(dead_code)] usize), -} - -impl From for AttestationPerformanceError { - fn from(e: BlockReplayError) -> Self { - Self::BlockReplay(e) - } -} - -impl From for AttestationPerformanceError { - fn from(e: BeaconStateError) -> Self { - Self::BeaconState(e) - } -} - -pub fn get_attestation_performance( - target: String, - query: AttestationPerformanceQuery, - chain: Arc>, -) -> Result, warp::Rejection> { - let spec = &chain.spec; - // We increment by 2 here so that when we build the state from the `prior_slot` it is - // still 1 epoch ahead of the first epoch we want to analyse. - // This ensures the `.is_previous_epoch_X` functions on `EpochProcessingSummary` return results - // for the correct epoch. - let start_epoch = query.start_epoch + 2; - let start_slot = start_epoch.start_slot(T::EthSpec::slots_per_epoch()); - let prior_slot = start_slot - 1; - - let end_epoch = query.end_epoch + 2; - let end_slot = end_epoch.end_slot(T::EthSpec::slots_per_epoch()); - - // Ensure end_epoch is smaller than the current epoch - 1. - let current_epoch = chain.epoch().map_err(unhandled_error)?; - if query.end_epoch >= current_epoch - 1 { - return Err(custom_bad_request(format!( - "end_epoch must be less than the current epoch - 1. current: {}, end: {}", - current_epoch, query.end_epoch - ))); - } - - // Check query is valid. - if start_epoch > end_epoch { - return Err(custom_bad_request(format!( - "start_epoch must not be larger than end_epoch. start: {}, end: {}", - query.start_epoch, query.end_epoch - ))); - } - - // The response size can grow exceptionally large therefore we should check that the - // query is within permitted bounds to prevent potential OOM errors. - if (end_epoch - start_epoch).as_usize() > MAX_REQUEST_RANGE_EPOCHS { - return Err(custom_bad_request(format!( - "end_epoch must not exceed start_epoch by more than {} epochs. start: {}, end: {}", - MAX_REQUEST_RANGE_EPOCHS, query.start_epoch, query.end_epoch - ))); - } - - // Either use the global validator set, or the specified index. - // - // Does no further validation of the indices, so in the event an index has not yet been - // activated or does not yet exist (according to the head state), it will return all fields as - // `false`. - let index_range = if target.to_lowercase() == "global" { - chain - .with_head(|head| Ok((0..head.beacon_state.validators().len() as u64).collect())) - .map_err(unhandled_error::)? - } else { - vec![target.parse::().map_err(|_| { - custom_bad_request(format!( - "Invalid validator index: {:?}", - target.to_lowercase() - )) - })?] - }; - - // Load block roots. - let mut block_roots: Vec = chain - .forwards_iter_block_roots_until(start_slot, end_slot) - .map_err(unhandled_error)? - .map(|res| res.map(|(root, _)| root)) - .collect::, _>>() - .map_err(unhandled_error)?; - block_roots.dedup(); - - // Load first block so we can get its parent. - let first_block_root = block_roots.first().ok_or_else(|| { - custom_server_error( - "No blocks roots could be loaded. Ensure the beacon node is synced.".to_string(), - ) - })?; - let first_block = chain - .get_blinded_block(first_block_root) - .and_then(|maybe_block| { - maybe_block.ok_or(BeaconChainError::MissingBeaconBlock(*first_block_root)) - }) - .map_err(unhandled_error)?; - - // Load the block of the prior slot which will be used to build the starting state. - let prior_block = chain - .get_blinded_block(&first_block.parent_root()) - .and_then(|maybe_block| { - maybe_block - .ok_or_else(|| BeaconChainError::MissingBeaconBlock(first_block.parent_root())) - }) - .map_err(unhandled_error)?; - - // Load state for block replay. - let state_root = prior_block.state_root(); - - // This branch is reached from the HTTP API. We assume the user wants - // to cache states so that future calls are faster. - let state = chain - .get_state(&state_root, Some(prior_slot), true) - .and_then(|maybe_state| maybe_state.ok_or(BeaconChainError::MissingBeaconState(state_root))) - .map_err(unhandled_error)?; - - // Allocate an AttestationPerformance vector for each validator in the range. - let mut perfs: Vec = - AttestationPerformance::initialize(index_range.clone()); - - let post_slot_hook = |state: &mut BeaconState, - summary: Option>, - _is_skip_slot: bool| - -> Result<(), AttestationPerformanceError> { - // If a `summary` was not output then an epoch boundary was not crossed - // so we move onto the next slot. - if let Some(summary) = summary { - for (position, i) in index_range.iter().enumerate() { - let index = *i as usize; - - let val = perfs - .get_mut(position) - .ok_or(AttestationPerformanceError::UnableToFindValidator(index))?; - - // We are two epochs ahead since the summary is generated for - // `state.previous_epoch()` then `summary.is_previous_epoch_X` functions return - // data for the epoch before that. - let epoch = state.previous_epoch().as_u64() - 1; - - let is_active = summary.is_active_unslashed_in_previous_epoch(index); - - let received_source_reward = summary.is_previous_epoch_source_attester(index)?; - - let received_head_reward = summary.is_previous_epoch_head_attester(index)?; - - let received_target_reward = summary.is_previous_epoch_target_attester(index)?; - - let inclusion_delay = summary - .previous_epoch_inclusion_info(index) - .map(|info| info.delay); - - let perf = AttestationPerformanceStatistics { - active: is_active, - head: received_head_reward, - target: received_target_reward, - source: received_source_reward, - delay: inclusion_delay, - }; - - val.epochs.insert(epoch, perf); - } - } - Ok(()) - }; - - // Initialize block replayer - let mut replayer = BlockReplayer::new(state, spec) - .no_state_root_iter() - .no_signature_verification() - .minimal_block_root_verification() - .post_slot_hook(Box::new(post_slot_hook)); - - // Iterate through block roots in chunks to reduce load on memory. - for block_root_chunks in block_roots.chunks(BLOCK_ROOT_CHUNK_SIZE) { - // Load blocks from the block root chunks. - let blocks = block_root_chunks - .iter() - .map(|root| { - chain - .get_blinded_block(root) - .and_then(|maybe_block| { - maybe_block.ok_or(BeaconChainError::MissingBeaconBlock(*root)) - }) - .map_err(unhandled_error) - }) - .collect::, _>>()?; - - // TODO(gloas): add payloads - replayer = replayer - .apply_blocks(blocks, vec![], None) - .map_err(|e| custom_server_error(format!("{:?}", e)))?; - } - - drop(replayer); - - Ok(perfs) -} diff --git a/beacon_node/http_api/src/block_packing_efficiency.rs b/beacon_node/http_api/src/block_packing_efficiency.rs deleted file mode 100644 index 725a0648a5..0000000000 --- a/beacon_node/http_api/src/block_packing_efficiency.rs +++ /dev/null @@ -1,410 +0,0 @@ -use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes}; -use eth2::lighthouse::{ - BlockPackingEfficiency, BlockPackingEfficiencyQuery, ProposerInfo, UniqueAttestation, -}; -use parking_lot::Mutex; -use state_processing::{ - BlockReplayError, BlockReplayer, per_epoch_processing::EpochProcessingSummary, -}; -use std::collections::{HashMap, HashSet}; -use std::marker::PhantomData; -use std::sync::Arc; -use types::{ - AttestationRef, BeaconCommittee, BeaconState, BeaconStateError, BlindedPayload, ChainSpec, - Epoch, EthSpec, Hash256, OwnedBeaconCommittee, RelativeEpoch, SignedBeaconBlock, Slot, -}; -use warp_utils::reject::{custom_bad_request, custom_server_error, unhandled_error}; - -/// Load blocks from block roots in chunks to reduce load on memory. -const BLOCK_ROOT_CHUNK_SIZE: usize = 100; - -#[derive(Debug)] -// We don't use the inner values directly, but they're used in the Debug impl. -enum PackingEfficiencyError { - BlockReplay(#[allow(dead_code)] BlockReplayError), - BeaconState(#[allow(dead_code)] BeaconStateError), - CommitteeStoreError(#[allow(dead_code)] Slot), - InvalidAttestationError, -} - -impl From for PackingEfficiencyError { - fn from(e: BlockReplayError) -> Self { - Self::BlockReplay(e) - } -} - -impl From for PackingEfficiencyError { - fn from(e: BeaconStateError) -> Self { - Self::BeaconState(e) - } -} - -struct CommitteeStore { - current_epoch_committees: Vec, - previous_epoch_committees: Vec, -} - -impl CommitteeStore { - fn new() -> Self { - CommitteeStore { - current_epoch_committees: Vec::new(), - previous_epoch_committees: Vec::new(), - } - } -} - -struct PackingEfficiencyHandler { - current_slot: Slot, - current_epoch: Epoch, - prior_skip_slots: u64, - available_attestations: HashSet, - included_attestations: HashMap, - committee_store: CommitteeStore, - _phantom: PhantomData, -} - -impl PackingEfficiencyHandler { - fn new( - start_epoch: Epoch, - starting_state: BeaconState, - spec: &ChainSpec, - ) -> Result { - let mut handler = PackingEfficiencyHandler { - current_slot: start_epoch.start_slot(E::slots_per_epoch()), - current_epoch: start_epoch, - prior_skip_slots: 0, - available_attestations: HashSet::new(), - included_attestations: HashMap::new(), - committee_store: CommitteeStore::new(), - _phantom: PhantomData, - }; - - handler.compute_epoch(start_epoch, &starting_state, spec)?; - Ok(handler) - } - - fn update_slot(&mut self, slot: Slot) { - self.current_slot = slot; - if slot % E::slots_per_epoch() == 0 { - self.current_epoch = Epoch::new(slot.as_u64() / E::slots_per_epoch()); - } - } - - fn prune_included_attestations(&mut self) { - let epoch = self.current_epoch; - self.included_attestations.retain(|x, _| { - x.slot >= Epoch::new(epoch.as_u64().saturating_sub(2)).start_slot(E::slots_per_epoch()) - }); - } - - fn prune_available_attestations(&mut self) { - let slot = self.current_slot; - self.available_attestations - .retain(|x| x.slot >= (slot.as_u64().saturating_sub(E::slots_per_epoch()))); - } - - fn apply_block( - &mut self, - block: &SignedBeaconBlock>, - ) -> Result { - let block_body = block.message().body(); - let attestations = block_body.attestations(); - - let mut attestations_in_block = HashMap::new(); - for attestation in attestations { - match attestation { - AttestationRef::Base(attn) => { - for (position, voted) in attn.aggregation_bits.iter().enumerate() { - if voted { - let unique_attestation = UniqueAttestation { - slot: attn.data.slot, - committee_index: attn.data.index, - committee_position: position, - }; - let inclusion_distance: u64 = block - .slot() - .as_u64() - .checked_sub(attn.data.slot.as_u64()) - .ok_or(PackingEfficiencyError::InvalidAttestationError)?; - - self.available_attestations.remove(&unique_attestation); - attestations_in_block.insert(unique_attestation, inclusion_distance); - } - } - } - AttestationRef::Electra(attn) => { - for (position, voted) in attn.aggregation_bits.iter().enumerate() { - if voted { - let unique_attestation = UniqueAttestation { - slot: attn.data.slot, - committee_index: attn.data.index, - committee_position: position, - }; - let inclusion_distance: u64 = block - .slot() - .as_u64() - .checked_sub(attn.data.slot.as_u64()) - .ok_or(PackingEfficiencyError::InvalidAttestationError)?; - - self.available_attestations.remove(&unique_attestation); - attestations_in_block.insert(unique_attestation, inclusion_distance); - } - } - } - } - } - - // Remove duplicate attestations as these yield no reward. - attestations_in_block.retain(|x, _| !self.included_attestations.contains_key(x)); - self.included_attestations - .extend(attestations_in_block.clone()); - - Ok(attestations_in_block.len()) - } - - fn add_attestations(&mut self, slot: Slot) -> Result<(), PackingEfficiencyError> { - let committees = self.get_committees_at_slot(slot)?; - for committee in committees { - for position in 0..committee.committee.len() { - let unique_attestation = UniqueAttestation { - slot, - committee_index: committee.index, - committee_position: position, - }; - self.available_attestations.insert(unique_attestation); - } - } - - Ok(()) - } - - fn compute_epoch( - &mut self, - epoch: Epoch, - state: &BeaconState, - spec: &ChainSpec, - ) -> Result<(), PackingEfficiencyError> { - // Free some memory by pruning old attestations from the included set. - self.prune_included_attestations(); - - let new_committees = if state.committee_cache_is_initialized(RelativeEpoch::Current) { - state - .get_beacon_committees_at_epoch(RelativeEpoch::Current)? - .into_iter() - .map(BeaconCommittee::into_owned) - .collect::>() - } else { - state - .initialize_committee_cache(epoch, spec)? - .get_all_beacon_committees()? - .into_iter() - .map(BeaconCommittee::into_owned) - .collect::>() - }; - - self.committee_store - .previous_epoch_committees - .clone_from(&self.committee_store.current_epoch_committees); - - self.committee_store.current_epoch_committees = new_committees; - - Ok(()) - } - - fn get_committees_at_slot( - &self, - slot: Slot, - ) -> Result, PackingEfficiencyError> { - let mut committees = Vec::new(); - - for committee in &self.committee_store.current_epoch_committees { - if committee.slot == slot { - committees.push(committee.clone()); - } - } - for committee in &self.committee_store.previous_epoch_committees { - if committee.slot == slot { - committees.push(committee.clone()); - } - } - - if committees.is_empty() { - return Err(PackingEfficiencyError::CommitteeStoreError(slot)); - } - - Ok(committees) - } -} - -pub fn get_block_packing_efficiency( - query: BlockPackingEfficiencyQuery, - chain: Arc>, -) -> Result, warp::Rejection> { - let spec = &chain.spec; - - let start_epoch = query.start_epoch; - let start_slot = start_epoch.start_slot(T::EthSpec::slots_per_epoch()); - let prior_slot = start_slot - 1; - - let end_epoch = query.end_epoch; - let end_slot = end_epoch.end_slot(T::EthSpec::slots_per_epoch()); - - // Check query is valid. - if start_epoch > end_epoch || start_epoch == 0 { - return Err(custom_bad_request(format!( - "invalid start and end epochs: {}, {}", - start_epoch, end_epoch - ))); - } - - let prior_epoch = start_epoch - 1; - let start_slot_of_prior_epoch = prior_epoch.start_slot(T::EthSpec::slots_per_epoch()); - - // Load block roots. - let mut block_roots: Vec = chain - .forwards_iter_block_roots_until(start_slot_of_prior_epoch, end_slot) - .map_err(unhandled_error)? - .collect::, _>>() - .map_err(unhandled_error)? - .iter() - .map(|(root, _)| *root) - .collect(); - block_roots.dedup(); - - let first_block_root = block_roots - .first() - .ok_or_else(|| custom_server_error("no blocks were loaded".to_string()))?; - - let first_block = chain - .get_blinded_block(first_block_root) - .and_then(|maybe_block| { - maybe_block.ok_or(BeaconChainError::MissingBeaconBlock(*first_block_root)) - }) - .map_err(unhandled_error)?; - - // Load state for block replay. - let starting_state_root = first_block.state_root(); - - // This branch is reached from the HTTP API. We assume the user wants - // to cache states so that future calls are faster. - let starting_state = chain - .get_state(&starting_state_root, Some(prior_slot), true) - .and_then(|maybe_state| { - maybe_state.ok_or(BeaconChainError::MissingBeaconState(starting_state_root)) - }) - .map_err(unhandled_error)?; - - // Initialize response vector. - let mut response = Vec::new(); - - // Initialize handler. - let handler = Arc::new(Mutex::new( - PackingEfficiencyHandler::new(prior_epoch, starting_state.clone(), spec) - .map_err(|e| custom_server_error(format!("{:?}", e)))?, - )); - - let pre_slot_hook = - |_, state: &mut BeaconState| -> Result<(), PackingEfficiencyError> { - // Add attestations to `available_attestations`. - handler.lock().add_attestations(state.slot())?; - Ok(()) - }; - - let post_slot_hook = |state: &mut BeaconState, - _summary: Option>, - is_skip_slot: bool| - -> Result<(), PackingEfficiencyError> { - handler.lock().update_slot(state.slot()); - - // Check if this a new epoch. - if state.slot() % T::EthSpec::slots_per_epoch() == 0 { - handler.lock().compute_epoch( - state.slot().epoch(T::EthSpec::slots_per_epoch()), - state, - spec, - )?; - } - - if is_skip_slot { - handler.lock().prior_skip_slots += 1; - } - - // Remove expired attestations. - handler.lock().prune_available_attestations(); - - Ok(()) - }; - - let pre_block_hook = |_state: &mut BeaconState, - block: &SignedBeaconBlock<_, BlindedPayload<_>>| - -> Result<(), PackingEfficiencyError> { - let slot = block.slot(); - - let block_message = block.message(); - // Get block proposer info. - let proposer_info = ProposerInfo { - validator_index: block_message.proposer_index(), - graffiti: block_message.body().graffiti().as_utf8_lossy(), - }; - - // Store the count of available attestations at this point. - // In the future it may be desirable to check that the number of available attestations - // does not exceed the maximum possible amount given the length of available committees. - let available_count = handler.lock().available_attestations.len(); - - // Get all attestations included in the block. - let included = handler.lock().apply_block(block)?; - - let efficiency = BlockPackingEfficiency { - slot, - block_hash: block.canonical_root(), - proposer_info, - available_attestations: available_count, - included_attestations: included, - prior_skip_slots: handler.lock().prior_skip_slots, - }; - - // Write to response. - if slot >= start_slot { - response.push(efficiency); - } - - handler.lock().prior_skip_slots = 0; - - Ok(()) - }; - - // Build BlockReplayer. - let mut replayer = BlockReplayer::new(starting_state, spec) - .no_state_root_iter() - .no_signature_verification() - .minimal_block_root_verification() - .pre_slot_hook(Box::new(pre_slot_hook)) - .post_slot_hook(Box::new(post_slot_hook)) - .pre_block_hook(Box::new(pre_block_hook)); - - // Iterate through the block roots, loading blocks in chunks to reduce load on memory. - for block_root_chunks in block_roots.chunks(BLOCK_ROOT_CHUNK_SIZE) { - // Load blocks from the block root chunks. - let blocks = block_root_chunks - .iter() - .map(|root| { - chain - .get_blinded_block(root) - .and_then(|maybe_block| { - maybe_block.ok_or(BeaconChainError::MissingBeaconBlock(*root)) - }) - .map_err(unhandled_error) - }) - .collect::, _>>()?; - - // TODO(gloas): add payloads - replayer = replayer - .apply_blocks(blocks, vec![], None) - .map_err(|e: PackingEfficiencyError| custom_server_error(format!("{:?}", e)))?; - } - - drop(replayer); - - Ok(response) -} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index fc92128c91..29e2d39aee 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -7,11 +7,9 @@ //! used for development. mod aggregate_attestation; -mod attestation_performance; mod attester_duties; mod beacon; mod block_id; -mod block_packing_efficiency; mod build_block_contents; mod builder_states; mod custody; @@ -3091,39 +3089,6 @@ pub fn serve( }, ); - // GET lighthouse/analysis/attestation_performance/{index} - let get_lighthouse_attestation_performance = warp::path("lighthouse") - .and(warp::path("analysis")) - .and(warp::path("attestation_performance")) - .and(warp::path::param::()) - .and(warp::query::()) - .and(warp::path::end()) - .and(task_spawner_filter.clone()) - .and(chain_filter.clone()) - .then( - |target, query, task_spawner: TaskSpawner, chain: Arc>| { - task_spawner.blocking_json_task(Priority::P1, move || { - attestation_performance::get_attestation_performance(target, query, chain) - }) - }, - ); - - // GET lighthouse/analysis/block_packing_efficiency - let get_lighthouse_block_packing_efficiency = warp::path("lighthouse") - .and(warp::path("analysis")) - .and(warp::path("block_packing_efficiency")) - .and(warp::query::()) - .and(warp::path::end()) - .and(task_spawner_filter.clone()) - .and(chain_filter.clone()) - .then( - |query, task_spawner: TaskSpawner, chain: Arc>| { - task_spawner.blocking_json_task(Priority::P1, move || { - block_packing_efficiency::get_block_packing_efficiency(query, chain) - }) - }, - ); - let get_events = eth_v1 .clone() .and(warp::path("events")) @@ -3359,12 +3324,10 @@ pub fn serve( .uor(get_lighthouse_database_info) .uor(get_lighthouse_database_invariants) .uor(get_lighthouse_custody_info) - .uor(get_lighthouse_attestation_performance) .uor(get_beacon_light_client_optimistic_update) .uor(get_beacon_light_client_finality_update) .uor(get_beacon_light_client_bootstrap) .uor(get_beacon_light_client_updates) - .uor(get_lighthouse_block_packing_efficiency) .uor(get_events) .uor(get_expected_withdrawals) .uor(lighthouse_log_events.boxed()) diff --git a/book/src/api_lighthouse.md b/book/src/api_lighthouse.md index 2fd7290cb2..c2e4fbdd5a 100644 --- a/book/src/api_lighthouse.md +++ b/book/src/api_lighthouse.md @@ -512,126 +512,6 @@ As all testnets and Mainnet have been merged, both values will be the same after } ``` -## `/lighthouse/analysis/attestation_performance/{index}` - -Fetch information about the attestation performance of a validator index or all validators for a -range of consecutive epochs. - -Two query parameters are required: - -- `start_epoch` (inclusive): the first epoch to compute attestation performance for. -- `end_epoch` (inclusive): the final epoch to compute attestation performance for. - -Example: - -```bash -curl -X GET "http://localhost:5052/lighthouse/analysis/attestation_performance/1?start_epoch=1&end_epoch=1" | jq -``` - -```json -[ - { - "index": 1, - "epochs": { - "1": { - "active": true, - "head": true, - "target": true, - "source": true, - "delay": 1 - } - } - } -] -``` - -Instead of specifying a validator index, you can specify the entire validator set by using `global`: - -```bash -curl -X GET "http://localhost:5052/lighthouse/analysis/attestation_performance/global?start_epoch=1&end_epoch=1" | jq -``` - -```json -[ - { - "index": 0, - "epochs": { - "1": { - "active": true, - "head": true, - "target": true, - "source": true, - "delay": 1 - } - } - }, - { - "index": 1, - "epochs": { - "1": { - "active": true, - "head": true, - "target": true, - "source": true, - "delay": 1 - } - } - }, - { - .. - } -] - -``` - -Caveats: - -- For maximum efficiency the start_epoch should satisfy `(start_epoch * slots_per_epoch) % slots_per_restore_point == 1`. - This is because the state *prior* to the `start_epoch` needs to be loaded from the database, - and loading a state on a boundary is most efficient. - -## `/lighthouse/analysis/block_packing` - -Fetch information about the block packing efficiency of blocks for a range of consecutive -epochs. - -Two query parameters are required: - -- `start_epoch` (inclusive): the epoch of the first block to compute packing efficiency for. -- `end_epoch` (inclusive): the epoch of the last block to compute packing efficiency for. - -```bash -curl -X GET "http://localhost:5052/lighthouse/analysis/block_packing_efficiency?start_epoch=1&end_epoch=1" | jq -``` - -An excerpt of the response looks like: - -```json -[ - { - "slot": "33", - "block_hash": "0xb20970bb97c6c6de6b1e2b689d6381dd15b3d3518fbaee032229495f963bd5da", - "proposer_info": { - "validator_index": 855, - "graffiti": "poapZoJ7zWNfK7F3nWjEausWVBvKa6gA" - }, - "available_attestations": 3805, - "included_attestations": 1143, - "prior_skip_slots": 1 - }, - { - .. - } -] -``` - -Caveats: - -- `start_epoch` must not be `0`. -- For maximum efficiency the `start_epoch` should satisfy `(start_epoch * slots_per_epoch) % slots_per_restore_point == 1`. - This is because the state *prior* to the `start_epoch` needs to be loaded from the database, and - loading a state on a boundary is most efficient. - ## `/lighthouse/logs` This is a Server Side Event subscription endpoint. This allows a user to read diff --git a/common/eth2/src/lighthouse.rs b/common/eth2/src/lighthouse.rs index 3c039b16b3..5ff7a7e0f0 100644 --- a/common/eth2/src/lighthouse.rs +++ b/common/eth2/src/lighthouse.rs @@ -1,7 +1,5 @@ //! This module contains endpoints that are non-standard and only available on Lighthouse servers. -mod attestation_performance; -mod block_packing_efficiency; mod custody; pub mod sync_state; @@ -15,12 +13,6 @@ use serde::{Deserialize, Serialize}; use ssz::four_byte_option_impl; use ssz_derive::{Decode, Encode}; -pub use attestation_performance::{ - AttestationPerformance, AttestationPerformanceQuery, AttestationPerformanceStatistics, -}; -pub use block_packing_efficiency::{ - BlockPackingEfficiency, BlockPackingEfficiencyQuery, ProposerInfo, UniqueAttestation, -}; pub use custody::CustodyInfo; // Define "legacy" implementations of `Option` which use four bytes for encoding the union @@ -310,52 +302,4 @@ impl BeaconNodeHttpClient { self.post_with_response(path, &req).await } - - /* - Analysis endpoints. - */ - - /// `GET` lighthouse/analysis/block_packing?start_epoch,end_epoch - pub async fn get_lighthouse_analysis_block_packing( - &self, - start_epoch: Epoch, - end_epoch: Epoch, - ) -> Result, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("lighthouse") - .push("analysis") - .push("block_packing_efficiency"); - - path.query_pairs_mut() - .append_pair("start_epoch", &start_epoch.to_string()) - .append_pair("end_epoch", &end_epoch.to_string()); - - self.get(path).await - } - - /// `GET` lighthouse/analysis/attestation_performance/{index}?start_epoch,end_epoch - pub async fn get_lighthouse_analysis_attestation_performance( - &self, - start_epoch: Epoch, - end_epoch: Epoch, - target: String, - ) -> Result, Error> { - let mut path = self.server.expose_full().clone(); - - path.path_segments_mut() - .map_err(|()| Error::InvalidUrl(self.server.clone()))? - .push("lighthouse") - .push("analysis") - .push("attestation_performance") - .push(&target); - - path.query_pairs_mut() - .append_pair("start_epoch", &start_epoch.to_string()) - .append_pair("end_epoch", &end_epoch.to_string()); - - self.get(path).await - } } diff --git a/common/eth2/src/lighthouse/attestation_performance.rs b/common/eth2/src/lighthouse/attestation_performance.rs deleted file mode 100644 index 5ce1d90a38..0000000000 --- a/common/eth2/src/lighthouse/attestation_performance.rs +++ /dev/null @@ -1,39 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use types::Epoch; - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] -pub struct AttestationPerformanceStatistics { - pub active: bool, - pub head: bool, - pub target: bool, - pub source: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub delay: Option, -} - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] -pub struct AttestationPerformance { - pub index: u64, - pub epochs: HashMap, -} - -impl AttestationPerformance { - pub fn initialize(indices: Vec) -> Vec { - let mut vec = Vec::with_capacity(indices.len()); - for index in indices { - vec.push(Self { - index, - ..Default::default() - }) - } - vec - } -} - -/// Query parameters for the `/lighthouse/analysis/attestation_performance` endpoint. -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct AttestationPerformanceQuery { - pub start_epoch: Epoch, - pub end_epoch: Epoch, -} diff --git a/common/eth2/src/lighthouse/block_packing_efficiency.rs b/common/eth2/src/lighthouse/block_packing_efficiency.rs deleted file mode 100644 index 0ad6f46031..0000000000 --- a/common/eth2/src/lighthouse/block_packing_efficiency.rs +++ /dev/null @@ -1,34 +0,0 @@ -use serde::{Deserialize, Serialize}; -use types::{Epoch, Hash256, Slot}; - -type CommitteePosition = usize; -type Committee = u64; -type ValidatorIndex = u64; - -#[derive(Debug, Default, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] -pub struct UniqueAttestation { - pub slot: Slot, - pub committee_index: Committee, - pub committee_position: CommitteePosition, -} -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] -pub struct ProposerInfo { - pub validator_index: ValidatorIndex, - pub graffiti: String, -} - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] -pub struct BlockPackingEfficiency { - pub slot: Slot, - pub block_hash: Hash256, - pub proposer_info: ProposerInfo, - pub available_attestations: usize, - pub included_attestations: usize, - pub prior_skip_slots: u64, -} - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)] -pub struct BlockPackingEfficiencyQuery { - pub start_epoch: Epoch, - pub end_epoch: Epoch, -} diff --git a/testing/simulator/src/checks.rs b/testing/simulator/src/checks.rs index 35200692c3..de202e5812 100644 --- a/testing/simulator/src/checks.rs +++ b/testing/simulator/src/checks.rs @@ -463,6 +463,9 @@ pub async fn reconnect_to_execution_layer( } /// Ensure all validators have attested correctly. +/// +/// Checks attestation rewards for head, target, and source. +/// A positive reward indicates a correct vote. pub async fn check_attestation_correctness( network: LocalNetwork, start_epoch: u64, @@ -476,54 +479,49 @@ pub async fn check_attestation_correctness( let remote_node = &network.remote_nodes()?[node_index]; - let results = remote_node - .get_lighthouse_analysis_attestation_performance( - Epoch::new(start_epoch), - Epoch::new(upto_epoch - 2), - "global".to_string(), - ) - .await - .map_err(|e| format!("Unable to get attestation performance: {e}"))?; - - let mut active_successes: f64 = 0.0; let mut head_successes: f64 = 0.0; let mut target_successes: f64 = 0.0; let mut source_successes: f64 = 0.0; - let mut total: f64 = 0.0; - for result in results { - for epochs in result.epochs.values() { + let end_epoch = upto_epoch + .checked_sub(2) + .ok_or_else(|| "upto_epoch must be >= 2 to have attestation rewards".to_string())?; + for epoch in start_epoch..=end_epoch { + let response = remote_node + .post_beacon_rewards_attestations(Epoch::new(epoch), &[]) + .await + .map_err(|e| format!("Unable to get attestation rewards for epoch {epoch}: {e}"))?; + + for reward in &response.data.total_rewards { total += 1.0; - if epochs.active { - active_successes += 1.0; - } - if epochs.head { + // A positive reward means the validator made a correct vote. + if reward.head > 0 { head_successes += 1.0; } - if epochs.target { + if reward.target > 0 { target_successes += 1.0; } - if epochs.source { + if reward.source > 0 { source_successes += 1.0; } } } - let active_percent = active_successes / total * 100.0; + + if total == 0.0 { + return Err("No attestation rewards data found".to_string()); + } + let head_percent = head_successes / total * 100.0; let target_percent = target_successes / total * 100.0; let source_percent = source_successes / total * 100.0; eprintln!("Total Attestations: {}", total); - eprintln!("Active: {}: {}%", active_successes, active_percent); eprintln!("Head: {}: {}%", head_successes, head_percent); eprintln!("Target: {}: {}%", target_successes, target_percent); eprintln!("Source: {}: {}%", source_successes, source_percent); - if active_percent < acceptable_attestation_performance { - return Err("Active percent was below required level".to_string()); - } if head_percent < acceptable_attestation_performance { return Err("Head percent was below required level".to_string()); } From 06025228ae489ac55137a238d0c16e9c76005da2 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 19 Mar 2026 20:09:13 +1100 Subject: [PATCH 075/189] Gloas cold DB (#8991) Closes: - https://github.com/sigp/lighthouse/issues/8958 - Update the `HotColdStore` to handle storage of cold states. - Update `BeaconSnapshot` to hold the execution envelope. This is required to make `chain_dump`-related checks sane, and will be generally useful (see: https://github.com/sigp/lighthouse/issues/8956). - Bug fix in the `BlockReplayer` for the case where the starting state is already `Full` (we should not try to apply another payload). This happens on the cold DB path because we try to replay from the closest cached state (which is often full). - Update `test_gloas_hot_state_hierarchy` to cover the cold DB migration. Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/beacon_chain.rs | 49 ++++++++++++++--- .../beacon_chain/src/beacon_snapshot.rs | 16 +++++- beacon_node/beacon_chain/src/builder.rs | 4 ++ .../beacon_chain/src/canonical_head.rs | 2 + .../beacon_chain/tests/block_verification.rs | 2 + beacon_node/beacon_chain/tests/store_tests.rs | 6 +- beacon_node/store/src/hot_cold_store.rs | 55 +++++++++++++++++-- beacon_node/store/src/invariants.rs | 29 +++++++--- .../state_processing/src/block_replayer.rs | 1 + 9 files changed, 139 insertions(+), 25 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 20af7b4630..c7009fc6dc 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -6689,6 +6689,9 @@ impl BeaconChain { let mut prev_block_root = None; let mut prev_beacon_state = None; + // Collect all blocks. + let mut blocks = vec![]; + for res in self.forwards_iter_block_roots(from_slot)? { let (beacon_block_root, _) = res?; @@ -6704,16 +6707,42 @@ impl BeaconChain { .ok_or_else(|| { Error::DBInconsistent(format!("Missing block {}", beacon_block_root)) })?; - let beacon_state_root = beacon_block.state_root(); + blocks.push((beacon_block_root, Arc::new(beacon_block))); + } + + // Collect states, using the next blocks to determine if states are full (have Gloas + // payloads). + for (i, (block_root, block)) in blocks.iter().enumerate() { + let (opt_envelope, state_root) = if block.fork_name_unchecked().gloas_enabled() { + let opt_envelope = self.store.get_payload_envelope(block_root)?.map(Arc::new); + + if let Some((_, next_block)) = blocks.get(i + 1) { + let block_hash = block.payload_bid_block_hash()?; + if next_block.is_parent_block_full(block_hash) { + let envelope = opt_envelope.ok_or_else(|| { + Error::DBInconsistent(format!("Missing envelope {block_root:?}")) + })?; + let state_root = envelope.message.state_root; + (Some(envelope), state_root) + } else { + (None, block.state_root()) + } + } else { + // TODO(gloas): should use fork choice/cached head for last block in sequence + opt_envelope + .as_ref() + .map_or((None, block.state_root()), |envelope| { + (Some(envelope.clone()), envelope.message.state_root) + }) + } + } else { + (None, block.state_root()) + }; - // This branch is reached from the HTTP API. We assume the user wants - // to cache states so that future calls are faster. let mut beacon_state = self .store - .get_state(&beacon_state_root, Some(beacon_block.slot()), true)? - .ok_or_else(|| { - Error::DBInconsistent(format!("Missing state {:?}", beacon_state_root)) - })?; + .get_state(&state_root, Some(block.slot()), true)? + .ok_or_else(|| Error::DBInconsistent(format!("Missing state {:?}", state_root)))?; // This beacon state might come from the freezer DB, which means it could have pending // updates or lots of untethered memory. We rebase it on the previous state in order to @@ -6726,12 +6755,14 @@ impl BeaconChain { prev_beacon_state = Some(beacon_state.clone()); let snapshot = BeaconSnapshot { - beacon_block: Arc::new(beacon_block), - beacon_block_root, + beacon_block: block.clone(), + execution_envelope: opt_envelope, + beacon_block_root: *block_root, beacon_state, }; dump.push(snapshot); } + Ok(dump) } diff --git a/beacon_node/beacon_chain/src/beacon_snapshot.rs b/beacon_node/beacon_chain/src/beacon_snapshot.rs index e9fde48ac6..566713e3f3 100644 --- a/beacon_node/beacon_chain/src/beacon_snapshot.rs +++ b/beacon_node/beacon_chain/src/beacon_snapshot.rs @@ -2,7 +2,7 @@ use serde::Serialize; use std::sync::Arc; use types::{ AbstractExecPayload, BeaconState, EthSpec, FullPayload, Hash256, SignedBeaconBlock, - SignedBlindedBeaconBlock, + SignedBlindedBeaconBlock, SignedExecutionPayloadEnvelope, }; /// Represents some block and its associated state. Generally, this will be used for tracking the @@ -10,6 +10,7 @@ use types::{ #[derive(Clone, Serialize, PartialEq, Debug)] pub struct BeaconSnapshot = FullPayload> { pub beacon_block: Arc>, + pub execution_envelope: Option>>, pub beacon_block_root: Hash256, pub beacon_state: BeaconState, } @@ -31,33 +32,42 @@ impl> BeaconSnapshot { /// Create a new checkpoint. pub fn new( beacon_block: Arc>, + execution_envelope: Option>>, beacon_block_root: Hash256, beacon_state: BeaconState, ) -> Self { Self { beacon_block, + execution_envelope, beacon_block_root, beacon_state, } } - /// Returns the state root from `self.beacon_block`. + /// Returns the state root from `self.beacon_block` or `self.execution_envelope` as + /// appropriate. /// /// ## Caution /// /// It is not strictly enforced that `root(self.beacon_state) == self.beacon_state_root()`. pub fn beacon_state_root(&self) -> Hash256 { - self.beacon_block.message().state_root() + if let Some(ref envelope) = self.execution_envelope { + envelope.message.state_root + } else { + self.beacon_block.message().state_root() + } } /// Update all fields of the checkpoint. pub fn update( &mut self, beacon_block: Arc>, + execution_envelope: Option>>, beacon_block_root: Hash256, beacon_state: BeaconState, ) { self.beacon_block = beacon_block; + self.execution_envelope = execution_envelope; self.beacon_block_root = beacon_block_root; self.beacon_state = beacon_state; } diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 59fa5ec9ec..7eb92060a2 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -358,6 +358,7 @@ where Ok(( BeaconSnapshot { beacon_block_root, + execution_envelope: None, beacon_block: Arc::new(beacon_block), beacon_state, }, @@ -616,8 +617,10 @@ where .map_err(|e| format!("Failed to initialize data column info: {:?}", e))?, ); + // TODO(gloas): add check that checkpoint state is Pending let snapshot = BeaconSnapshot { beacon_block_root: weak_subj_block_root, + execution_envelope: None, beacon_block: Arc::new(weak_subj_block), beacon_state: weak_subj_state, }; @@ -800,6 +803,7 @@ where let mut head_snapshot = BeaconSnapshot { beacon_block_root: head_block_root, + execution_envelope: None, beacon_block: Arc::new(head_block), beacon_state: head_state, }; diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index fd060e2b59..0faddd1792 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -319,6 +319,7 @@ impl CanonicalHead { let snapshot = BeaconSnapshot { beacon_block_root, + execution_envelope: None, beacon_block: Arc::new(beacon_block), beacon_state, }; @@ -695,6 +696,7 @@ impl BeaconChain { BeaconSnapshot { beacon_block: Arc::new(beacon_block), + execution_envelope: None, beacon_block_root: new_view.head_block_root, beacon_state, } diff --git a/beacon_node/beacon_chain/tests/block_verification.rs b/beacon_node/beacon_chain/tests/block_verification.rs index 8981b20a55..2bb60f111a 100644 --- a/beacon_node/beacon_chain/tests/block_verification.rs +++ b/beacon_node/beacon_chain/tests/block_verification.rs @@ -77,8 +77,10 @@ async fn get_chain_segment() -> (Vec>, Vec>(); + let some_validators = (0..LOW_VALIDATOR_COUNT).collect::>(); let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); @@ -5886,6 +5889,7 @@ async fn test_gloas_hot_state_hierarchy() { // Verify chain dump and iterators work with Gloas states. check_chain_dump(&harness, num_blocks + 1); check_iterators(&harness); + check_db_invariants(&harness); } /// Check that the HotColdDB's split_slot is equal to the start slot of the last finalized epoch. diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index 428086c464..8ef91b3c74 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -1906,6 +1906,51 @@ impl, Cold: ItemStore> HotColdDB } } + /// Recompute the payload status for a state at `slot` that is stored in the cold DB. + /// + /// This function returns an error for any `slot` that is outside the range of slots stored in + /// the freezer DB. + /// + /// For all slots prior to Gloas, it returns `Pending`. + /// + /// For post-Gloas slots the algorithm is: + /// + /// 1. Load the most recently applied block at `slot` (may not be from `slot` in case of a skip) + /// 2. Load the canonical `state_root` at the slot of the block. If this `state_root` matches + /// the one in the block then we know the state at *that* slot is canonically empty (no + /// payload). Conversely, if it is different, we know that the block's slot is full (assuming + /// no database corruption). + /// 3. The payload status of `slot` is the same as the payload status of `block.slot()`, because + /// we only care about whether a beacon block or payload was applied most recently, and + /// `block` is by definition the most-recently-applied block. + /// + /// All of this mucking around could be avoided if we do a schema migration to record the + /// payload status in the database. For now, this is simpler. + fn get_cold_state_payload_status(&self, slot: Slot) -> Result { + // Pre-Gloas states are always `Pending`. + if !self.spec.fork_name_at_slot::(slot).gloas_enabled() { + return Ok(StatePayloadStatus::Pending); + } + + let block_root = self + .get_cold_block_root(slot)? + .ok_or(HotColdDBError::MissingFrozenBlock(slot))?; + + let block = self + .get_blinded_block(&block_root)? + .ok_or(Error::MissingBlock(block_root))?; + + let state_root = self + .get_cold_state_root(block.slot())? + .ok_or(HotColdDBError::MissingRestorePointState(block.slot()))?; + + if block.state_root() != state_root { + Ok(StatePayloadStatus::Full) + } else { + Ok(StatePayloadStatus::Pending) + } + } + fn load_hot_hdiff_buffer(&self, state_root: Hash256) -> Result { if let Some(buffer) = self .state_cache @@ -2454,8 +2499,7 @@ impl, Cold: ItemStore> HotColdDB self.forwards_state_roots_iterator_until(base_state.slot(), slot, || { Err(Error::StateShouldNotBeRequired(slot)) })?; - // TODO(gloas): calculate correct payload status for cold states - let payload_status = StatePayloadStatus::Pending; + let payload_status = self.get_cold_state_payload_status(slot)?; let state = self.replay_blocks( base_state, blocks, @@ -2591,9 +2635,10 @@ impl, Cold: ItemStore> HotColdDB { return Ok((blocks, vec![])); } - // TODO(gloas): wire this up - let end_block_root = Hash256::ZERO; - let desired_payload_status = StatePayloadStatus::Pending; + let end_block_root = self + .get_cold_block_root(end_slot)? + .ok_or(HotColdDBError::MissingFrozenBlock(end_slot))?; + let desired_payload_status = self.get_cold_state_payload_status(end_slot)?; let envelopes = self.load_payload_envelopes_for_blocks( &blocks, end_block_root, diff --git a/beacon_node/store/src/invariants.rs b/beacon_node/store/src/invariants.rs index eb5232d344..d251fb8800 100644 --- a/beacon_node/store/src/invariants.rs +++ b/beacon_node/store/src/invariants.rs @@ -319,6 +319,10 @@ impl, Cold: ItemStore> HotColdDB .spec .fulu_fork_epoch .map(|epoch| epoch.start_slot(E::slots_per_epoch())); + let gloas_fork_slot = self + .spec + .gloas_fork_epoch + .map(|epoch| epoch.start_slot(E::slots_per_epoch())); let oldest_blob_slot = self.get_blob_info().oldest_blob_slot; let oldest_data_column_slot = self.get_data_column_info().oldest_data_column_slot; @@ -343,17 +347,28 @@ impl, Cold: ItemStore> HotColdDB } // Invariant 5: execution payload consistency. - // TODO(gloas): reconsider this invariant if check_payloads && let Some(bellatrix_slot) = bellatrix_fork_slot && slot >= bellatrix_slot - && !self.execution_payload_exists(&block_root)? - && !self.payload_envelope_exists(&block_root)? { - result.add_violation(InvariantViolation::ExecutionPayloadMissing { - block_root, - slot, - }); + if let Some(gloas_slot) = gloas_fork_slot + && slot >= gloas_slot + { + // For Gloas there is never a true payload stored at slot 0. + // TODO(gloas): still need to account for non-canonical payloads once pruning + // is implemented. + if slot != 0 && !self.payload_envelope_exists(&block_root)? { + result.add_violation(InvariantViolation::ExecutionPayloadMissing { + block_root, + slot, + }); + } + } else if !self.execution_payload_exists(&block_root)? { + result.add_violation(InvariantViolation::ExecutionPayloadMissing { + block_root, + slot, + }); + } } // Invariant 6: blob sidecar consistency. diff --git a/consensus/state_processing/src/block_replayer.rs b/consensus/state_processing/src/block_replayer.rs index a10d6179fe..f5f06d1cb9 100644 --- a/consensus/state_processing/src/block_replayer.rs +++ b/consensus/state_processing/src/block_replayer.rs @@ -313,6 +313,7 @@ where // indicates that the parent is full (and it hasn't already been applied). state_root = if block.fork_name_unchecked().gloas_enabled() && self.state.slot() == self.state.latest_block_header().slot + && self.state.payload_status() == StatePayloadStatus::Pending { let latest_bid_block_hash = self .state From 54d62d0017c772e58bc752dea325f43c96a2571d Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Thu, 19 Mar 2026 12:36:36 +0100 Subject: [PATCH 076/189] fix: update kurtosis apt source to sdk.kurtosis.com (#9000) Co-Authored-By: Barnabas Busa --- .github/workflows/local-testnet.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/local-testnet.yml b/.github/workflows/local-testnet.yml index 9992273e0a..308ddcf819 100644 --- a/.github/workflows/local-testnet.yml +++ b/.github/workflows/local-testnet.yml @@ -38,7 +38,7 @@ jobs: - name: Install Kurtosis run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + echo "deb [trusted=yes] https://sdk.kurtosis.com/kurtosis-cli-release-artifacts/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list sudo apt update sudo apt install -y kurtosis-cli kurtosis analytics disable @@ -106,7 +106,7 @@ jobs: - name: Install Kurtosis run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + echo "deb [trusted=yes] https://sdk.kurtosis.com/kurtosis-cli-release-artifacts/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list sudo apt update sudo apt install -y kurtosis-cli kurtosis analytics disable @@ -142,7 +142,7 @@ jobs: - name: Install Kurtosis run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + echo "deb [trusted=yes] https://sdk.kurtosis.com/kurtosis-cli-release-artifacts/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list sudo apt update sudo apt install -y kurtosis-cli kurtosis analytics disable @@ -185,7 +185,7 @@ jobs: - name: Install Kurtosis run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + echo "deb [trusted=yes] https://sdk.kurtosis.com/kurtosis-cli-release-artifacts/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list sudo apt update sudo apt install -y kurtosis-cli kurtosis analytics disable @@ -227,7 +227,7 @@ jobs: - name: Install Kurtosis run: | - echo "deb [trusted=yes] https://apt.fury.io/kurtosis-tech/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list + echo "deb [trusted=yes] https://sdk.kurtosis.com/kurtosis-cli-release-artifacts/ /" | sudo tee /etc/apt/sources.list.d/kurtosis.list sudo apt update sudo apt install -y kurtosis-cli kurtosis analytics disable From 8f9c1ca9ca2e6bbfdadaa8f70f842dcc55cbc08e Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Sat, 21 Mar 2026 20:45:20 +1100 Subject: [PATCH 077/189] Bump rustls and ignore unpatched version due to Warp (#9010) Fix the cargo-audit failure caused by: - https://rustsec.org/advisories/RUSTSEC-2026-0049 We can't fix it completely yet because `warp 0.3` is keeping us on an old version of `rustls`. Mac's PR here will fix it: - https://github.com/sigp/lighthouse/pull/9001 Co-Authored-By: Michael Sproul --- Cargo.lock | 10 +++++----- Makefile | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cba93f2fd5..72ec9c6e4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5306,7 +5306,7 @@ dependencies = [ "rcgen", "ring", "rustls 0.23.35", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.10", "thiserror 2.0.17", "x509-parser", "yasna", @@ -7196,7 +7196,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7740,7 +7740,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.8", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] @@ -7789,9 +7789,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", diff --git a/Makefile b/Makefile index 9d08c3ebe1..d55fcd7e87 100644 --- a/Makefile +++ b/Makefile @@ -324,7 +324,7 @@ install-audit: cargo install --force cargo-audit audit-CI: - cargo audit + cargo audit --ignore RUSTSEC-2026-0049 # Runs cargo deny (check for banned crates, duplicate versions, and source restrictions) deny: install-deny deny-CI From b3d51858938283604651bdda1a41482586faeee9 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Mon, 23 Mar 2026 06:46:39 +0900 Subject: [PATCH 078/189] Carry forward withdrawals from the current `BeaconState` when a parent envelope is missed (#9014) Co-Authored-By: Eitan Seri- Levi --- beacon_node/beacon_chain/src/block_production/gloas.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 5d7d99b5bd..2fc4fb51f7 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -763,8 +763,12 @@ fn get_execution_payload_gloas( let latest_execution_block_hash = *state.latest_block_hash()?; let latest_gas_limit = state.latest_execution_payload_bid()?.gas_limit; - let withdrawals = - Withdrawals::::from(get_expected_withdrawals(state, spec)?).into(); + let withdrawals = if state.is_parent_block_full() { + Withdrawals::::from(get_expected_withdrawals(state, spec)?).into() + } else { + // If the previous payload was missed, carry forward the withdrawals from the state. + state.payload_expected_withdrawals()?.to_vec() + }; // Spawn a task to obtain the execution payload from the EL via a series of async calls. The // `join_handle` can be used to await the result of the function. From e21053311d2b1aa38508fe7adedf886b264c88ef Mon Sep 17 00:00:00 2001 From: antondlr Date: Mon, 23 Mar 2026 07:25:06 +0100 Subject: [PATCH 079/189] Scrap redundant docker builds on releases (#8999) Our release workflow is pretty inefficient and slow. This PR aims to consolidate and cut down on duplicate tasks. 1) We now run the whole build process both on pushing to the `stable` branch and pushing a version tag. A quick win is to not fire off separate builds. ~~2) The Docker release workflow could re-use the binaries being built instead of doing its own cross-compilation. ~~ we won't take this on _right now_ Co-Authored-By: antondlr Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul --- .github/workflows/docker-reproducible.yml | 15 +++++++++++---- .github/workflows/docker.yml | 15 +++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/workflows/docker-reproducible.yml b/.github/workflows/docker-reproducible.yml index f3479e9468..7e46fc691b 100644 --- a/.github/workflows/docker-reproducible.yml +++ b/.github/workflows/docker-reproducible.yml @@ -4,7 +4,6 @@ on: push: branches: - unstable - - stable tags: - v* workflow_dispatch: # allows manual triggering for testing purposes and skips publishing an image @@ -25,9 +24,6 @@ jobs: if [[ "${{ github.ref }}" == refs/tags/* ]]; then # It's a tag (e.g., v1.2.3) VERSION="${GITHUB_REF#refs/tags/}" - elif [[ "${{ github.ref }}" == refs/heads/stable ]]; then - # stable branch -> latest - VERSION="latest" elif [[ "${{ github.ref }}" == refs/heads/unstable ]]; then # unstable branch -> latest-unstable VERSION="latest-unstable" @@ -174,3 +170,14 @@ jobs: ${IMAGE_NAME}:${VERSION}-arm64 docker manifest push ${IMAGE_NAME}:${VERSION} + + # For version tags, also create/update the latest tag to keep stable up to date + # Only create latest tag for proper release versions (e.g. v1.2.3, not v1.2.3-alpha) + if [[ "${GITHUB_REF}" == refs/tags/* ]] && [[ "${VERSION}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}$ ]]; then + docker manifest create \ + ${IMAGE_NAME}:latest \ + ${IMAGE_NAME}:${VERSION}-amd64 \ + ${IMAGE_NAME}:${VERSION}-arm64 + + docker manifest push ${IMAGE_NAME}:latest + fi diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 415f4db0e6..e3f6e5d8b8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,7 +4,6 @@ on: push: branches: - unstable - - stable tags: - v* @@ -28,11 +27,6 @@ jobs: extract-version: runs-on: ubuntu-22.04 steps: - - name: Extract version (if stable) - if: github.event.ref == 'refs/heads/stable' - run: | - echo "VERSION=latest" >> $GITHUB_ENV - echo "VERSION_SUFFIX=" >> $GITHUB_ENV - name: Extract version (if unstable) if: github.event.ref == 'refs/heads/unstable' run: | @@ -159,7 +153,16 @@ jobs: - name: Create and push multiarch manifests run: | + # Create the main tag (versioned for releases, latest-unstable for unstable) docker buildx imagetools create -t ${{ github.repository_owner}}/${{ matrix.binary }}:${VERSION}${VERSION_SUFFIX} \ ${{ github.repository_owner}}/${{ matrix.binary }}:${VERSION}-arm64${VERSION_SUFFIX} \ ${{ github.repository_owner}}/${{ matrix.binary }}:${VERSION}-amd64${VERSION_SUFFIX}; + # For version tags, also create/update the latest tag to keep stable up to date + # Only create latest tag for proper release versions (e.g. v1.2.3, not v1.2.3-alpha) + if [[ "${GITHUB_REF}" == refs/tags/* ]] && [[ "${VERSION}" =~ ^v[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}$ ]]; then + docker buildx imagetools create -t ${{ github.repository_owner}}/${{ matrix.binary }}:latest \ + ${{ github.repository_owner}}/${{ matrix.binary }}:${VERSION}-arm64${VERSION_SUFFIX} \ + ${{ github.repository_owner}}/${{ matrix.binary }}:${VERSION}-amd64${VERSION_SUFFIX}; + fi + From 7ffc637eefd38622e7940010a0e557d3520f9326 Mon Sep 17 00:00:00 2001 From: Alleysira <56925051+Alleysira@users.noreply.github.com> Date: Tue, 24 Mar 2026 06:07:37 +0800 Subject: [PATCH 080/189] fix(network): set ENR nfd to zero bytes when next fork is unknown (#9009) Fixes #8996 When no next fork is scheduled, the `nfd` field in the ENR was set to the current fork digest via `.unwrap_or_else(|| ctx.fork_context.current_fork_digest())`. According to the [spec](https://github.com/ethereum/consensus-specs/blob/1baa05e71148b0975e28918ac6022d2256b56f4a/specs/fulu/p2p-interface.md?plain=1#L636-L637), `nfd` should be zero-valued bytes when the next fork is unknown. Co-Authored-By: Alleysira <1367108378@qq.com> Co-Authored-By: Alleysira <56925051+Alleysira@users.noreply.github.com> Co-Authored-By: Pawan Dhananjay --- beacon_node/lighthouse_network/src/service/mod.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/beacon_node/lighthouse_network/src/service/mod.rs b/beacon_node/lighthouse_network/src/service/mod.rs index 94e0ad0710..184a334591 100644 --- a/beacon_node/lighthouse_network/src/service/mod.rs +++ b/beacon_node/lighthouse_network/src/service/mod.rs @@ -187,10 +187,9 @@ impl Network { // set up a collection of variables accessible outside of the network crate // Create an ENR or load from disk if appropriate - let next_fork_digest = ctx - .fork_context - .next_fork_digest() - .unwrap_or_else(|| ctx.fork_context.current_fork_digest()); + // Per [spec](https://github.com/ethereum/consensus-specs/blob/1baa05e71148b0975e28918ac6022d2256b56f4a/specs/fulu/p2p-interface.md?plain=1#L636-L637) + // `nfd` must be zero-valued when no next fork is scheduled. + let next_fork_digest = ctx.fork_context.next_fork_digest().unwrap_or_default(); let advertised_cgc = config .advertise_false_custody_group_count From c451ae763c975058cde26f4a50b5e1d1c9665163 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 24 Mar 2026 12:43:19 +1100 Subject: [PATCH 081/189] Use BTreeMap for state.validators pending updates (#9017) Closes: - https://github.com/sigp/lighthouse/issues/9003 Milhouse `List`s use a map in front of the binary tree to cache updates. Ever since we adopted Milhouse, we've been using `VecMap`, which is essentially `Vec>`. Turns out, when you've got 2M indices and only 2 non-`None` entries (changes), this is inefficient. Milhouse is generic in the choice of map (`U: UpdateMap`) and has always supported `BTreeMap`, so this PR switches us over to `BTreeMap`. In previous benchmarks (years ago) it had been slower than `VecMap`, but now it is vastly superior. Co-Authored-By: Michael Sproul --- .../src/per_epoch_processing/epoch_processing_summary.rs | 6 +++--- consensus/types/src/state/beacon_state.rs | 6 ++++-- consensus/types/src/state/mod.rs | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/consensus/state_processing/src/per_epoch_processing/epoch_processing_summary.rs b/consensus/state_processing/src/per_epoch_processing/epoch_processing_summary.rs index a818e08775..3c043a65f2 100644 --- a/consensus/state_processing/src/per_epoch_processing/epoch_processing_summary.rs +++ b/consensus/state_processing/src/per_epoch_processing/epoch_processing_summary.rs @@ -4,8 +4,8 @@ use milhouse::List; use std::sync::Arc; use types::{ BeaconStateError, Epoch, EthSpec, ParticipationFlags, ProgressiveBalancesCache, SyncCommittee, - Validator, consts::altair::{TIMELY_HEAD_FLAG_INDEX, TIMELY_SOURCE_FLAG_INDEX, TIMELY_TARGET_FLAG_INDEX}, + state::Validators, }; /// Provides a summary of validator participation during the epoch. @@ -26,7 +26,7 @@ pub enum EpochProcessingSummary { #[derive(PartialEq, Debug)] pub struct ParticipationEpochSummary { /// Copy of the validator registry prior to mutation. - validators: List, + validators: Validators, /// Copy of the participation flags for the previous epoch. previous_epoch_participation: List, /// Copy of the participation flags for the current epoch. @@ -37,7 +37,7 @@ pub struct ParticipationEpochSummary { impl ParticipationEpochSummary { pub fn new( - validators: List, + validators: Validators, previous_epoch_participation: List, current_epoch_participation: List, previous_epoch: Epoch, diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 3f8fa4cfff..9c7b8285d4 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use ssz::{Decode, DecodeError, Encode, ssz_encode}; use ssz_derive::{Decode, Encode}; use ssz_types::{BitVector, FixedVector}; +use std::collections::BTreeMap; use superstruct::superstruct; use swap_or_not_shuffle::compute_shuffled_index; use test_random_derive::TestRandom; @@ -58,7 +59,8 @@ pub const CACHED_EPOCHS: usize = 3; const MAX_RANDOM_BYTE: u64 = (1 << 8) - 1; const MAX_RANDOM_VALUE: u64 = (1 << 16) - 1; -pub type Validators = List::ValidatorRegistryLimit>; +pub type Validators = + List::ValidatorRegistryLimit, BTreeMap>; pub type Balances = List::ValidatorRegistryLimit>; #[derive(Debug, PartialEq, Clone)] @@ -453,7 +455,7 @@ where // Registry #[compare_fields(as_iter)] #[test_random(default)] - pub validators: List, + pub validators: Validators, #[serde(with = "ssz_types::serde_utils::quoted_u64_var_list")] #[compare_fields(as_iter)] #[test_random(default)] diff --git a/consensus/types/src/state/mod.rs b/consensus/types/src/state/mod.rs index 309796d359..321c66671a 100644 --- a/consensus/types/src/state/mod.rs +++ b/consensus/types/src/state/mod.rs @@ -17,7 +17,7 @@ pub use balance::Balance; pub use beacon_state::{ BeaconState, BeaconStateAltair, BeaconStateBase, BeaconStateBellatrix, BeaconStateCapella, BeaconStateDeneb, BeaconStateElectra, BeaconStateError, BeaconStateFulu, BeaconStateGloas, - BeaconStateHash, BeaconStateRef, CACHED_EPOCHS, + BeaconStateHash, BeaconStateRef, CACHED_EPOCHS, Validators, }; pub use committee_cache::{ CommitteeCache, compute_committee_index_in_epoch, compute_committee_range_in_epoch, From 91c25794fe15af6b3097a0670c77cb218fb8cff4 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:50:14 -0500 Subject: [PATCH 082/189] Schedule Fulu fork for Gnosis mainnet (#9007) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Michael Sproul --- .../built_in_network_configs/gnosis/config.yaml | 7 ++++++- consensus/types/src/core/chain_spec.rs | 5 ++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/common/eth2_network_config/built_in_network_configs/gnosis/config.yaml b/common/eth2_network_config/built_in_network_configs/gnosis/config.yaml index 34313aa393..d27f7a09e8 100644 --- a/common/eth2_network_config/built_in_network_configs/gnosis/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/gnosis/config.yaml @@ -46,7 +46,7 @@ ELECTRA_FORK_VERSION: 0x05000064 ELECTRA_FORK_EPOCH: 1337856 # 2025-04-30T14:03:40.000Z # Fulu FULU_FORK_VERSION: 0x06000064 -FULU_FORK_EPOCH: 18446744073709551615 +FULU_FORK_EPOCH: 1714688 # Tue Apr 14 2026 12:06:20 GMT+0000 # Gloas GLOAS_FORK_VERSION: 0x07000064 GLOAS_FORK_EPOCH: 18446744073709551615 @@ -156,6 +156,11 @@ NUMBER_OF_CUSTODY_GROUPS: 128 DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 SAMPLES_PER_SLOT: 8 CUSTODY_REQUIREMENT: 4 +VALIDATOR_CUSTODY_REQUIREMENT: 8 +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 +# `2**14` (= 16384 epochs, ~15 days) +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 16384 MAX_BLOBS_PER_BLOCK_FULU: 12 # Gloas \ No newline at end of file diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index 6d25e3baf4..f505c9f0d9 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -1604,7 +1604,7 @@ impl ChainSpec { * Fulu hard fork params */ fulu_fork_version: [0x06, 0x00, 0x00, 0x64], - fulu_fork_epoch: None, + fulu_fork_epoch: Some(Epoch::new(1714688)), custody_requirement: 4, number_of_custody_groups: 128, data_column_sidecar_subnet_count: 128, @@ -1673,8 +1673,7 @@ impl ChainSpec { * Networking Fulu specific */ blob_schedule: BlobSchedule::default(), - min_epochs_for_data_column_sidecars_requests: - default_min_epochs_for_data_column_sidecars_requests(), + min_epochs_for_data_column_sidecars_requests: 16384, max_data_columns_by_root_request: default_data_columns_by_root_request(), /* From 5d6f787a06c3fb5c51205a91eef94afb0f0157f6 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 23 Mar 2026 12:44:43 +1100 Subject: [PATCH 083/189] Bump quinn --- Cargo.lock | 17 ++++++++++------- Cargo.toml | 1 - 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72ec9c6e4e..96e84ed73e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7149,7 +7149,8 @@ dependencies = [ [[package]] name = "quinn" version = "0.11.9" -source = "git+https://github.com/sigp/quinn?rev=59af87979c8411864c1cb68613222f54ed2930a7#59af87979c8411864c1cb68613222f54ed2930a7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", @@ -7159,7 +7160,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.35", - "socket2 0.5.10", + "socket2 0.6.1", "thiserror 2.0.17", "tokio", "tracing", @@ -7168,8 +7169,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" -source = "git+https://github.com/sigp/quinn?rev=59af87979c8411864c1cb68613222f54ed2930a7#59af87979c8411864c1cb68613222f54ed2930a7" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -7189,14 +7191,15 @@ dependencies = [ [[package]] name = "quinn-udp" version = "0.5.14" -source = "git+https://github.com/sigp/quinn?rev=59af87979c8411864c1cb68613222f54ed2930a7#59af87979c8411864c1cb68613222f54ed2930a7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.1", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f483e998c9..63cfb39ba4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -280,7 +280,6 @@ debug = true [patch.crates-io] quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "87c4ccb9bb2af494de375f5f6c62850badd26304" } yamux = { git = "https://github.com/sigp/rust-yamux", rev = "29efa6aebd4bdfcb16bfb21969ec0c785e570b74" } -quinn = { git = "https://github.com/sigp/quinn", rev = "59af87979c8411864c1cb68613222f54ed2930a7" } [patch."https://github.com/libp2p/rust-libp2p.git"] libp2p = { git = "https://github.com/sigp/rust-libp2p.git", rev = "f88e43de9eba00b416d0374b1a1fb2de47b65864" } From e5facc2faf31ae1f3d9923f397bcea4c239ec5f0 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 23 Mar 2026 12:52:10 +1100 Subject: [PATCH 084/189] Bump yamux --- Cargo.lock | 7 ++++--- Cargo.toml | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 96e84ed73e..4043cb9e12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5337,7 +5337,7 @@ dependencies = [ "thiserror 2.0.17", "tracing", "yamux 0.12.1", - "yamux 0.13.8", + "yamux 0.13.10", ] [[package]] @@ -10606,8 +10606,9 @@ dependencies = [ [[package]] name = "yamux" -version = "0.13.8" -source = "git+https://github.com/sigp/rust-yamux?rev=29efa6aebd4bdfcb16bfb21969ec0c785e570b74#29efa6aebd4bdfcb16bfb21969ec0c785e570b74" +version = "0.13.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" dependencies = [ "futures", "log", diff --git a/Cargo.toml b/Cargo.toml index 63cfb39ba4..6910d02427 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -279,7 +279,6 @@ debug = true [patch.crates-io] quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "87c4ccb9bb2af494de375f5f6c62850badd26304" } -yamux = { git = "https://github.com/sigp/rust-yamux", rev = "29efa6aebd4bdfcb16bfb21969ec0c785e570b74" } [patch."https://github.com/libp2p/rust-libp2p.git"] libp2p = { git = "https://github.com/sigp/rust-libp2p.git", rev = "f88e43de9eba00b416d0374b1a1fb2de47b65864" } From c7055b604f9958db410b2e42023763cb19dd7138 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Wed, 25 Mar 2026 15:45:24 +0900 Subject: [PATCH 085/189] Gloas serve envelope rpc (#8896) Serves envelope by range and by root requests. Added PayloadEnvelopeStreamer so that we dont need to alter upstream code when we introduce blinded payload envelopes. Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/beacon_chain/src/beacon_chain.rs | 17 + .../beacon_chain/src/canonical_head.rs | 7 + beacon_node/beacon_chain/src/errors.rs | 2 + beacon_node/beacon_chain/src/lib.rs | 1 + .../beacon_chain_adapter.rs | 42 ++ .../src/payload_envelope_streamer/mod.rs | 219 ++++++++++ .../src/payload_envelope_streamer/tests.rs | 386 ++++++++++++++++++ beacon_node/beacon_processor/src/lib.rs | 31 +- .../src/scheduler/work_queue.rs | 12 + .../src/peer_manager/mod.rs | 6 + .../lighthouse_network/src/rpc/codec.rs | 66 +++ .../lighthouse_network/src/rpc/config.rs | 28 ++ .../lighthouse_network/src/rpc/handler.rs | 29 ++ .../lighthouse_network/src/rpc/methods.rs | 68 ++- .../lighthouse_network/src/rpc/protocol.rs | 92 ++++- .../src/rpc/rate_limiter.rs | 38 +- .../src/service/api_types.rs | 15 + .../lighthouse_network/src/service/mod.rs | 38 ++ .../src/network_beacon_processor/mod.rs | 43 +- .../network_beacon_processor/rpc_methods.rs | 285 ++++++++++++- .../src/network_beacon_processor/tests.rs | 254 +++++++++++- beacon_node/network/src/router.rs | 23 ++ .../types/src/block/signed_beacon_block.rs | 10 + consensus/types/src/core/chain_spec.rs | 20 + .../execution/execution_payload_envelope.rs | 40 ++ .../signed_execution_payload_envelope.rs | 19 + 26 files changed, 1778 insertions(+), 13 deletions(-) create mode 100644 beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs create mode 100644 beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs create mode 100644 beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index c7009fc6dc..81735bdd9d 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -54,6 +54,8 @@ use crate::observed_block_producers::ObservedBlockProducers; use crate::observed_data_sidecars::ObservedDataSidecars; use crate::observed_operations::{ObservationOutcome, ObservedOperations}; use crate::observed_slashable::ObservedSlashable; +#[cfg(not(test))] +use crate::payload_envelope_streamer::{EnvelopeRequestSource, launch_payload_envelope_stream}; use crate::pending_payload_envelopes::PendingPayloadEnvelopes; use crate::persisted_beacon_chain::PersistedBeaconChain; use crate::persisted_custody::persist_custody_context; @@ -1135,6 +1137,21 @@ impl BeaconChain { .map_or_else(|| self.get_blobs(block_root), Ok) } + #[cfg(not(test))] + #[allow(clippy::type_complexity)] + pub fn get_payload_envelopes( + self: &Arc, + block_roots: Vec, + request_source: EnvelopeRequestSource, + ) -> impl Stream< + Item = ( + Hash256, + Arc>>, Error>>, + ), + > { + launch_payload_envelope_stream(self.clone(), block_roots, request_source) + } + pub fn get_data_columns_checking_all_caches( &self, block_root: Hash256, diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 0faddd1792..3a429bdb8a 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -371,6 +371,13 @@ impl CanonicalHead { Ok((head, execution_status)) } + // TODO(gloas) just a stub for now, implement this once we have fork choice. + /// Returns true if the payload for this block is canonical according to fork choice + /// Returns an error if the block root doesn't exist in fork choice. + pub fn block_has_canonical_payload(&self, _root: &Hash256) -> Result { + Ok(true) + } + /// Returns a clone of `self.cached_head`. /// /// Takes a read-lock on `self.cached_head` for a short time (just long enough to clone it). diff --git a/beacon_node/beacon_chain/src/errors.rs b/beacon_node/beacon_chain/src/errors.rs index 6c8f0d2794..210c4a4482 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -8,6 +8,7 @@ use crate::observed_aggregates::Error as ObservedAttestationsError; use crate::observed_attesters::Error as ObservedAttestersError; use crate::observed_block_producers::Error as ObservedBlockProducersError; use crate::observed_data_sidecars::Error as ObservedDataSidecarsError; +use crate::payload_envelope_streamer::Error as EnvelopeStreamerError; use bls::PublicKeyBytes; use execution_layer::PayloadStatus; use fork_choice::ExecutionStatus; @@ -157,6 +158,7 @@ pub enum BeaconChainError { reconstructed_transactions_root: Hash256, }, BlockStreamerError(BlockStreamerError), + EnvelopeStreamerError(EnvelopeStreamerError), AddPayloadLogicError, ExecutionForkChoiceUpdateFailed(execution_layer::Error), PrepareProposerFailed(BlockProcessingError), diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index 29081fd767..cf427d1a40 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -43,6 +43,7 @@ pub mod observed_block_producers; pub mod observed_data_sidecars; pub mod observed_operations; mod observed_slashable; +pub mod payload_envelope_streamer; pub mod payload_envelope_verification; pub mod pending_payload_envelopes; pub mod persisted_beacon_chain; diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs new file mode 100644 index 0000000000..47c58f07b9 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +#[cfg(test)] +use mockall::automock; +use task_executor::TaskExecutor; +use types::{Hash256, SignedExecutionPayloadEnvelope, Slot}; + +use crate::{BeaconChain, BeaconChainError, BeaconChainTypes}; + +/// An adapter to the `BeaconChain` functionalities to remove `BeaconChain` from direct dependency to enable testing envelope streamer logic. +pub(crate) struct EnvelopeStreamerBeaconAdapter { + chain: Arc>, +} + +#[cfg_attr(test, automock, allow(dead_code))] +impl EnvelopeStreamerBeaconAdapter { + pub(crate) fn new(chain: Arc>) -> Self { + Self { chain } + } + + pub(crate) fn executor(&self) -> &TaskExecutor { + &self.chain.task_executor + } + + pub(crate) fn get_payload_envelope( + &self, + root: &Hash256, + ) -> Result>, store::Error> { + self.chain.store.get_payload_envelope(root) + } + + pub(crate) fn get_split_slot(&self) -> Slot { + self.chain.store.get_split_info().slot + } + + pub(crate) fn block_has_canonical_payload( + &self, + root: &Hash256, + ) -> Result { + self.chain.canonical_head.block_has_canonical_payload(root) + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs new file mode 100644 index 0000000000..d10e3762a4 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs @@ -0,0 +1,219 @@ +mod beacon_chain_adapter; +#[cfg(test)] +mod tests; + +use std::sync::Arc; + +#[cfg_attr(test, double)] +use crate::payload_envelope_streamer::beacon_chain_adapter::EnvelopeStreamerBeaconAdapter; +use futures::Stream; +#[cfg(test)] +use mockall_double::double; +use tokio::sync::mpsc::{self, UnboundedSender}; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tracing::{debug, error, warn}; +use types::{EthSpec, Hash256, SignedExecutionPayloadEnvelope}; + +#[cfg(not(test))] +use crate::BeaconChain; +use crate::{BeaconChainError, BeaconChainTypes}; + +type PayloadEnvelopeResult = + Result>>, BeaconChainError>; + +#[derive(Debug)] +pub enum Error { + BlockMissingFromForkChoice, +} + +#[derive(Debug, PartialEq)] +pub enum EnvelopeRequestSource { + ByRoot, + ByRange, +} + +pub struct PayloadEnvelopeStreamer { + adapter: EnvelopeStreamerBeaconAdapter, + request_source: EnvelopeRequestSource, +} + +// TODO(gloas) eventually we'll need to expand this to support loading blinded payload envelopes from the db +// and fetching the execution payload from the EL. See BlockStreamer impl as an example +impl PayloadEnvelopeStreamer { + pub(crate) fn new( + adapter: EnvelopeStreamerBeaconAdapter, + request_source: EnvelopeRequestSource, + ) -> Arc { + Arc::new(Self { + adapter, + request_source, + }) + } + + // TODO(gloas) simply a stub impl for now. Should check some exec payload envelope cache + // and return the envelope if it exists in the cache + fn check_payload_envelope_cache( + &self, + _beacon_block_root: &Hash256, + ) -> Option>> { + // if self.check_caches == CheckCaches::Yes + None + } + + fn load_envelope( + self: &Arc, + beacon_block_root: &Hash256, + ) -> Result>>, BeaconChainError> { + if let Some(cached_envelope) = self.check_payload_envelope_cache(beacon_block_root) { + Ok(Some(cached_envelope)) + } else { + // TODO(gloas) we'll want to use the execution layer directly to call + // the engine api method eth_getPayloadBodiesByRange() + match self.adapter.get_payload_envelope(beacon_block_root) { + Ok(opt_envelope) => Ok(opt_envelope.map(Arc::new)), + Err(e) => Err(BeaconChainError::DBError(e)), + } + } + } + + async fn load_envelopes( + self: &Arc, + block_roots: &[Hash256], + ) -> Result)>, BeaconChainError> { + let streamer = self.clone(); + let block_roots = block_roots.to_vec(); + let split_slot = streamer.adapter.get_split_slot(); + // Loading from the DB is slow -> spawn a blocking task + self.adapter + .executor() + .spawn_blocking_handle( + move || { + let mut results: Vec<(Hash256, PayloadEnvelopeResult)> = Vec::new(); + for root in block_roots.iter() { + // TODO(gloas) we are loading the full envelope from the db. + // in a future PR we will only be storing the blinded envelope. + // When that happens we'll need to use the EL here to fetch + // the payload and reconstruct the non-blinded envelope. + let opt_envelope = match streamer.load_envelope(root) { + Ok(opt_envelope) => opt_envelope, + Err(e) => { + results.push((*root, Err(e))); + continue; + } + }; + + if streamer.request_source == EnvelopeRequestSource::ByRoot { + // No envelope verification required for `ENVELOPE_BY_ROOT` requests. + // If we only served envelopes that match our canonical view, nodes + // wouldn't be able to sync other branches. + results.push((*root, Ok(opt_envelope))); + continue; + } + + // When loading envelopes on or after the split slot, we must cross reference the bid from the child beacon block. + // There can be payloads that have been imported into the hot db but don't match our current view + // of the canonical chain. + + if let Some(envelope) = opt_envelope { + // Ensure that the envelopes we're serving match our view of the canonical chain. + + // When loading envelopes before the split slot, there is no need to check. + // Non-canonical payload envelopes will have already been pruned. + if split_slot > envelope.slot() { + results.push((*root, Ok(Some(envelope)))); + continue; + } + + match streamer.adapter.block_has_canonical_payload(root) { + Ok(is_envelope_canonical) => { + if is_envelope_canonical { + results.push((*root, Ok(Some(envelope)))); + } else { + results.push((*root, Ok(None))); + } + } + Err(_) => { + results.push(( + *root, + Err(BeaconChainError::EnvelopeStreamerError( + Error::BlockMissingFromForkChoice, + )), + )); + } + } + } else { + results.push((*root, Ok(None))); + } + } + results + }, + "load_execution_payload_envelopes", + ) + .ok_or(BeaconChainError::RuntimeShutdown)? + .await + .map_err(BeaconChainError::TokioJoin) + } + + async fn stream_payload_envelopes( + self: Arc, + beacon_block_roots: Vec, + sender: UnboundedSender<(Hash256, Arc>)>, + ) { + let results = match self.load_envelopes(&beacon_block_roots).await { + Ok(results) => results, + Err(e) => { + warn!(error = ?e, "Failed to load payload envelopes"); + send_errors(&beacon_block_roots, sender, e).await; + return; + } + }; + + for (root, result) in results { + if sender.send((root, Arc::new(result))).is_err() { + break; + } + } + } + + pub fn launch_stream( + self: Arc, + block_roots: Vec, + ) -> impl Stream>)> { + let (envelope_tx, envelope_rx) = mpsc::unbounded_channel(); + debug!( + envelopes = block_roots.len(), + "Launching a PayloadEnvelopeStreamer" + ); + let executor = self.adapter.executor().clone(); + executor.spawn( + self.stream_payload_envelopes(block_roots, envelope_tx), + "get_payload_envelopes_sender", + ); + UnboundedReceiverStream::new(envelope_rx) + } +} + +/// Create a `PayloadEnvelopeStreamer` from a `BeaconChain` and launch a stream. +#[cfg(not(test))] +pub fn launch_payload_envelope_stream( + chain: Arc>, + block_roots: Vec, + request_source: EnvelopeRequestSource, +) -> impl Stream>)> { + let adapter = beacon_chain_adapter::EnvelopeStreamerBeaconAdapter::new(chain); + PayloadEnvelopeStreamer::new(adapter, request_source).launch_stream(block_roots) +} + +async fn send_errors( + block_roots: &[Hash256], + sender: UnboundedSender<(Hash256, Arc>)>, + beacon_chain_error: BeaconChainError, +) { + let result = Arc::new(Err(beacon_chain_error)); + for beacon_block_root in block_roots { + if sender.send((*beacon_block_root, result.clone())).is_err() { + error!("EnvelopeStreamer channel closed unexpectedly"); + break; + } + } +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs new file mode 100644 index 0000000000..9e869a59b8 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs @@ -0,0 +1,386 @@ +use super::*; +use crate::payload_envelope_streamer::beacon_chain_adapter::MockEnvelopeStreamerBeaconAdapter; +use crate::test_utils::EphemeralHarnessType; +use bls::{FixedBytesExtended, Signature}; +use futures::StreamExt; +use std::collections::HashMap; +use task_executor::test_utils::TestRuntime; +use types::{ + ExecutionBlockHash, ExecutionPayloadEnvelope, ExecutionPayloadGloas, Hash256, MinimalEthSpec, + SignedExecutionPayloadEnvelope, Slot, +}; + +type E = MinimalEthSpec; +type T = EphemeralHarnessType; + +struct SlotEntry { + block_root: Hash256, + slot: Slot, + envelope: Option>, + non_canonical_envelope: bool, +} + +impl SlotEntry { + fn expect_envelope(&self, split_slot: Option) -> bool { + if self.envelope.is_none() { + return false; + } + if !self.non_canonical_envelope { + return true; + } + // Non-canonical envelopes before the split slot are returned + // (in production they would have been pruned). + split_slot.is_some_and(|s| self.slot < s) + } +} + +fn roots(chain: &[SlotEntry]) -> Vec { + chain.iter().map(|s| s.block_root).collect() +} + +/// Build test chain data. +fn build_chain( + num_slots: u64, + skipped_slots: &[u64], + missing_envelope_slots: &[u64], + non_canonical_envelope_slots: &[u64], +) -> Vec { + let mut chain = Vec::new(); + for i in 1..=num_slots { + if skipped_slots.contains(&i) { + continue; + } + let slot = Slot::new(i); + let block_root = Hash256::from_low_u64_be(i); + let has_envelope = !missing_envelope_slots.contains(&i); + let is_non_canonical = non_canonical_envelope_slots.contains(&i); + + let envelope = if has_envelope { + let block_hash = if is_non_canonical { + ExecutionBlockHash::from_root(Hash256::repeat_byte(0xFF)) + } else { + ExecutionBlockHash::from_root(Hash256::from_low_u64_be(i)) + }; + Some(SignedExecutionPayloadEnvelope { + message: ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas { + block_hash, + ..Default::default() + }, + execution_requests: Default::default(), + builder_index: 0, + beacon_block_root: block_root, + slot, + state_root: Hash256::zero(), + }, + signature: Signature::empty(), + }) + } else { + None + }; + + chain.push(SlotEntry { + block_root, + slot, + envelope, + non_canonical_envelope: is_non_canonical, + }); + } + chain +} + +fn mock_adapter() -> (MockEnvelopeStreamerBeaconAdapter, TestRuntime) { + let runtime = TestRuntime::default(); + let mut mock = MockEnvelopeStreamerBeaconAdapter::default(); + mock.expect_executor() + .return_const(runtime.task_executor.clone()); + (mock, runtime) +} + +/// Configure `get_payload_envelope` to return envelopes from chain data. +fn mock_envelopes(mock: &mut MockEnvelopeStreamerBeaconAdapter, chain: &[SlotEntry]) { + let envelope_map: HashMap>> = chain + .iter() + .map(|entry| (entry.block_root, entry.envelope.clone())) + .collect(); + mock.expect_get_payload_envelope() + .returning(move |root| Ok(envelope_map.get(root).cloned().flatten())); +} + +/// Configure `block_has_canonical_payload` based on chain's non-canonical entries. +fn mock_canonical_head(mock: &mut MockEnvelopeStreamerBeaconAdapter, chain: &[SlotEntry]) { + let non_canonical: Vec = chain + .iter() + .filter(|e| e.non_canonical_envelope) + .map(|e| e.block_root) + .collect(); + mock.expect_block_has_canonical_payload() + .returning(move |root| Ok(!non_canonical.contains(root))); +} + +fn unwrap_result( + result: &Arc>, +) -> &Option>> { + result + .as_ref() + .as_ref() + .expect("unexpected error in stream result") +} + +async fn assert_stream_matches( + stream: &mut (impl Stream>)> + Unpin), + chain: &[SlotEntry], + split_slot: Option, +) { + for (i, entry) in chain.iter().enumerate() { + let (root, result) = stream + .next() + .await + .unwrap_or_else(|| panic!("stream ended early at index {i}")); + assert_eq!(root, entry.block_root, "root mismatch at index {i}"); + + let result = unwrap_result(&result); + + if entry.expect_envelope(split_slot) { + let envelope = result + .as_ref() + .unwrap_or_else(|| panic!("expected Some at index {i} but got None")); + let expected_envelope = entry.envelope.as_ref().unwrap(); + assert_eq!( + envelope.block_hash(), + expected_envelope.block_hash(), + "block_hash mismatch at index {i}" + ); + } else { + assert!( + result.is_none(), + "expected None at index {i} (missing or non-canonical), got Some" + ); + } + } + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} + +/// Happy path: all envelopes exist and are canonical. +#[tokio::test] +async fn stream_envelopes_by_range() { + let chain = build_chain(8, &[], &[], &[]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock_canonical_head(&mut mock, &chain); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(roots(&chain)); + assert_stream_matches(&mut stream, &chain, None).await; +} + +/// Mixed chain: skipped slots, missing envelopes, and non-canonical envelopes. +#[tokio::test] +async fn stream_envelopes_by_range_mixed() { + let chain = build_chain(12, &[3, 8], &[5], &[7, 11]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock_canonical_head(&mut mock, &chain); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(roots(&chain)); + assert_stream_matches(&mut stream, &chain, None).await; +} + +/// Non-canonical envelopes before the split slot bypass canonical verification +/// and are returned. Non-canonical envelopes after the split slot are filtered out. +#[tokio::test] +async fn stream_envelopes_by_range_before_split() { + // Non-canonical envelopes at slots 2 and 4 (before split), slot 8 (after split). + let chain = build_chain(10, &[], &[], &[2, 4, 8]); + let split_slot = Slot::new(6); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(split_slot); + mock_envelopes(&mut mock, &chain); + mock_canonical_head(&mut mock, &chain); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(roots(&chain)); + assert_stream_matches(&mut stream, &chain, Some(split_slot)).await; +} + +#[tokio::test] +async fn stream_envelopes_empty_roots() { + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(vec![]); + assert!( + stream.next().await.is_none(), + "empty roots should produce no results" + ); +} + +#[tokio::test] +async fn stream_envelopes_single_root() { + let chain = build_chain(3, &[], &[], &[]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock_canonical_head(&mut mock, &chain); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(vec![chain[1].block_root]); + + let (root, result) = stream.next().await.expect("should get one result"); + assert_eq!(root, chain[1].block_root); + let envelope = unwrap_result(&result) + .as_ref() + .expect("should have envelope"); + assert_eq!( + envelope.block_hash(), + chain[1].envelope.as_ref().unwrap().block_hash(), + ); + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} + +/// ByRoot requests skip canonical verification, so non-canonical envelopes +/// should still be returned. `block_has_canonical_payload` should never be called. +#[tokio::test] +async fn stream_envelopes_by_root() { + let chain = build_chain(8, &[], &[], &[3, 5, 7]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock.expect_block_has_canonical_payload().times(0); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRoot); + let mut stream = streamer.launch_stream(roots(&chain)); + + // Every envelope should come back as Some, even the non-canonical ones. + for (i, entry) in chain.iter().enumerate() { + let (root, result) = stream + .next() + .await + .unwrap_or_else(|| panic!("stream ended early at index {i}")); + assert_eq!(root, entry.block_root, "root mismatch at index {i}"); + + let envelope = unwrap_result(&result) + .as_ref() + .unwrap_or_else(|| panic!("expected Some at index {i} for ByRoot request")); + let expected_envelope = entry.envelope.as_ref().unwrap(); + assert_eq!( + envelope.block_hash(), + expected_envelope.block_hash(), + "block_hash mismatch at index {i}" + ); + } + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} + +/// When `block_has_canonical_payload` returns an error, the streamer should +/// yield `Err(EnvelopeStreamerError(BlockMissingFromForkChoice))` for those roots. +#[tokio::test] +async fn stream_envelopes_error() { + let chain = build_chain(4, &[], &[], &[]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock.expect_block_has_canonical_payload() + .returning(|_| Err(BeaconChainError::CanonicalHeadLockTimeout)); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(roots(&chain)); + + for (i, entry) in chain.iter().enumerate() { + let (root, result) = stream + .next() + .await + .unwrap_or_else(|| panic!("stream ended early at index {i}")); + assert_eq!(root, entry.block_root, "root mismatch at index {i}"); + assert!( + matches!( + result.as_ref(), + Err(BeaconChainError::EnvelopeStreamerError( + Error::BlockMissingFromForkChoice + )) + ), + "expected BlockMissingFromForkChoice error at index {i}, got {:?}", + result + ); + } + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} + +/// Requesting unknown roots (not in the store) via ByRange should return Ok(None). +#[tokio::test] +async fn stream_envelopes_by_range_unknown_roots() { + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock.expect_get_payload_envelope().returning(|_| Ok(None)); + + let unknown_roots: Vec = (1..=4) + .map(|i| Hash256::from_low_u64_be(i * 1000)) + .collect(); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); + let mut stream = streamer.launch_stream(unknown_roots.clone()); + + for (i, expected_root) in unknown_roots.iter().enumerate() { + let (root, result) = stream + .next() + .await + .unwrap_or_else(|| panic!("stream ended early at index {i}")); + assert_eq!(root, *expected_root, "root mismatch at index {i}"); + let envelope = unwrap_result(&result); + assert!( + envelope.is_none(), + "expected None for unknown root at index {i}" + ); + } + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} + +/// Requesting roots via ByRoot where some envelopes are missing should +/// return Ok(None) for those roots. +#[tokio::test] +async fn stream_envelopes_by_root_missing_envelopes() { + let chain = build_chain(6, &[], &[2, 4], &[]); + let (mut mock, _runtime) = mock_adapter(); + mock.expect_get_split_slot().return_const(Slot::new(0)); + mock_envelopes(&mut mock, &chain); + mock.expect_block_has_canonical_payload().times(0); + + let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRoot); + let mut stream = streamer.launch_stream(roots(&chain)); + + for (i, entry) in chain.iter().enumerate() { + let (root, result) = stream + .next() + .await + .unwrap_or_else(|| panic!("stream ended early at index {i}")); + assert_eq!(root, entry.block_root, "root mismatch at index {i}"); + + let envelope_opt = unwrap_result(&result); + if let Some(entry_envelope) = &entry.envelope { + let envelope = envelope_opt + .as_ref() + .unwrap_or_else(|| panic!("expected Some at index {i}")); + assert_eq!( + envelope.block_hash(), + entry_envelope.block_hash(), + "block_hash mismatch at index {i}" + ); + } else { + assert!( + envelope_opt.is_none(), + "expected None for missing envelope at index {i}" + ); + } + } + + assert!(stream.next().await.is_none(), "stream should be exhausted"); +} diff --git a/beacon_node/beacon_processor/src/lib.rs b/beacon_node/beacon_processor/src/lib.rs index c33f4840e0..724c41cfc9 100644 --- a/beacon_node/beacon_processor/src/lib.rs +++ b/beacon_node/beacon_processor/src/lib.rs @@ -426,6 +426,8 @@ pub enum Work { Status(BlockingFn), BlocksByRangeRequest(AsyncFn), BlocksByRootsRequest(AsyncFn), + PayloadEnvelopesByRangeRequest(AsyncFn), + PayloadEnvelopesByRootRequest(AsyncFn), BlobsByRangeRequest(BlockingFn), BlobsByRootsRequest(BlockingFn), DataColumnsByRootsRequest(BlockingFn), @@ -483,6 +485,8 @@ pub enum WorkType { Status, BlocksByRangeRequest, BlocksByRootsRequest, + PayloadEnvelopesByRangeRequest, + PayloadEnvelopesByRootRequest, BlobsByRangeRequest, BlobsByRootsRequest, DataColumnsByRootsRequest, @@ -542,6 +546,8 @@ impl Work { Work::Status(_) => WorkType::Status, Work::BlocksByRangeRequest(_) => WorkType::BlocksByRangeRequest, Work::BlocksByRootsRequest(_) => WorkType::BlocksByRootsRequest, + Work::PayloadEnvelopesByRangeRequest(_) => WorkType::PayloadEnvelopesByRangeRequest, + Work::PayloadEnvelopesByRootRequest(_) => WorkType::PayloadEnvelopesByRootRequest, Work::BlobsByRangeRequest(_) => WorkType::BlobsByRangeRequest, Work::BlobsByRootsRequest(_) => WorkType::BlobsByRootsRequest, Work::DataColumnsByRootsRequest(_) => WorkType::DataColumnsByRootsRequest, @@ -991,6 +997,12 @@ impl BeaconProcessor { Some(item) } else if let Some(item) = work_queues.dcbrange_queue.pop() { Some(item) + } else if let Some(item) = work_queues.payload_envelopes_brange_queue.pop() + { + Some(item) + } else if let Some(item) = work_queues.payload_envelopes_broots_queue.pop() + { + Some(item) // Check slashings after all other consensus messages so we prioritize // following head. // @@ -1180,6 +1192,12 @@ impl BeaconProcessor { Work::BlocksByRootsRequest { .. } => { work_queues.block_broots_queue.push(work, work_id) } + Work::PayloadEnvelopesByRangeRequest { .. } => work_queues + .payload_envelopes_brange_queue + .push(work, work_id), + Work::PayloadEnvelopesByRootRequest { .. } => work_queues + .payload_envelopes_broots_queue + .push(work, work_id), Work::BlobsByRangeRequest { .. } => { work_queues.blob_brange_queue.push(work, work_id) } @@ -1296,6 +1314,12 @@ impl BeaconProcessor { WorkType::Status => work_queues.status_queue.len(), WorkType::BlocksByRangeRequest => work_queues.block_brange_queue.len(), WorkType::BlocksByRootsRequest => work_queues.block_broots_queue.len(), + WorkType::PayloadEnvelopesByRangeRequest => { + work_queues.payload_envelopes_brange_queue.len() + } + WorkType::PayloadEnvelopesByRootRequest => { + work_queues.payload_envelopes_broots_queue.len() + } WorkType::BlobsByRangeRequest => work_queues.blob_brange_queue.len(), WorkType::BlobsByRootsRequest => work_queues.blob_broots_queue.len(), WorkType::DataColumnsByRootsRequest => work_queues.dcbroots_queue.len(), @@ -1487,9 +1511,10 @@ impl BeaconProcessor { | Work::DataColumnsByRangeRequest(process_fn) => { task_spawner.spawn_blocking(process_fn) } - Work::BlocksByRangeRequest(work) | Work::BlocksByRootsRequest(work) => { - task_spawner.spawn_async(work) - } + Work::BlocksByRangeRequest(work) + | Work::BlocksByRootsRequest(work) + | Work::PayloadEnvelopesByRangeRequest(work) + | Work::PayloadEnvelopesByRootRequest(work) => task_spawner.spawn_async(work), Work::ChainSegmentBackfill(process_fn) => { if self.config.enable_backfill_rate_limiting { task_spawner.spawn_blocking_with_rayon(RayonPoolType::LowPriority, process_fn) diff --git a/beacon_node/beacon_processor/src/scheduler/work_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_queue.rs index e48c776b6d..363ec06097 100644 --- a/beacon_node/beacon_processor/src/scheduler/work_queue.rs +++ b/beacon_node/beacon_processor/src/scheduler/work_queue.rs @@ -135,6 +135,8 @@ pub struct BeaconProcessorQueueLengths { blob_brange_queue: usize, dcbroots_queue: usize, dcbrange_queue: usize, + payload_envelopes_brange_queue: usize, + payload_envelopes_broots_queue: usize, gossip_bls_to_execution_change_queue: usize, gossip_execution_payload_queue: usize, gossip_execution_payload_bid_queue: usize, @@ -206,6 +208,8 @@ impl BeaconProcessorQueueLengths { blob_brange_queue: 1024, dcbroots_queue: 1024, dcbrange_queue: 1024, + payload_envelopes_brange_queue: 1024, + payload_envelopes_broots_queue: 1024, gossip_bls_to_execution_change_queue: 16384, // TODO(EIP-7732): verify 1024 is preferable. I used same value as `gossip_block_queue` and `gossip_blob_queue` gossip_execution_payload_queue: 1024, @@ -256,6 +260,8 @@ pub struct WorkQueues { pub status_queue: FifoQueue>, pub block_brange_queue: FifoQueue>, pub block_broots_queue: FifoQueue>, + pub payload_envelopes_brange_queue: FifoQueue>, + pub payload_envelopes_broots_queue: FifoQueue>, pub blob_broots_queue: FifoQueue>, pub blob_brange_queue: FifoQueue>, pub dcbroots_queue: FifoQueue>, @@ -327,6 +333,10 @@ impl WorkQueues { let blob_brange_queue = FifoQueue::new(queue_lengths.blob_brange_queue); let dcbroots_queue = FifoQueue::new(queue_lengths.dcbroots_queue); let dcbrange_queue = FifoQueue::new(queue_lengths.dcbrange_queue); + let payload_envelopes_brange_queue = + FifoQueue::new(queue_lengths.payload_envelopes_brange_queue); + let payload_envelopes_broots_queue = + FifoQueue::new(queue_lengths.payload_envelopes_broots_queue); let gossip_bls_to_execution_change_queue = FifoQueue::new(queue_lengths.gossip_bls_to_execution_change_queue); @@ -387,6 +397,8 @@ impl WorkQueues { blob_brange_queue, dcbroots_queue, dcbrange_queue, + payload_envelopes_brange_queue, + payload_envelopes_broots_queue, gossip_bls_to_execution_change_queue, gossip_execution_payload_queue, gossip_execution_payload_bid_queue, diff --git a/beacon_node/lighthouse_network/src/peer_manager/mod.rs b/beacon_node/lighthouse_network/src/peer_manager/mod.rs index 43a44c85fc..2edd9de2d9 100644 --- a/beacon_node/lighthouse_network/src/peer_manager/mod.rs +++ b/beacon_node/lighthouse_network/src/peer_manager/mod.rs @@ -590,6 +590,8 @@ impl PeerManager { Protocol::BlocksByRange => PeerAction::MidToleranceError, Protocol::BlocksByRoot => PeerAction::MidToleranceError, Protocol::BlobsByRange => PeerAction::MidToleranceError, + Protocol::PayloadEnvelopesByRange => PeerAction::MidToleranceError, + Protocol::PayloadEnvelopesByRoot => PeerAction::MidToleranceError, // Lighthouse does not currently make light client requests; therefore, this // is an unexpected scenario. We do not ban the peer for rate limiting. Protocol::LightClientBootstrap => return, @@ -615,6 +617,8 @@ impl PeerManager { Protocol::Ping => PeerAction::Fatal, Protocol::BlocksByRange => return, Protocol::BlocksByRoot => return, + Protocol::PayloadEnvelopesByRange => return, + Protocol::PayloadEnvelopesByRoot => return, Protocol::BlobsByRange => return, Protocol::BlobsByRoot => return, Protocol::DataColumnsByRoot => return, @@ -638,6 +642,8 @@ impl PeerManager { Protocol::Ping => PeerAction::LowToleranceError, Protocol::BlocksByRange => PeerAction::MidToleranceError, Protocol::BlocksByRoot => PeerAction::MidToleranceError, + Protocol::PayloadEnvelopesByRange => PeerAction::MidToleranceError, + Protocol::PayloadEnvelopesByRoot => PeerAction::MidToleranceError, Protocol::BlobsByRange => PeerAction::MidToleranceError, Protocol::BlobsByRoot => PeerAction::MidToleranceError, Protocol::DataColumnsByRoot => PeerAction::MidToleranceError, diff --git a/beacon_node/lighthouse_network/src/rpc/codec.rs b/beacon_node/lighthouse_network/src/rpc/codec.rs index d1a3182fad..346e350825 100644 --- a/beacon_node/lighthouse_network/src/rpc/codec.rs +++ b/beacon_node/lighthouse_network/src/rpc/codec.rs @@ -15,6 +15,7 @@ use std::io::{Read, Write}; use std::marker::PhantomData; use std::sync::Arc; use tokio_util::codec::{Decoder, Encoder}; +use types::SignedExecutionPayloadEnvelope; use types::{ BlobSidecar, ChainSpec, DataColumnSidecar, DataColumnsByRootIdentifier, EthSpec, ForkContext, ForkName, Hash256, LightClientBootstrap, LightClientFinalityUpdate, @@ -76,6 +77,8 @@ impl SSZSnappyInboundCodec { }, RpcSuccessResponse::BlocksByRange(res) => res.as_ssz_bytes(), RpcSuccessResponse::BlocksByRoot(res) => res.as_ssz_bytes(), + RpcSuccessResponse::PayloadEnvelopesByRange(res) => res.as_ssz_bytes(), + RpcSuccessResponse::PayloadEnvelopesByRoot(res) => res.as_ssz_bytes(), RpcSuccessResponse::BlobsByRange(res) => res.as_ssz_bytes(), RpcSuccessResponse::BlobsByRoot(res) => res.as_ssz_bytes(), RpcSuccessResponse::DataColumnsByRoot(res) => res.as_ssz_bytes(), @@ -356,6 +359,8 @@ impl Encoder> for SSZSnappyOutboundCodec { BlocksByRootRequest::V1(req) => req.block_roots.as_ssz_bytes(), BlocksByRootRequest::V2(req) => req.block_roots.as_ssz_bytes(), }, + RequestType::PayloadEnvelopesByRange(req) => req.as_ssz_bytes(), + RequestType::PayloadEnvelopesByRoot(req) => req.beacon_block_roots.as_ssz_bytes(), RequestType::BlobsByRange(req) => req.as_ssz_bytes(), RequestType::BlobsByRoot(req) => req.blob_ids.as_ssz_bytes(), RequestType::DataColumnsByRange(req) => req.as_ssz_bytes(), @@ -548,6 +553,19 @@ fn handle_rpc_request( )?, }), ))), + SupportedProtocol::PayloadEnvelopesByRangeV1 => { + Ok(Some(RequestType::PayloadEnvelopesByRange( + PayloadEnvelopesByRangeRequest::from_ssz_bytes(decoded_buffer)?, + ))) + } + SupportedProtocol::PayloadEnvelopesByRootV1 => Ok(Some( + RequestType::PayloadEnvelopesByRoot(PayloadEnvelopesByRootRequest { + beacon_block_roots: RuntimeVariableList::from_ssz_bytes( + decoded_buffer, + spec.max_request_payloads(), + )?, + }), + )), SupportedProtocol::BlobsByRangeV1 => Ok(Some(RequestType::BlobsByRange( BlobsByRangeRequest::from_ssz_bytes(decoded_buffer)?, ))), @@ -650,6 +668,48 @@ fn handle_rpc_response( SupportedProtocol::BlocksByRootV1 => Ok(Some(RpcSuccessResponse::BlocksByRoot(Arc::new( SignedBeaconBlock::Base(SignedBeaconBlockBase::from_ssz_bytes(decoded_buffer)?), )))), + SupportedProtocol::PayloadEnvelopesByRangeV1 => match fork_name { + Some(fork_name) => { + if fork_name.gloas_enabled() { + Ok(Some(RpcSuccessResponse::PayloadEnvelopesByRange(Arc::new( + SignedExecutionPayloadEnvelope::from_ssz_bytes(decoded_buffer)?, + )))) + } else { + Err(RPCError::ErrorResponse( + RpcErrorResponse::InvalidRequest, + "Invalid fork name for payload envelopes by range".to_string(), + )) + } + } + None => Err(RPCError::ErrorResponse( + RpcErrorResponse::InvalidRequest, + format!( + "No context bytes provided for {:?} response", + versioned_protocol + ), + )), + }, + SupportedProtocol::PayloadEnvelopesByRootV1 => match fork_name { + Some(fork_name) => { + if fork_name.gloas_enabled() { + Ok(Some(RpcSuccessResponse::PayloadEnvelopesByRoot(Arc::new( + SignedExecutionPayloadEnvelope::from_ssz_bytes(decoded_buffer)?, + )))) + } else { + Err(RPCError::ErrorResponse( + RpcErrorResponse::InvalidRequest, + "Invalid fork name for payload envelopes by root".to_string(), + )) + } + } + None => Err(RPCError::ErrorResponse( + RpcErrorResponse::InvalidRequest, + format!( + "No context bytes provided for {:?} response", + versioned_protocol + ), + )), + }, SupportedProtocol::BlobsByRangeV1 => match fork_name { Some(fork_name) => { if fork_name.deneb_enabled() { @@ -1260,6 +1320,12 @@ mod tests { RequestType::BlobsByRange(blbrange) => { assert_eq!(decoded, RequestType::BlobsByRange(blbrange)) } + RequestType::PayloadEnvelopesByRange(perange) => { + assert_eq!(decoded, RequestType::PayloadEnvelopesByRange(perange)) + } + RequestType::PayloadEnvelopesByRoot(peroot) => { + assert_eq!(decoded, RequestType::PayloadEnvelopesByRoot(peroot)) + } RequestType::BlobsByRoot(bbroot) => { assert_eq!(decoded, RequestType::BlobsByRoot(bbroot)) } diff --git a/beacon_node/lighthouse_network/src/rpc/config.rs b/beacon_node/lighthouse_network/src/rpc/config.rs index b0ee6fea64..9e1c6541ec 100644 --- a/beacon_node/lighthouse_network/src/rpc/config.rs +++ b/beacon_node/lighthouse_network/src/rpc/config.rs @@ -89,6 +89,8 @@ pub struct RateLimiterConfig { pub(super) goodbye_quota: Quota, pub(super) blocks_by_range_quota: Quota, pub(super) blocks_by_root_quota: Quota, + pub(super) payload_envelopes_by_range_quota: Quota, + pub(super) payload_envelopes_by_root_quota: Quota, pub(super) blobs_by_range_quota: Quota, pub(super) blobs_by_root_quota: Quota, pub(super) data_columns_by_root_quota: Quota, @@ -111,6 +113,10 @@ impl RateLimiterConfig { Quota::n_every(NonZeroU64::new(128).unwrap(), 10); pub const DEFAULT_BLOCKS_BY_ROOT_QUOTA: Quota = Quota::n_every(NonZeroU64::new(128).unwrap(), 10); + pub const DEFAULT_PAYLOAD_ENVELOPES_BY_RANGE_QUOTA: Quota = + Quota::n_every(NonZeroU64::new(128).unwrap(), 10); + pub const DEFAULT_PAYLOAD_ENVELOPES_BY_ROOT_QUOTA: Quota = + Quota::n_every(NonZeroU64::new(128).unwrap(), 10); // `DEFAULT_BLOCKS_BY_RANGE_QUOTA` * (target + 1) to account for high usage pub const DEFAULT_BLOBS_BY_RANGE_QUOTA: Quota = Quota::n_every(NonZeroU64::new(896).unwrap(), 10); @@ -137,6 +143,8 @@ impl Default for RateLimiterConfig { goodbye_quota: Self::DEFAULT_GOODBYE_QUOTA, blocks_by_range_quota: Self::DEFAULT_BLOCKS_BY_RANGE_QUOTA, blocks_by_root_quota: Self::DEFAULT_BLOCKS_BY_ROOT_QUOTA, + payload_envelopes_by_range_quota: Self::DEFAULT_PAYLOAD_ENVELOPES_BY_RANGE_QUOTA, + payload_envelopes_by_root_quota: Self::DEFAULT_PAYLOAD_ENVELOPES_BY_ROOT_QUOTA, blobs_by_range_quota: Self::DEFAULT_BLOBS_BY_RANGE_QUOTA, blobs_by_root_quota: Self::DEFAULT_BLOBS_BY_ROOT_QUOTA, data_columns_by_root_quota: Self::DEFAULT_DATA_COLUMNS_BY_ROOT_QUOTA, @@ -169,6 +177,14 @@ impl Debug for RateLimiterConfig { .field("goodbye", fmt_q!(&self.goodbye_quota)) .field("blocks_by_range", fmt_q!(&self.blocks_by_range_quota)) .field("blocks_by_root", fmt_q!(&self.blocks_by_root_quota)) + .field( + "payload_envelopes_by_range", + fmt_q!(&self.payload_envelopes_by_range_quota), + ) + .field( + "payload_envelopes_by_root", + fmt_q!(&self.payload_envelopes_by_root_quota), + ) .field("blobs_by_range", fmt_q!(&self.blobs_by_range_quota)) .field("blobs_by_root", fmt_q!(&self.blobs_by_root_quota)) .field( @@ -197,6 +213,8 @@ impl FromStr for RateLimiterConfig { let mut goodbye_quota = None; let mut blocks_by_range_quota = None; let mut blocks_by_root_quota = None; + let mut payload_envelopes_by_range_quota = None; + let mut payload_envelopes_by_root_quota = None; let mut blobs_by_range_quota = None; let mut blobs_by_root_quota = None; let mut data_columns_by_root_quota = None; @@ -214,6 +232,12 @@ impl FromStr for RateLimiterConfig { Protocol::Goodbye => goodbye_quota = goodbye_quota.or(quota), Protocol::BlocksByRange => blocks_by_range_quota = blocks_by_range_quota.or(quota), Protocol::BlocksByRoot => blocks_by_root_quota = blocks_by_root_quota.or(quota), + Protocol::PayloadEnvelopesByRange => { + payload_envelopes_by_range_quota = payload_envelopes_by_range_quota.or(quota) + } + Protocol::PayloadEnvelopesByRoot => { + payload_envelopes_by_root_quota = payload_envelopes_by_root_quota.or(quota) + } Protocol::BlobsByRange => blobs_by_range_quota = blobs_by_range_quota.or(quota), Protocol::BlobsByRoot => blobs_by_root_quota = blobs_by_root_quota.or(quota), Protocol::DataColumnsByRoot => { @@ -250,6 +274,10 @@ impl FromStr for RateLimiterConfig { .unwrap_or(Self::DEFAULT_BLOCKS_BY_RANGE_QUOTA), blocks_by_root_quota: blocks_by_root_quota .unwrap_or(Self::DEFAULT_BLOCKS_BY_ROOT_QUOTA), + payload_envelopes_by_range_quota: payload_envelopes_by_range_quota + .unwrap_or(Self::DEFAULT_PAYLOAD_ENVELOPES_BY_RANGE_QUOTA), + payload_envelopes_by_root_quota: payload_envelopes_by_root_quota + .unwrap_or(Self::DEFAULT_PAYLOAD_ENVELOPES_BY_ROOT_QUOTA), blobs_by_range_quota: blobs_by_range_quota .unwrap_or(Self::DEFAULT_BLOBS_BY_RANGE_QUOTA), blobs_by_root_quota: blobs_by_root_quota.unwrap_or(Self::DEFAULT_BLOBS_BY_ROOT_QUOTA), diff --git a/beacon_node/lighthouse_network/src/rpc/handler.rs b/beacon_node/lighthouse_network/src/rpc/handler.rs index 9861119ac1..336747fb83 100644 --- a/beacon_node/lighthouse_network/src/rpc/handler.rs +++ b/beacon_node/lighthouse_network/src/rpc/handler.rs @@ -954,6 +954,35 @@ where return; } } + RequestType::PayloadEnvelopesByRange(request) => { + let max_allowed = spec.max_request_payloads; + if request.count > max_allowed { + self.events_out.push(HandlerEvent::Err(HandlerErr::Inbound { + id: self.current_inbound_substream_id, + proto: Protocol::PayloadEnvelopesByRange, + error: RPCError::InvalidData(format!( + "requested exceeded limit. allowed: {}, requested: {}", + max_allowed, request.count + )), + })); + return; + } + } + RequestType::DataColumnsByRange(request) => { + let max_requested = request.max_requested::(); + let max_allowed = spec.max_request_data_column_sidecars; + if max_requested > max_allowed { + self.events_out.push(HandlerEvent::Err(HandlerErr::Inbound { + id: self.current_inbound_substream_id, + proto: Protocol::DataColumnsByRange, + error: RPCError::InvalidData(format!( + "requested exceeded limit. allowed: {}, requested: {}", + max_allowed, max_requested + )), + })); + return; + } + } _ => {} }; diff --git a/beacon_node/lighthouse_network/src/rpc/methods.rs b/beacon_node/lighthouse_network/src/rpc/methods.rs index 5a9a683b75..baabf48683 100644 --- a/beacon_node/lighthouse_network/src/rpc/methods.rs +++ b/beacon_node/lighthouse_network/src/rpc/methods.rs @@ -17,7 +17,8 @@ use types::light_client::consts::MAX_REQUEST_LIGHT_CLIENT_UPDATES; use types::{ BlobSidecar, ChainSpec, ColumnIndex, DataColumnSidecar, DataColumnsByRootIdentifier, Epoch, EthSpec, ForkContext, Hash256, LightClientBootstrap, LightClientFinalityUpdate, - LightClientOptimisticUpdate, LightClientUpdate, SignedBeaconBlock, Slot, + LightClientOptimisticUpdate, LightClientUpdate, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, Slot, }; /// Maximum length of error message. @@ -362,6 +363,16 @@ impl BlocksByRangeRequest { } } +/// Request a number of execution payload envelopes from a peer. +#[derive(Encode, Decode, Clone, Debug, PartialEq)] +pub struct PayloadEnvelopesByRangeRequest { + /// The starting slot to request execution payload envelopes. + pub start_slot: u64, + + /// The number of slots from the start slot. + pub count: u64, +} + /// Request a number of beacon blobs from a peer. #[derive(Encode, Decode, Clone, Debug, PartialEq)] pub struct BlobsByRangeRequest { @@ -505,6 +516,29 @@ impl BlocksByRootRequest { } } +/// Request a number of execution payload envelopes from a peer. +#[derive(Clone, Debug, PartialEq)] +pub struct PayloadEnvelopesByRootRequest { + /// The list of beacon block roots used to request execution payload envelopes. + pub beacon_block_roots: RuntimeVariableList, +} + +impl PayloadEnvelopesByRootRequest { + pub fn new( + beacon_block_roots: Vec, + fork_context: &ForkContext, + ) -> Result { + let max_requests_envelopes = fork_context.spec.max_request_payloads(); + + let beacon_block_roots = + RuntimeVariableList::new(beacon_block_roots, max_requests_envelopes).map_err(|e| { + format!("ExecutionPayloadEnvelopesByRootRequest too many beacon block roots: {e:?}") + })?; + + Ok(Self { beacon_block_roots }) + } +} + /// Request a number of beacon blocks and blobs from a peer. #[derive(Clone, Debug, PartialEq)] pub struct BlobsByRootRequest { @@ -588,6 +622,13 @@ pub enum RpcSuccessResponse { /// A response to a get BLOCKS_BY_ROOT request. BlocksByRoot(Arc>), + /// A response to a get EXECUTION_PAYLOAD_ENVELOPES_BY_RANGE request. A None response signifies + /// the end of the batch. + PayloadEnvelopesByRange(Arc>), + + /// A response to a get EXECUTION_PAYLOAD_ENVELOPES_BY_ROOT request. + PayloadEnvelopesByRoot(Arc>), + /// A response to a get BLOBS_BY_RANGE request BlobsByRange(Arc>), @@ -628,6 +669,12 @@ pub enum ResponseTermination { /// Blocks by root stream termination. BlocksByRoot, + /// Execution payload envelopes by range stream termination. + PayloadEnvelopesByRange, + + /// Execution payload envelopes by root stream termination. + PayloadEnvelopesByRoot, + /// Blobs by range stream termination. BlobsByRange, @@ -649,6 +696,8 @@ impl ResponseTermination { match self { ResponseTermination::BlocksByRange => Protocol::BlocksByRange, ResponseTermination::BlocksByRoot => Protocol::BlocksByRoot, + ResponseTermination::PayloadEnvelopesByRange => Protocol::PayloadEnvelopesByRange, + ResponseTermination::PayloadEnvelopesByRoot => Protocol::PayloadEnvelopesByRoot, ResponseTermination::BlobsByRange => Protocol::BlobsByRange, ResponseTermination::BlobsByRoot => Protocol::BlobsByRoot, ResponseTermination::DataColumnsByRoot => Protocol::DataColumnsByRoot, @@ -744,6 +793,8 @@ impl RpcSuccessResponse { RpcSuccessResponse::Status(_) => Protocol::Status, RpcSuccessResponse::BlocksByRange(_) => Protocol::BlocksByRange, RpcSuccessResponse::BlocksByRoot(_) => Protocol::BlocksByRoot, + RpcSuccessResponse::PayloadEnvelopesByRange(_) => Protocol::PayloadEnvelopesByRange, + RpcSuccessResponse::PayloadEnvelopesByRoot(_) => Protocol::PayloadEnvelopesByRoot, RpcSuccessResponse::BlobsByRange(_) => Protocol::BlobsByRange, RpcSuccessResponse::BlobsByRoot(_) => Protocol::BlobsByRoot, RpcSuccessResponse::DataColumnsByRoot(_) => Protocol::DataColumnsByRoot, @@ -762,6 +813,7 @@ impl RpcSuccessResponse { pub fn slot(&self) -> Option { match self { Self::BlocksByRange(r) | Self::BlocksByRoot(r) => Some(r.slot()), + Self::PayloadEnvelopesByRoot(r) | Self::PayloadEnvelopesByRange(r) => Some(r.slot()), Self::BlobsByRange(r) | Self::BlobsByRoot(r) => Some(r.slot()), Self::DataColumnsByRange(r) | Self::DataColumnsByRoot(r) => Some(r.slot()), Self::LightClientBootstrap(r) => Some(r.get_slot()), @@ -812,6 +864,20 @@ impl std::fmt::Display for RpcSuccessResponse { RpcSuccessResponse::BlocksByRoot(block) => { write!(f, "BlocksByRoot: Block slot: {}", block.slot()) } + RpcSuccessResponse::PayloadEnvelopesByRange(envelope) => { + write!( + f, + "ExecutionPayloadEnvelopesByRange: Envelope slot: {}", + envelope.slot() + ) + } + RpcSuccessResponse::PayloadEnvelopesByRoot(envelope) => { + write!( + f, + "ExecutionPayloadEnvelopesByRoot: Envelope slot: {}", + envelope.slot() + ) + } RpcSuccessResponse::BlobsByRange(blob) => { write!(f, "BlobsByRange: Blob slot: {}", blob.slot()) } diff --git a/beacon_node/lighthouse_network/src/rpc/protocol.rs b/beacon_node/lighthouse_network/src/rpc/protocol.rs index b75ca72eda..2c92e17c44 100644 --- a/beacon_node/lighthouse_network/src/rpc/protocol.rs +++ b/beacon_node/lighthouse_network/src/rpc/protocol.rs @@ -22,7 +22,7 @@ use types::{ LightClientBootstrap, LightClientBootstrapAltair, LightClientFinalityUpdate, LightClientFinalityUpdateAltair, LightClientOptimisticUpdate, LightClientOptimisticUpdateAltair, LightClientUpdate, MainnetEthSpec, MinimalEthSpec, - SignedBeaconBlock, + SignedBeaconBlock, SignedExecutionPayloadEnvelope, }; // Note: Hardcoding the `EthSpec` type for `SignedBeaconBlock` as min/max values is @@ -65,6 +65,12 @@ pub static SIGNED_BEACON_BLOCK_BELLATRIX_MAX: LazyLock = + types::ExecutionPayload::::max_execution_payload_bellatrix_size() // adding max size of execution payload (~16gb) + ssz::BYTES_PER_LENGTH_OFFSET); // Adding the additional ssz offset for the `ExecutionPayload` field +pub static SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MIN: LazyLock = + LazyLock::new(SignedExecutionPayloadEnvelope::::min_size); + +pub static SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MAX: LazyLock = + LazyLock::new(SignedExecutionPayloadEnvelope::::max_size); + pub static BLOB_SIDECAR_SIZE: LazyLock = LazyLock::new(BlobSidecar::::max_size); @@ -140,13 +146,30 @@ pub fn rpc_block_limits_by_fork(current_fork: ForkName) -> RpcLimits { ), // After the merge the max SSZ size of a block is absurdly big. The size is actually // bound by other constants, so here we default to the bellatrix's max value - _ => RpcLimits::new( - *SIGNED_BEACON_BLOCK_BASE_MIN, // Base block is smaller than altair and bellatrix blocks - *SIGNED_BEACON_BLOCK_BELLATRIX_MAX, // Bellatrix block is larger than base and altair blocks + // After the merge the max SSZ size includes the execution payload. + // Gloas blocks no longer contain the execution payload, but we must + // still accept pre-Gloas blocks during historical sync, so we keep the + // Bellatrix max as the upper bound. + ForkName::Bellatrix + | ForkName::Capella + | ForkName::Deneb + | ForkName::Electra + | ForkName::Fulu + | ForkName::Gloas => RpcLimits::new( + *SIGNED_BEACON_BLOCK_BASE_MIN, + *SIGNED_BEACON_BLOCK_BELLATRIX_MAX, ), } } +/// Returns the rpc limits for payload_envelope_by_range and payload_envelope_by_root responses. +pub fn rpc_payload_limits() -> RpcLimits { + RpcLimits::new( + *SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MIN, + *SIGNED_EXECUTION_PAYLOAD_ENVELOPE_MAX, + ) +} + fn rpc_light_client_updates_by_range_limits_by_fork(current_fork: ForkName) -> RpcLimits { let altair_fixed_len = LightClientFinalityUpdateAltair::::ssz_fixed_len(); @@ -242,6 +265,12 @@ pub enum Protocol { /// The `BlobsByRange` protocol name. #[strum(serialize = "blob_sidecars_by_range")] BlobsByRange, + /// The `ExecutionPayloadEnvelopesByRoot` protocol name. + #[strum(serialize = "execution_payload_envelopes_by_root")] + PayloadEnvelopesByRoot, + /// The `ExecutionPayloadEnvelopesByRange` protocol name. + #[strum(serialize = "execution_payload_envelopes_by_range")] + PayloadEnvelopesByRange, /// The `BlobsByRoot` protocol name. #[strum(serialize = "blob_sidecars_by_root")] BlobsByRoot, @@ -277,6 +306,8 @@ impl Protocol { Protocol::Goodbye => None, Protocol::BlocksByRange => Some(ResponseTermination::BlocksByRange), Protocol::BlocksByRoot => Some(ResponseTermination::BlocksByRoot), + Protocol::PayloadEnvelopesByRange => Some(ResponseTermination::PayloadEnvelopesByRange), + Protocol::PayloadEnvelopesByRoot => Some(ResponseTermination::PayloadEnvelopesByRoot), Protocol::BlobsByRange => Some(ResponseTermination::BlobsByRange), Protocol::BlobsByRoot => Some(ResponseTermination::BlobsByRoot), Protocol::DataColumnsByRoot => Some(ResponseTermination::DataColumnsByRoot), @@ -307,6 +338,8 @@ pub enum SupportedProtocol { BlocksByRangeV2, BlocksByRootV1, BlocksByRootV2, + PayloadEnvelopesByRangeV1, + PayloadEnvelopesByRootV1, BlobsByRangeV1, BlobsByRootV1, DataColumnsByRootV1, @@ -329,6 +362,8 @@ impl SupportedProtocol { SupportedProtocol::GoodbyeV1 => "1", SupportedProtocol::BlocksByRangeV1 => "1", SupportedProtocol::BlocksByRangeV2 => "2", + SupportedProtocol::PayloadEnvelopesByRangeV1 => "1", + SupportedProtocol::PayloadEnvelopesByRootV1 => "1", SupportedProtocol::BlocksByRootV1 => "1", SupportedProtocol::BlocksByRootV2 => "2", SupportedProtocol::BlobsByRangeV1 => "1", @@ -355,6 +390,8 @@ impl SupportedProtocol { SupportedProtocol::BlocksByRangeV2 => Protocol::BlocksByRange, SupportedProtocol::BlocksByRootV1 => Protocol::BlocksByRoot, SupportedProtocol::BlocksByRootV2 => Protocol::BlocksByRoot, + SupportedProtocol::PayloadEnvelopesByRangeV1 => Protocol::PayloadEnvelopesByRange, + SupportedProtocol::PayloadEnvelopesByRootV1 => Protocol::PayloadEnvelopesByRoot, SupportedProtocol::BlobsByRangeV1 => Protocol::BlobsByRange, SupportedProtocol::BlobsByRootV1 => Protocol::BlobsByRoot, SupportedProtocol::DataColumnsByRootV1 => Protocol::DataColumnsByRoot, @@ -409,6 +446,18 @@ impl SupportedProtocol { ProtocolId::new(SupportedProtocol::DataColumnsByRangeV1, Encoding::SSZSnappy), ]); } + if fork_context.fork_exists(ForkName::Gloas) { + supported.extend_from_slice(&[ + ProtocolId::new( + SupportedProtocol::PayloadEnvelopesByRangeV1, + Encoding::SSZSnappy, + ), + ProtocolId::new( + SupportedProtocol::PayloadEnvelopesByRootV1, + Encoding::SSZSnappy, + ), + ]); + } supported } } @@ -511,6 +560,13 @@ impl ProtocolId { ::ssz_fixed_len(), ), Protocol::BlocksByRoot => RpcLimits::new(0, spec.max_blocks_by_root_request), + Protocol::PayloadEnvelopesByRange => RpcLimits::new( + ::ssz_fixed_len(), + ::ssz_fixed_len(), + ), + Protocol::PayloadEnvelopesByRoot => { + RpcLimits::new(0, spec.max_payload_envelopes_by_root_request) + } Protocol::BlobsByRange => RpcLimits::new( ::ssz_fixed_len(), ::ssz_fixed_len(), @@ -549,6 +605,8 @@ impl ProtocolId { Protocol::Goodbye => RpcLimits::new(0, 0), // Goodbye request has no response Protocol::BlocksByRange => rpc_block_limits_by_fork(fork_context.current_fork_name()), Protocol::BlocksByRoot => rpc_block_limits_by_fork(fork_context.current_fork_name()), + Protocol::PayloadEnvelopesByRange => rpc_payload_limits(), + Protocol::PayloadEnvelopesByRoot => rpc_payload_limits(), Protocol::BlobsByRange => rpc_blob_limits::(), Protocol::BlobsByRoot => rpc_blob_limits::(), Protocol::DataColumnsByRoot => { @@ -586,6 +644,8 @@ impl ProtocolId { match self.versioned_protocol { SupportedProtocol::BlocksByRangeV2 | SupportedProtocol::BlocksByRootV2 + | SupportedProtocol::PayloadEnvelopesByRangeV1 + | SupportedProtocol::PayloadEnvelopesByRootV1 | SupportedProtocol::BlobsByRangeV1 | SupportedProtocol::BlobsByRootV1 | SupportedProtocol::DataColumnsByRootV1 @@ -737,6 +797,8 @@ pub enum RequestType { Goodbye(GoodbyeReason), BlocksByRange(OldBlocksByRangeRequest), BlocksByRoot(BlocksByRootRequest), + PayloadEnvelopesByRange(PayloadEnvelopesByRangeRequest), + PayloadEnvelopesByRoot(PayloadEnvelopesByRootRequest), BlobsByRange(BlobsByRangeRequest), BlobsByRoot(BlobsByRootRequest), DataColumnsByRoot(DataColumnsByRootRequest), @@ -760,6 +822,8 @@ impl RequestType { RequestType::Goodbye(_) => 0, RequestType::BlocksByRange(req) => *req.count(), RequestType::BlocksByRoot(req) => req.block_roots().len() as u64, + RequestType::PayloadEnvelopesByRange(req) => req.count, + RequestType::PayloadEnvelopesByRoot(req) => req.beacon_block_roots.len() as u64, RequestType::BlobsByRange(req) => req.max_blobs_requested(digest_epoch, spec), RequestType::BlobsByRoot(req) => req.blob_ids.len() as u64, RequestType::DataColumnsByRoot(req) => req.max_requested() as u64, @@ -789,6 +853,8 @@ impl RequestType { BlocksByRootRequest::V1(_) => SupportedProtocol::BlocksByRootV1, BlocksByRootRequest::V2(_) => SupportedProtocol::BlocksByRootV2, }, + RequestType::PayloadEnvelopesByRange(_) => SupportedProtocol::PayloadEnvelopesByRangeV1, + RequestType::PayloadEnvelopesByRoot(_) => SupportedProtocol::PayloadEnvelopesByRootV1, RequestType::BlobsByRange(_) => SupportedProtocol::BlobsByRangeV1, RequestType::BlobsByRoot(_) => SupportedProtocol::BlobsByRootV1, RequestType::DataColumnsByRoot(_) => SupportedProtocol::DataColumnsByRootV1, @@ -820,6 +886,8 @@ impl RequestType { // variants that have `multiple_responses()` can have values. RequestType::BlocksByRange(_) => ResponseTermination::BlocksByRange, RequestType::BlocksByRoot(_) => ResponseTermination::BlocksByRoot, + RequestType::PayloadEnvelopesByRange(_) => ResponseTermination::PayloadEnvelopesByRange, + RequestType::PayloadEnvelopesByRoot(_) => ResponseTermination::PayloadEnvelopesByRoot, RequestType::BlobsByRange(_) => ResponseTermination::BlobsByRange, RequestType::BlobsByRoot(_) => ResponseTermination::BlobsByRoot, RequestType::DataColumnsByRoot(_) => ResponseTermination::DataColumnsByRoot, @@ -854,6 +922,14 @@ impl RequestType { ProtocolId::new(SupportedProtocol::BlocksByRootV2, Encoding::SSZSnappy), ProtocolId::new(SupportedProtocol::BlocksByRootV1, Encoding::SSZSnappy), ], + RequestType::PayloadEnvelopesByRange(_) => vec![ProtocolId::new( + SupportedProtocol::PayloadEnvelopesByRangeV1, + Encoding::SSZSnappy, + )], + RequestType::PayloadEnvelopesByRoot(_) => vec![ProtocolId::new( + SupportedProtocol::PayloadEnvelopesByRootV1, + Encoding::SSZSnappy, + )], RequestType::BlobsByRange(_) => vec![ProtocolId::new( SupportedProtocol::BlobsByRangeV1, Encoding::SSZSnappy, @@ -905,6 +981,8 @@ impl RequestType { RequestType::BlocksByRange(_) => false, RequestType::BlocksByRoot(_) => false, RequestType::BlobsByRange(_) => false, + RequestType::PayloadEnvelopesByRange(_) => false, + RequestType::PayloadEnvelopesByRoot(_) => false, RequestType::BlobsByRoot(_) => false, RequestType::DataColumnsByRoot(_) => false, RequestType::DataColumnsByRange(_) => false, @@ -1015,6 +1093,12 @@ impl std::fmt::Display for RequestType { RequestType::Goodbye(reason) => write!(f, "Goodbye: {}", reason), RequestType::BlocksByRange(req) => write!(f, "Blocks by range: {}", req), RequestType::BlocksByRoot(req) => write!(f, "Blocks by root: {:?}", req), + RequestType::PayloadEnvelopesByRange(req) => { + write!(f, "Payload envelopes by range: {:?}", req) + } + RequestType::PayloadEnvelopesByRoot(req) => { + write!(f, "Payload envelopes by root: {:?}", req) + } RequestType::BlobsByRange(req) => write!(f, "Blobs by range: {:?}", req), RequestType::BlobsByRoot(req) => write!(f, "Blobs by root: {:?}", req), RequestType::DataColumnsByRoot(req) => write!(f, "Data columns by root: {:?}", req), diff --git a/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs b/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs index 2407038bc3..ebdca386d8 100644 --- a/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs +++ b/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs @@ -109,7 +109,11 @@ pub struct RPCRateLimiter { blbrange_rl: Limiter, /// BlobsByRoot rate limiter. blbroot_rl: Limiter, - /// DataColumnssByRoot rate limiter. + /// PayloadEnvelopesByRange rate limiter. + envrange_rl: Limiter, + /// PayloadEnvelopesByRoot rate limiter. + envroots_rl: Limiter, + /// DataColumnsByRoot rate limiter. dcbroot_rl: Limiter, /// DataColumnsByRange rate limiter. dcbrange_rl: Limiter, @@ -148,6 +152,10 @@ pub struct RPCRateLimiterBuilder { bbrange_quota: Option, /// Quota for the BlocksByRoot protocol. bbroots_quota: Option, + /// Quota for the ExecutionPayloadEnvelopesByRange protocol. + perange_quota: Option, + /// Quota for the ExecutionPayloadEnvelopesByRoot protocol. + peroots_quota: Option, /// Quota for the BlobsByRange protocol. blbrange_quota: Option, /// Quota for the BlobsByRoot protocol. @@ -177,6 +185,8 @@ impl RPCRateLimiterBuilder { Protocol::Goodbye => self.goodbye_quota = q, Protocol::BlocksByRange => self.bbrange_quota = q, Protocol::BlocksByRoot => self.bbroots_quota = q, + Protocol::PayloadEnvelopesByRange => self.perange_quota = q, + Protocol::PayloadEnvelopesByRoot => self.peroots_quota = q, Protocol::BlobsByRange => self.blbrange_quota = q, Protocol::BlobsByRoot => self.blbroot_quota = q, Protocol::DataColumnsByRoot => self.dcbroot_quota = q, @@ -201,6 +211,12 @@ impl RPCRateLimiterBuilder { let bbrange_quota = self .bbrange_quota .ok_or("BlocksByRange quota not specified")?; + let perange_quota = self + .perange_quota + .ok_or("PayloadEnvelopesByRange quota not specified")?; + let peroots_quota = self + .peroots_quota + .ok_or("PayloadEnvelopesByRoot quota not specified")?; let lc_bootstrap_quota = self .lcbootstrap_quota .ok_or("LightClientBootstrap quota not specified")?; @@ -236,6 +252,8 @@ impl RPCRateLimiterBuilder { let goodbye_rl = Limiter::from_quota(goodbye_quota)?; let bbroots_rl = Limiter::from_quota(bbroots_quota)?; let bbrange_rl = Limiter::from_quota(bbrange_quota)?; + let envrange_rl = Limiter::from_quota(perange_quota)?; + let envroots_rl = Limiter::from_quota(peroots_quota)?; let blbrange_rl = Limiter::from_quota(blbrange_quota)?; let blbroot_rl = Limiter::from_quota(blbroots_quota)?; let dcbroot_rl = Limiter::from_quota(dcbroot_quota)?; @@ -259,6 +277,8 @@ impl RPCRateLimiterBuilder { goodbye_rl, bbroots_rl, bbrange_rl, + envrange_rl, + envroots_rl, blbrange_rl, blbroot_rl, dcbroot_rl, @@ -312,6 +332,8 @@ impl RPCRateLimiter { goodbye_quota, blocks_by_range_quota, blocks_by_root_quota, + payload_envelopes_by_range_quota, + payload_envelopes_by_root_quota, blobs_by_range_quota, blobs_by_root_quota, data_columns_by_root_quota, @@ -329,6 +351,14 @@ impl RPCRateLimiter { .set_quota(Protocol::Goodbye, goodbye_quota) .set_quota(Protocol::BlocksByRange, blocks_by_range_quota) .set_quota(Protocol::BlocksByRoot, blocks_by_root_quota) + .set_quota( + Protocol::PayloadEnvelopesByRange, + payload_envelopes_by_range_quota, + ) + .set_quota( + Protocol::PayloadEnvelopesByRoot, + payload_envelopes_by_root_quota, + ) .set_quota(Protocol::BlobsByRange, blobs_by_range_quota) .set_quota(Protocol::BlobsByRoot, blobs_by_root_quota) .set_quota(Protocol::DataColumnsByRoot, data_columns_by_root_quota) @@ -376,6 +406,8 @@ impl RPCRateLimiter { Protocol::Goodbye => &mut self.goodbye_rl, Protocol::BlocksByRange => &mut self.bbrange_rl, Protocol::BlocksByRoot => &mut self.bbroots_rl, + Protocol::PayloadEnvelopesByRange => &mut self.envrange_rl, + Protocol::PayloadEnvelopesByRoot => &mut self.envroots_rl, Protocol::BlobsByRange => &mut self.blbrange_rl, Protocol::BlobsByRoot => &mut self.blbroot_rl, Protocol::DataColumnsByRoot => &mut self.dcbroot_rl, @@ -400,6 +432,8 @@ impl RPCRateLimiter { status_rl, bbrange_rl, bbroots_rl, + envrange_rl, + envroots_rl, blbrange_rl, blbroot_rl, dcbroot_rl, @@ -417,6 +451,8 @@ impl RPCRateLimiter { status_rl.prune(time_since_start); bbrange_rl.prune(time_since_start); bbroots_rl.prune(time_since_start); + envrange_rl.prune(time_since_start); + envroots_rl.prune(time_since_start); blbrange_rl.prune(time_since_start); blbroot_rl.prune(time_since_start); dcbrange_rl.prune(time_since_start); diff --git a/beacon_node/lighthouse_network/src/service/api_types.rs b/beacon_node/lighthouse_network/src/service/api_types.rs index d0323bab52..486a443857 100644 --- a/beacon_node/lighthouse_network/src/service/api_types.rs +++ b/beacon_node/lighthouse_network/src/service/api_types.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use types::{ BlobSidecar, DataColumnSidecar, Epoch, EthSpec, LightClientBootstrap, LightClientFinalityUpdate, LightClientOptimisticUpdate, LightClientUpdate, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, }; pub type Id = u32; @@ -160,6 +161,10 @@ pub enum Response { DataColumnsByRange(Option>>), /// A response to a get BLOCKS_BY_ROOT request. BlocksByRoot(Option>>), + /// A response to a get `EXECUTION_PAYLOAD_ENVELOPES_BY_ROOT` request. + PayloadEnvelopesByRoot(Option>>), + /// A response to a get `EXECUTION_PAYLOAD_ENVELOPES_BY_RANGE` request. + PayloadEnvelopesByRange(Option>>), /// A response to a get BLOBS_BY_ROOT request. BlobsByRoot(Option>>), /// A response to a get DATA_COLUMN_SIDECARS_BY_ROOT request. @@ -185,6 +190,16 @@ impl std::convert::From> for RpcResponse { Some(b) => RpcResponse::Success(RpcSuccessResponse::BlocksByRange(b)), None => RpcResponse::StreamTermination(ResponseTermination::BlocksByRange), }, + Response::PayloadEnvelopesByRoot(r) => match r { + Some(p) => RpcResponse::Success(RpcSuccessResponse::PayloadEnvelopesByRoot(p)), + None => RpcResponse::StreamTermination(ResponseTermination::PayloadEnvelopesByRoot), + }, + Response::PayloadEnvelopesByRange(r) => match r { + Some(p) => RpcResponse::Success(RpcSuccessResponse::PayloadEnvelopesByRange(p)), + None => { + RpcResponse::StreamTermination(ResponseTermination::PayloadEnvelopesByRange) + } + }, Response::BlobsByRoot(r) => match r { Some(b) => RpcResponse::Success(RpcSuccessResponse::BlobsByRoot(b)), None => RpcResponse::StreamTermination(ResponseTermination::BlobsByRoot), diff --git a/beacon_node/lighthouse_network/src/service/mod.rs b/beacon_node/lighthouse_network/src/service/mod.rs index 184a334591..56fcbb3bb6 100644 --- a/beacon_node/lighthouse_network/src/service/mod.rs +++ b/beacon_node/lighthouse_network/src/service/mod.rs @@ -1524,6 +1524,28 @@ impl Network { request_type, }) } + RequestType::PayloadEnvelopesByRange(_) => { + metrics::inc_counter_vec( + &metrics::TOTAL_RPC_REQUESTS, + &["payload_envelopes_by_range"], + ); + Some(NetworkEvent::RequestReceived { + peer_id, + inbound_request_id, + request_type, + }) + } + RequestType::PayloadEnvelopesByRoot(_) => { + metrics::inc_counter_vec( + &metrics::TOTAL_RPC_REQUESTS, + &["payload_envelopes_by_root"], + ); + Some(NetworkEvent::RequestReceived { + peer_id, + inbound_request_id, + request_type, + }) + } RequestType::BlobsByRange(_) => { metrics::inc_counter_vec(&metrics::TOTAL_RPC_REQUESTS, &["blobs_by_range"]); Some(NetworkEvent::RequestReceived { @@ -1638,6 +1660,16 @@ impl Network { RpcSuccessResponse::BlocksByRoot(resp) => { self.build_response(id, peer_id, Response::BlocksByRoot(Some(resp))) } + RpcSuccessResponse::PayloadEnvelopesByRange(resp) => self.build_response( + id, + peer_id, + Response::PayloadEnvelopesByRange(Some(resp)), + ), + RpcSuccessResponse::PayloadEnvelopesByRoot(resp) => self.build_response( + id, + peer_id, + Response::PayloadEnvelopesByRoot(Some(resp)), + ), RpcSuccessResponse::BlobsByRoot(resp) => { self.build_response(id, peer_id, Response::BlobsByRoot(Some(resp))) } @@ -1672,6 +1704,12 @@ impl Network { let response = match termination { ResponseTermination::BlocksByRange => Response::BlocksByRange(None), ResponseTermination::BlocksByRoot => Response::BlocksByRoot(None), + ResponseTermination::PayloadEnvelopesByRange => { + Response::PayloadEnvelopesByRange(None) + } + ResponseTermination::PayloadEnvelopesByRoot => { + Response::PayloadEnvelopesByRoot(None) + } ResponseTermination::BlobsByRange => Response::BlobsByRange(None), ResponseTermination::BlobsByRoot => Response::BlobsByRoot(None), ResponseTermination::DataColumnsByRoot => Response::DataColumnsByRoot(None), diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index e40eacce08..f74e7dacfb 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -15,7 +15,8 @@ use beacon_processor::{ use lighthouse_network::rpc::InboundRequestId; use lighthouse_network::rpc::methods::{ BlobsByRangeRequest, BlobsByRootRequest, DataColumnsByRangeRequest, DataColumnsByRootRequest, - LightClientUpdatesByRangeRequest, + LightClientUpdatesByRangeRequest, PayloadEnvelopesByRangeRequest, + PayloadEnvelopesByRootRequest, }; use lighthouse_network::service::api_types::CustodyBackfillBatchId; use lighthouse_network::{ @@ -693,6 +694,46 @@ impl NetworkBeaconProcessor { }) } + /// Create a new work event to process `PayloadEnvelopesByRootRequest`s from the RPC network. + pub fn send_payload_envelopes_by_roots_request( + self: &Arc, + peer_id: PeerId, + inbound_request_id: InboundRequestId, // Use ResponseId here + request: PayloadEnvelopesByRootRequest, + ) -> Result<(), Error> { + let processor = self.clone(); + let process_fn = async move { + processor + .handle_payload_envelopes_by_root_request(peer_id, inbound_request_id, request) + .await; + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: false, + work: Work::PayloadEnvelopesByRootRequest(Box::pin(process_fn)), + }) + } + + /// Create a new work event to process `PayloadEnvelopesByRangeRequest`s from the RPC network. + pub fn send_payload_envelopes_by_range_request( + self: &Arc, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + request: PayloadEnvelopesByRangeRequest, + ) -> Result<(), Error> { + let processor = self.clone(); + let process_fn = async move { + processor + .handle_payload_envelopes_by_range_request(peer_id, inbound_request_id, request) + .await; + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: false, + work: Work::PayloadEnvelopesByRangeRequest(Box::pin(process_fn)), + }) + } + /// Create a new work event to process `BlobsByRangeRequest`s from the RPC network. pub fn send_blobs_by_range_request( self: &Arc, diff --git a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs index 279870d444..8b31b67acb 100644 --- a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs @@ -3,10 +3,12 @@ use crate::network_beacon_processor::{FUTURE_SLOT_TOLERANCE, NetworkBeaconProces use crate::service::NetworkMessage; use crate::status::ToStatusMessage; use crate::sync::SyncMessage; +use beacon_chain::payload_envelope_streamer::EnvelopeRequestSource; use beacon_chain::{BeaconChainError, BeaconChainTypes, BlockProcessStatus, WhenSlotSkipped}; use itertools::{Itertools, process_results}; use lighthouse_network::rpc::methods::{ BlobsByRangeRequest, BlobsByRootRequest, DataColumnsByRangeRequest, DataColumnsByRootRequest, + PayloadEnvelopesByRangeRequest, PayloadEnvelopesByRootRequest, }; use lighthouse_network::rpc::*; use lighthouse_network::{PeerId, ReportSource, Response, SyncInfo}; @@ -15,7 +17,7 @@ use slot_clock::SlotClock; use std::collections::{HashMap, HashSet, hash_map::Entry}; use std::sync::Arc; use tokio_stream::StreamExt; -use tracing::{Span, debug, error, field, instrument, warn}; +use tracing::{Span, debug, error, field, instrument, trace, warn}; use types::data::BlobIdentifier; use types::{ColumnIndex, Epoch, EthSpec, Hash256, Slot}; @@ -254,6 +256,104 @@ impl NetworkBeaconProcessor { Ok(()) } + /// Handle a `ExecutionPayloadEnvelopesByRoot` request from the peer. + #[instrument( + name = "lh_handle_payload_envelopes_by_root_request", + parent = None, + level = "debug", + skip_all, + fields(peer_id = %peer_id, client = tracing::field::Empty) + )] + pub async fn handle_payload_envelopes_by_root_request( + self: Arc, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + request: PayloadEnvelopesByRootRequest, + ) { + let client = self.network_globals.client(&peer_id); + Span::current().record("client", field::display(client.kind)); + + self.terminate_response_stream( + peer_id, + inbound_request_id, + self.clone() + .handle_payload_envelopes_by_root_request_inner( + peer_id, + inbound_request_id, + request, + ) + .await, + Response::PayloadEnvelopesByRoot, + ); + } + + /// Handle a `ExecutionPayloadEnvelopesByRoot` request from the peer. + async fn handle_payload_envelopes_by_root_request_inner( + self: Arc, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + request: PayloadEnvelopesByRootRequest, + ) -> Result<(), (RpcErrorResponse, &'static str)> { + let log_results = |peer_id, requested_envelopes, send_envelope_count| { + debug!( + %peer_id, + requested = requested_envelopes, + returned = %send_envelope_count, + "ExecutionPayloadEnvelopes outgoing response processed" + ); + }; + + let requested_envelopes = request.beacon_block_roots.len(); + let mut envelope_stream = self.chain.get_payload_envelopes( + request.beacon_block_roots.to_vec(), + EnvelopeRequestSource::ByRoot, + ); + // Fetching payload envelopes is async because it may have to hit the execution layer for payloads. + let mut send_envelope_count = 0; + while let Some((root, result)) = envelope_stream.next().await { + match result.as_ref() { + Ok(Some(envelope)) => { + self.send_response( + peer_id, + inbound_request_id, + Response::PayloadEnvelopesByRoot(Some(envelope.clone())), + ); + send_envelope_count += 1; + } + Ok(None) => { + debug!( + %peer_id, + request_root = ?root, + "Peer requested unknown payload envelope" + ); + } + Err(BeaconChainError::BlockHashMissingFromExecutionLayer(_)) => { + debug!( + block_root = ?root, + reason = "execution layer not synced", + "Failed to fetch execution payload for payload envelopes by root request" + ); + log_results(peer_id, requested_envelopes, send_envelope_count); + return Err(( + RpcErrorResponse::ResourceUnavailable, + "Execution layer not synced", + )); + } + Err(e) => { + debug!( + ?peer_id, + request_root = ?root, + error = ?e, + "Error fetching payload envelope for peer" + ); + } + } + } + log_results(peer_id, requested_envelopes, send_envelope_count); + + Ok(()) + } + /// Handle a `BlobsByRoot` request from the peer. #[instrument( name = "lh_handle_blobs_by_root_request", @@ -983,6 +1083,189 @@ impl NetworkBeaconProcessor { .collect::>()) } + /// Handle a `ExecutionPayloadEnvelopesByRange` request from the peer. + #[instrument( + name = "lh_handle_payload_envelopes_by_range_request", + parent = None, + level = "debug", + skip_all, + fields(peer_id = %peer_id, client = tracing::field::Empty) + )] + pub async fn handle_payload_envelopes_by_range_request( + self: Arc, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + req: PayloadEnvelopesByRangeRequest, + ) { + let client = self.network_globals.client(&peer_id); + Span::current().record("client", field::display(client.kind)); + + self.terminate_response_stream( + peer_id, + inbound_request_id, + self.clone() + .handle_payload_envelopes_by_range_request_inner(peer_id, inbound_request_id, req) + .await, + Response::PayloadEnvelopesByRange, + ); + } + + /// Handle a `ExecutionPayloadEnvelopesByRange` request from the peer. + async fn handle_payload_envelopes_by_range_request_inner( + self: Arc, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + req: PayloadEnvelopesByRangeRequest, + ) -> Result<(), (RpcErrorResponse, &'static str)> { + let req_start_slot = req.start_slot; + let req_count = req.count; + + debug!( + %peer_id, + count = req_count, + start_slot = %req_start_slot, + "Received ExecutionPayloadEnvelopesByRange Request" + ); + + let request_start_slot = Slot::from(req_start_slot); + let fork_name = self + .chain + .spec + .fork_name_at_slot::(request_start_slot); + + if !fork_name.gloas_enabled() { + return Err(( + RpcErrorResponse::InvalidRequest, + "Requested envelopes for pre-gloas slots", + )); + } + + // Spawn a blocking handle since get_block_roots_for_slot_range takes a sync lock on the + // fork-choice. + let network_beacon_processor = self.clone(); + let block_roots = self + .executor + .spawn_blocking_handle( + move || { + network_beacon_processor.get_block_roots_for_slot_range( + req_start_slot, + req_count, + "ExecutionPayloadEnvelopesByRange", + ) + }, + "get_block_roots_for_slot_range", + ) + .ok_or((RpcErrorResponse::ServerError, "shutting down"))? + .await + .map_err(|_| (RpcErrorResponse::ServerError, "tokio join"))?? + .iter() + .map(|(root, _)| *root) + .collect::>(); + + let current_slot = self + .chain + .slot() + .unwrap_or_else(|_| self.chain.slot_clock.genesis_slot()); + + let log_results = |peer_id, payloads_sent| { + if payloads_sent < (req_count as usize) { + debug!( + %peer_id, + msg = "Failed to return all requested payload envelopes", + start_slot = %req_start_slot, + %current_slot, + requested = req_count, + returned = payloads_sent, + "ExecutionPayloadEnvelopesByRange outgoing response processed" + ); + } else { + debug!( + %peer_id, + start_slot = %req_start_slot, + %current_slot, + requested = req_count, + returned = payloads_sent, + "ExecutionPayloadEnvelopesByRange outgoing response processed" + ); + } + }; + + let mut envelope_stream = self + .chain + .get_payload_envelopes(block_roots, EnvelopeRequestSource::ByRange); + + // Fetching payload envelopes is async because it may have to hit the execution layer for payloads. + let mut envelopes_sent = 0; + while let Some((root, result)) = envelope_stream.next().await { + match result.as_ref() { + Ok(Some(envelope)) => { + // Due to skip slots, blocks could be out of the range, we ensure they + // are in the range before sending + if envelope.slot() >= req_start_slot + && envelope.slot() < req_start_slot.saturating_add(req.count) + { + envelopes_sent += 1; + self.send_network_message(NetworkMessage::SendResponse { + peer_id, + inbound_request_id, + response: Response::PayloadEnvelopesByRange(Some(envelope.clone())), + }); + } + } + Ok(None) => { + trace!( + request = ?req, + %peer_id, + request_root = ?root, + "No envelope for block root" + ); + } + Err(BeaconChainError::BlockHashMissingFromExecutionLayer(_)) => { + debug!( + block_root = ?root, + reason = "execution layer not synced", + "Failed to fetch execution payload for envelope by range request" + ); + log_results(peer_id, envelopes_sent); + // send the stream terminator + return Err(( + RpcErrorResponse::ResourceUnavailable, + "Execution layer not synced", + )); + } + Err(e) => { + if matches!( + e, + BeaconChainError::ExecutionLayerErrorPayloadReconstruction(_block_hash, boxed_error) + if matches!(**boxed_error, execution_layer::Error::EngineError(_)) + ) { + warn!( + info = "this may occur occasionally when the EE is busy", + block_root = ?root, + error = ?e, + "Error rebuilding payload for peer" + ); + } else { + error!( + block_root = ?root, + error = ?e, + "Error fetching payload envelope for peer" + ); + } + log_results(peer_id, envelopes_sent); + // send the stream terminator + return Err(( + RpcErrorResponse::ServerError, + "Failed fetching payload envelopes", + )); + } + } + } + + log_results(peer_id, envelopes_sent); + Ok(()) + } + /// Handle a `BlobsByRange` request from the peer. #[instrument( name = "lh_handle_blobs_by_range_request", diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index c5ccbc2ae6..d0f0557223 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -19,11 +19,14 @@ use beacon_chain::test_utils::{ }; use beacon_chain::{BeaconChain, WhenSlotSkipped}; use beacon_processor::{work_reprocessing_queue::*, *}; +use bls::Signature; +use fixed_bytes::FixedBytesExtended; use itertools::Itertools; use libp2p::gossipsub::MessageAcceptance; use lighthouse_network::rpc::InboundRequestId; use lighthouse_network::rpc::methods::{ BlobsByRangeRequest, BlobsByRootRequest, DataColumnsByRangeRequest, MetaDataV3, + PayloadEnvelopesByRangeRequest, PayloadEnvelopesByRootRequest, }; use lighthouse_network::{ Client, MessageId, NetworkConfig, NetworkGlobals, PeerId, Response, @@ -41,8 +44,9 @@ use std::time::Duration; use tokio::sync::mpsc; use types::{ AttesterSlashing, BlobSidecar, ChainSpec, DataColumnSidecarList, DataColumnSubnetId, Epoch, - EthSpec, Hash256, MainnetEthSpec, ProposerSlashing, SignedAggregateAndProof, SignedBeaconBlock, - SignedVoluntaryExit, SingleAttestation, Slot, SubnetId, + EthSpec, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, Hash256, + MainnetEthSpec, ProposerSlashing, SignedAggregateAndProof, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, SignedVoluntaryExit, SingleAttestation, Slot, SubnetId, }; use types::{ BlobSidecarList, @@ -522,6 +526,29 @@ impl TestRig { .unwrap(); } + pub fn enqueue_payload_envelopes_by_range_request(&self, start_slot: u64, count: u64) { + self.network_beacon_processor + .send_payload_envelopes_by_range_request( + PeerId::random(), + InboundRequestId::new_unchecked(42, 24), + PayloadEnvelopesByRangeRequest { start_slot, count }, + ) + .unwrap(); + } + + pub fn enqueue_payload_envelopes_by_root_request( + &self, + beacon_block_roots: RuntimeVariableList, + ) { + self.network_beacon_processor + .send_payload_envelopes_by_roots_request( + PeerId::random(), + InboundRequestId::new_unchecked(42, 24), + PayloadEnvelopesByRootRequest { beacon_block_roots }, + ) + .unwrap(); + } + pub fn enqueue_backfill_batch(&self, epoch: Epoch) { self.network_beacon_processor .send_chain_segment( @@ -2091,6 +2118,229 @@ async fn test_data_columns_by_range_no_duplicates_with_skip_slots() { ); } +/// Create a test `SignedExecutionPayloadEnvelope` with the given slot and beacon block root. +fn make_test_payload_envelope( + slot: Slot, + beacon_block_root: Hash256, +) -> SignedExecutionPayloadEnvelope { + SignedExecutionPayloadEnvelope { + message: ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequests::default(), + builder_index: 0, + beacon_block_root, + slot, + state_root: Hash256::zero(), + }, + signature: Signature::empty(), + } +} + +#[tokio::test] +async fn test_payload_envelopes_by_range() { + // Only test when Gloas fork is scheduled + if test_spec::().gloas_fork_epoch.is_none() { + return; + }; + + let mut rig = TestRig::new(64).await; + let start_slot = 0; + let slot_count = 32; + + // Manually store payload envelopes for each block in the range + let mut expected_roots = Vec::new(); + for slot in start_slot..slot_count { + if let Some(root) = rig + .chain + .block_root_at_slot(Slot::new(slot), WhenSlotSkipped::None) + .unwrap() + { + let envelope = make_test_payload_envelope(Slot::new(slot), root); + rig.chain + .store + .put_payload_envelope(&root, envelope) + .unwrap(); + expected_roots.push(root); + } + } + + rig.enqueue_payload_envelopes_by_range_request(start_slot, slot_count); + + let mut actual_roots = Vec::new(); + while let Some(next) = rig.network_rx.recv().await { + if let NetworkMessage::SendResponse { + peer_id: _, + response: Response::PayloadEnvelopesByRange(envelope), + inbound_request_id: _, + } = next + { + if let Some(env) = envelope { + actual_roots.push(env.beacon_block_root()); + } else { + break; + } + } else if let NetworkMessage::SendErrorResponse { .. } = next { + // Error response terminates the stream + break; + } else { + panic!("unexpected message {:?}", next); + } + } + assert_eq!(expected_roots, actual_roots); +} + +#[tokio::test] +async fn test_payload_envelopes_by_root() { + // Only test when Gloas fork is scheduled + if test_spec::().gloas_fork_epoch.is_none() { + return; + }; + + let mut rig = TestRig::new(64).await; + + let block_root = rig + .chain + .block_root_at_slot(Slot::new(1), WhenSlotSkipped::None) + .unwrap() + .unwrap(); + + // Manually store a payload envelope for this block + let envelope = make_test_payload_envelope(Slot::new(1), block_root); + rig.chain + .store + .put_payload_envelope(&block_root, envelope) + .unwrap(); + + let roots = RuntimeVariableList::new(vec![block_root], 1).unwrap(); + rig.enqueue_payload_envelopes_by_root_request(roots); + + let mut actual_roots = Vec::new(); + while let Some(next) = rig.network_rx.recv().await { + if let NetworkMessage::SendResponse { + peer_id: _, + response: Response::PayloadEnvelopesByRoot(envelope), + inbound_request_id: _, + } = next + { + if let Some(env) = envelope { + actual_roots.push(env.beacon_block_root()); + } else { + break; + } + } else { + panic!("unexpected message {:?}", next); + } + } + assert_eq!(vec![block_root], actual_roots); +} + +#[tokio::test] +async fn test_payload_envelopes_by_root_unknown_root_returns_empty() { + // Only test when Gloas fork is scheduled + if test_spec::().gloas_fork_epoch.is_none() { + return; + }; + + let mut rig = TestRig::new(64).await; + + // Request envelope for a root that has no stored envelope + let block_root = rig + .chain + .block_root_at_slot(Slot::new(1), WhenSlotSkipped::None) + .unwrap() + .unwrap(); + + // Don't store any envelope — the handler should return 0 envelopes + let roots = RuntimeVariableList::new(vec![block_root], 1).unwrap(); + rig.enqueue_payload_envelopes_by_root_request(roots); + + let mut actual_count = 0; + while let Some(next) = rig.network_rx.recv().await { + if let NetworkMessage::SendResponse { + peer_id: _, + response: Response::PayloadEnvelopesByRoot(envelope), + inbound_request_id: _, + } = next + { + if envelope.is_some() { + actual_count += 1; + } else { + break; + } + } else { + panic!("unexpected message {:?}", next); + } + } + assert_eq!(0, actual_count); +} + +#[tokio::test] +async fn test_payload_envelopes_by_range_no_duplicates_with_skip_slots() { + // Only test when Gloas fork is scheduled + if test_spec::().gloas_fork_epoch.is_none() { + return; + }; + + // Build a chain of 128 slots (4 epochs) with skip slots at positions 5 and 6. + let skip_slots: HashSet = [5, 6].into_iter().collect(); + let mut rig = TestRig::new_with_skip_slots(128, &skip_slots).await; + + let start_slot = 0u64; + let slot_count = 10u64; + + // Store payload envelopes for all blocks in the range (skipping the skip slots) + for slot in start_slot..slot_count { + if let Some(root) = rig + .chain + .block_root_at_slot(Slot::new(slot), WhenSlotSkipped::None) + .unwrap() + { + let envelope = make_test_payload_envelope(Slot::new(slot), root); + rig.chain + .store + .put_payload_envelope(&root, envelope) + .unwrap(); + } + } + + rig.enqueue_payload_envelopes_by_range_request(start_slot, slot_count); + + let mut beacon_block_roots: Vec = Vec::new(); + while let Some(next) = rig.network_rx.recv().await { + if let NetworkMessage::SendResponse { + peer_id: _, + response: Response::PayloadEnvelopesByRange(envelope), + inbound_request_id: _, + } = next + { + if let Some(env) = envelope { + beacon_block_roots.push(env.beacon_block_root()); + } else { + break; + } + } else if let NetworkMessage::SendErrorResponse { .. } = next { + break; + } else { + panic!("unexpected message {:?}", next); + } + } + + assert!( + !beacon_block_roots.is_empty(), + "Should have received at least some payload envelopes" + ); + + // Skip slots should not cause duplicate envelopes for the same block root + let unique_roots: HashSet<_> = beacon_block_roots.iter().collect(); + assert_eq!( + beacon_block_roots.len(), + unique_roots.len(), + "Response contained duplicate block roots: got {} envelopes but only {} unique roots", + beacon_block_roots.len(), + unique_roots.len(), + ); +} + // TODO(ePBS): Add integration tests for envelope deferral (UnknownBlockForEnvelope): // 1. Gossip envelope arrives before its block → queued via UnknownBlockForEnvelope // 2. Block imported → envelope released and processed successfully diff --git a/beacon_node/network/src/router.rs b/beacon_node/network/src/router.rs index 77d64c92e6..e6982e6a84 100644 --- a/beacon_node/network/src/router.rs +++ b/beacon_node/network/src/router.rs @@ -229,6 +229,24 @@ impl Router { request, ), ), + RequestType::PayloadEnvelopesByRoot(request) => self + .handle_beacon_processor_send_result( + self.network_beacon_processor + .send_payload_envelopes_by_roots_request( + peer_id, + inbound_request_id, + request, + ), + ), + RequestType::PayloadEnvelopesByRange(request) => self + .handle_beacon_processor_send_result( + self.network_beacon_processor + .send_payload_envelopes_by_range_request( + peer_id, + inbound_request_id, + request, + ), + ), RequestType::BlobsByRange(request) => self.handle_beacon_processor_send_result( self.network_beacon_processor.send_blobs_by_range_request( peer_id, @@ -309,6 +327,11 @@ impl Router { Response::DataColumnsByRange(data_column) => { self.on_data_columns_by_range_response(peer_id, app_request_id, data_column); } + // TODO(EIP-7732): implement outgoing payload envelopes by range and root + // responses once sync manager requests them. + Response::PayloadEnvelopesByRoot(_) | Response::PayloadEnvelopesByRange(_) => { + debug!("Requesting envelopes by root and by range not supported yet"); + } // Light client responses should not be received Response::LightClientBootstrap(_) | Response::LightClientOptimisticUpdate(_) diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index b6218ba64d..dd6f52426a 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -377,6 +377,16 @@ impl> SignedBeaconBlock .map(|bid| bid.message.block_hash) } + /// Convenience accessor for the block's bid's `parent_block_hash`. + /// + /// This method returns an error prior to Gloas. + pub fn payload_bid_parent_block_hash(&self) -> Result { + self.message() + .body() + .signed_execution_payload_bid() + .map(|bid| bid.message.parent_block_hash) + } + /// Check if the `parent_hash` in this block's `signed_payload_bid` matches `parent_block_hash`. /// /// This function is useful post-Gloas for determining if the parent block is full, *without* diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index adf87dee94..2f3b5da956 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -295,6 +295,7 @@ pub struct ChainSpec { /* * Networking Gloas */ + pub max_request_payloads: u64, /* * Networking Derived @@ -305,6 +306,7 @@ pub struct ChainSpec { pub max_blocks_by_root_request_deneb: usize, pub max_blobs_by_root_request: usize, pub max_data_columns_by_root_request: usize, + pub max_payload_envelopes_by_root_request: usize, /* * Application params @@ -700,6 +702,10 @@ impl ChainSpec { } } + pub fn max_request_payloads(&self) -> usize { + self.max_request_payloads as usize + } + pub fn max_request_blob_sidecars(&self, fork_name: ForkName) -> usize { if fork_name.electra_enabled() { self.max_request_blob_sidecars_electra as usize @@ -964,6 +970,8 @@ impl ChainSpec { max_blobs_by_root_request_common(self.max_request_blob_sidecars); self.max_data_columns_by_root_request = max_data_columns_by_root_request_common::(self.max_request_blocks_deneb); + self.max_payload_envelopes_by_root_request = + max_blocks_by_root_request_common(self.max_request_payloads); self } @@ -1228,6 +1236,7 @@ impl ChainSpec { builder_payment_threshold_numerator: 6, builder_payment_threshold_denominator: 10, min_builder_withdrawability_delay: Epoch::new(4096), + max_request_payloads: 128, /* * Network specific @@ -1293,6 +1302,7 @@ impl ChainSpec { min_epochs_for_data_column_sidecars_requests: default_min_epochs_for_data_column_sidecars_requests(), max_data_columns_by_root_request: default_data_columns_by_root_request(), + max_payload_envelopes_by_root_request: default_max_payload_envelopes_by_root_request(), /* * Application specific @@ -1622,6 +1632,7 @@ impl ChainSpec { builder_payment_threshold_numerator: 6, builder_payment_threshold_denominator: 10, min_builder_withdrawability_delay: Epoch::new(4096), + max_request_payloads: 128, /* * Network specific @@ -1678,6 +1689,7 @@ impl ChainSpec { min_epochs_for_data_column_sidecars_requests: default_min_epochs_for_data_column_sidecars_requests(), max_data_columns_by_root_request: default_data_columns_by_root_request(), + max_payload_envelopes_by_root_request: default_max_payload_envelopes_by_root_request(), /* * Application specific @@ -2342,6 +2354,14 @@ fn default_data_columns_by_root_request() -> usize { max_data_columns_by_root_request_common::(default_max_request_blocks_deneb()) } +fn default_max_payload_envelopes_by_root_request() -> usize { + max_blocks_by_root_request_common(default_max_request_payloads()) +} + +fn default_max_request_payloads() -> u64 { + 128 +} + impl Default for Config { fn default() -> Self { let chain_spec = MainnetEthSpec::default_spec(); diff --git a/consensus/types/src/execution/execution_payload_envelope.rs b/consensus/types/src/execution/execution_payload_envelope.rs index 7f68dae037..169331a884 100644 --- a/consensus/types/src/execution/execution_payload_envelope.rs +++ b/consensus/types/src/execution/execution_payload_envelope.rs @@ -3,7 +3,9 @@ use crate::test_utils::TestRandom; use crate::{EthSpec, ForkName, Hash256, SignedRoot, Slot}; use context_deserialize::context_deserialize; use educe::Educe; +use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; +use ssz::{BYTES_PER_LENGTH_OFFSET, Encode as SszEncode}; use ssz_derive::{Decode, Encode}; use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; @@ -22,6 +24,44 @@ pub struct ExecutionPayloadEnvelope { pub state_root: Hash256, } +impl ExecutionPayloadEnvelope { + /// Returns an empty envelope with all fields zeroed. Used for SSZ size calculations. + pub fn empty() -> Self { + Self { + payload: ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequests::default(), + builder_index: 0, + beacon_block_root: Hash256::zero(), + slot: Slot::new(0), + state_root: Hash256::zero(), + } + } + + /// Returns the minimum SSZ-encoded size (all variable-length fields empty). + pub fn min_size() -> usize { + Self::empty().as_ssz_bytes().len() + } + + /// Returns the maximum SSZ-encoded size. + #[allow(clippy::arithmetic_side_effects)] + pub fn max_size() -> usize { + Self::min_size() + // ExecutionPayloadGloas variable-length fields: + + (E::max_extra_data_bytes() * ::ssz_fixed_len()) + + (E::max_transactions_per_payload() + * (BYTES_PER_LENGTH_OFFSET + E::max_bytes_per_transaction())) + + (E::max_withdrawals_per_payload() + * ::ssz_fixed_len()) + // ExecutionRequests variable-length fields: + + (E::max_deposit_requests_per_payload() + * ::ssz_fixed_len()) + + (E::max_withdrawal_requests_per_payload() + * ::ssz_fixed_len()) + + (E::max_consolidation_requests_per_payload() + * ::ssz_fixed_len()) + } +} + impl SignedRoot for ExecutionPayloadEnvelope {} #[cfg(test)] diff --git a/consensus/types/src/execution/signed_execution_payload_envelope.rs b/consensus/types/src/execution/signed_execution_payload_envelope.rs index b1d949f863..76fa841680 100644 --- a/consensus/types/src/execution/signed_execution_payload_envelope.rs +++ b/consensus/types/src/execution/signed_execution_payload_envelope.rs @@ -8,6 +8,7 @@ use bls::{PublicKey, Signature}; use context_deserialize::context_deserialize; use educe::Educe; use serde::{Deserialize, Serialize}; +use ssz::Encode; use ssz_derive::{Decode, Encode}; use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; @@ -22,6 +23,24 @@ pub struct SignedExecutionPayloadEnvelope { } impl SignedExecutionPayloadEnvelope { + /// Returns the minimum SSZ-encoded size (all variable-length fields empty). + pub fn min_size() -> usize { + Self { + message: ExecutionPayloadEnvelope::empty(), + signature: Signature::empty(), + } + .as_ssz_bytes() + .len() + } + + /// Returns the maximum SSZ-encoded size. + #[allow(clippy::arithmetic_side_effects)] + pub fn max_size() -> usize { + // Signature is fixed-size, so the variable-length delta is entirely from the envelope. + Self::min_size() + ExecutionPayloadEnvelope::::max_size() + - ExecutionPayloadEnvelope::::min_size() + } + pub fn slot(&self) -> Slot { self.message.slot } From dfd715b65ee9ee30f73115ff1e24f4cfadef7aca Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 23 Mar 2026 12:56:45 +1100 Subject: [PATCH 086/189] Bump libp2p --- Cargo.lock | 215 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 4 +- 2 files changed, 109 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4043cb9e12..87f1d08d9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -399,7 +399,7 @@ checksum = "64b728d511962dda67c1bc7ea7c03736ec275ed2cf4c35d9585298ac9ccf3b73" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -510,7 +510,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -526,7 +526,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "syn-solidity", "tiny-keccak", ] @@ -543,7 +543,7 @@ dependencies = [ "macro-string", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "syn-solidity", ] @@ -632,7 +632,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -815,7 +815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -853,7 +853,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -964,7 +964,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "synstructure", ] @@ -976,7 +976,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -1055,7 +1055,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -1066,7 +1066,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -1108,7 +1108,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -1378,7 +1378,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.111", + "syn 2.0.117", "which", ] @@ -1553,7 +1553,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -1820,7 +1820,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -1909,7 +1909,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -2276,7 +2276,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2334,7 +2334,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2349,7 +2349,7 @@ dependencies = [ "quote", "serde", "strsim 0.11.1", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2371,7 +2371,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2382,7 +2382,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2549,7 +2549,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2570,7 +2570,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2580,7 +2580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2593,7 +2593,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2615,7 +2615,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.111", + "syn 2.0.117", "unicode-xid", ] @@ -2722,7 +2722,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -2817,7 +2817,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3040,7 +3040,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3060,7 +3060,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3098,7 +3098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3287,7 +3287,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3700,7 +3700,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -3819,7 +3819,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -4613,7 +4613,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -4729,7 +4729,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4958,9 +4958,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.178" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libloading" @@ -4996,7 +4996,7 @@ dependencies = [ [[package]] name = "libp2p" version = "0.56.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "bytes", "either", @@ -5027,7 +5027,7 @@ dependencies = [ [[package]] name = "libp2p-allow-block-list" version = "0.6.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5037,7 +5037,7 @@ dependencies = [ [[package]] name = "libp2p-connection-limits" version = "0.6.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5047,7 +5047,7 @@ dependencies = [ [[package]] name = "libp2p-core" version = "0.43.2" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "either", "fnv", @@ -5070,10 +5070,9 @@ dependencies = [ [[package]] name = "libp2p-dns" -version = "0.44.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +version = "0.45.0" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ - "async-trait", "futures", "hickory-resolver", "libp2p-core", @@ -5086,7 +5085,7 @@ dependencies = [ [[package]] name = "libp2p-gossipsub" version = "0.50.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "async-channel 2.5.0", "asynchronous-codec", @@ -5116,7 +5115,7 @@ dependencies = [ [[package]] name = "libp2p-identify" version = "0.47.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "asynchronous-codec", "either", @@ -5156,7 +5155,7 @@ dependencies = [ [[package]] name = "libp2p-mdns" version = "0.48.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "futures", "hickory-proto", @@ -5174,7 +5173,7 @@ dependencies = [ [[package]] name = "libp2p-metrics" version = "0.17.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "futures", "libp2p-core", @@ -5190,7 +5189,7 @@ dependencies = [ [[package]] name = "libp2p-mplex" version = "0.43.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "asynchronous-codec", "bytes", @@ -5208,7 +5207,7 @@ dependencies = [ [[package]] name = "libp2p-noise" version = "0.46.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "asynchronous-codec", "bytes", @@ -5230,7 +5229,7 @@ dependencies = [ [[package]] name = "libp2p-quic" version = "0.13.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "futures", "futures-timer", @@ -5251,7 +5250,7 @@ dependencies = [ [[package]] name = "libp2p-swarm" version = "0.47.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "either", "fnv", @@ -5272,17 +5271,17 @@ dependencies = [ [[package]] name = "libp2p-swarm-derive" version = "0.35.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "heck", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] name = "libp2p-tcp" version = "0.44.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "futures", "futures-timer", @@ -5297,7 +5296,7 @@ dependencies = [ [[package]] name = "libp2p-tls" version = "0.6.2" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "futures", "futures-rustls", @@ -5315,7 +5314,7 @@ dependencies = [ [[package]] name = "libp2p-upnp" version = "0.6.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "futures", "futures-timer", @@ -5329,7 +5328,7 @@ dependencies = [ [[package]] name = "libp2p-yamux" version = "0.47.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "either", "futures", @@ -5673,7 +5672,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -5894,7 +5893,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -5906,7 +5905,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -6021,7 +6020,7 @@ dependencies = [ [[package]] name = "multistream-select" version = "0.13.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "bytes", "futures", @@ -6074,7 +6073,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -6291,7 +6290,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6385,7 +6384,7 @@ checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -6484,7 +6483,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -6649,7 +6648,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -6740,7 +6739,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -6913,7 +6912,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -6955,7 +6954,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -7022,7 +7021,7 @@ checksum = "9adf1691c04c0a5ff46ff8f262b58beb07b0dbb61f96f9f54f6cbd82106ed87f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -7052,7 +7051,7 @@ checksum = "095a99f75c69734802359b682be8daaf8980296731f6470434ea2c652af1dd30" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -7075,7 +7074,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -7137,7 +7136,7 @@ dependencies = [ [[package]] name = "quick-protobuf-codec" version = "0.3.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "asynchronous-codec", "bytes", @@ -7199,7 +7198,7 @@ dependencies = [ "once_cell", "socket2 0.6.1", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7412,14 +7411,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -7716,7 +7715,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7822,7 +7821,7 @@ dependencies = [ [[package]] name = "rw-stream-sink" version = "0.4.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=f88e43de9eba00b416d0374b1a1fb2de47b65864#f88e43de9eba00b416d0374b1a1fb2de47b65864" +source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" dependencies = [ "futures", "pin-project", @@ -8071,7 +8070,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -8095,7 +8094,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -8138,7 +8137,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -8603,7 +8602,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -8623,7 +8622,7 @@ dependencies = [ "proc-macro2", "quote", "smallvec", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -8649,9 +8648,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -8667,7 +8666,7 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -8687,7 +8686,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -8781,7 +8780,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8805,7 +8804,7 @@ name = "test_random_derive" version = "0.2.0" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -8834,7 +8833,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -8845,7 +8844,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -9002,9 +9001,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -9026,7 +9025,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -9276,7 +9275,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -9379,7 +9378,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -9984,7 +9983,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -10132,7 +10131,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -10194,7 +10193,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -10205,7 +10204,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -10648,7 +10647,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "synstructure", ] @@ -10669,7 +10668,7 @@ checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -10689,7 +10688,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", "synstructure", ] @@ -10711,7 +10710,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] @@ -10744,7 +10743,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6910d02427..4cd1dfcea2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -281,5 +281,5 @@ debug = true quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "87c4ccb9bb2af494de375f5f6c62850badd26304" } [patch."https://github.com/libp2p/rust-libp2p.git"] -libp2p = { git = "https://github.com/sigp/rust-libp2p.git", rev = "f88e43de9eba00b416d0374b1a1fb2de47b65864" } -libp2p-mplex = { git = "https://github.com/sigp/rust-libp2p.git", rev = "f88e43de9eba00b416d0374b1a1fb2de47b65864" } +libp2p = { git = "https://github.com/sigp/rust-libp2p.git", rev = "defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" } +libp2p-mplex = { git = "https://github.com/sigp/rust-libp2p.git", rev = "defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" } From 7ca91b8ef43311768241c4c4252e0eb9c1264de5 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 25 Mar 2026 10:14:09 +1100 Subject: [PATCH 087/189] Bump c-kzg --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87f1d08d9c..8efa6897cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1617,9 +1617,9 @@ dependencies = [ [[package]] name = "c-kzg" -version = "2.1.5" +version = "2.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e00bf4b112b07b505472dbefd19e37e53307e2bfed5a79e0cc161d58ccd0e687" +checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" dependencies = [ "blst", "cc", From 176cce585c1ba979a6210ed79b6b6528596cdb8c Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 26 Mar 2026 12:21:13 +1100 Subject: [PATCH 088/189] Release v8.1.3 --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8efa6897cd..26730562c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "account_manager" -version = "8.1.2" +version = "8.1.3" dependencies = [ "account_utils", "bls", @@ -1276,7 +1276,7 @@ dependencies = [ [[package]] name = "beacon_node" -version = "8.1.2" +version = "8.1.3" dependencies = [ "account_utils", "beacon_chain", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "boot_node" -version = "8.1.2" +version = "8.1.3" dependencies = [ "beacon_node", "bytes", @@ -4897,7 +4897,7 @@ checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "lcli" -version = "8.1.2" +version = "8.1.3" dependencies = [ "account_utils", "beacon_chain", @@ -5382,7 +5382,7 @@ dependencies = [ [[package]] name = "lighthouse" -version = "8.1.2" +version = "8.1.3" dependencies = [ "account_manager", "account_utils", @@ -5514,7 +5514,7 @@ dependencies = [ [[package]] name = "lighthouse_version" -version = "8.1.2" +version = "8.1.3" dependencies = [ "regex", ] @@ -9621,7 +9621,7 @@ dependencies = [ [[package]] name = "validator_client" -version = "8.1.2" +version = "8.1.3" dependencies = [ "account_utils", "beacon_node_fallback", diff --git a/Cargo.toml b/Cargo.toml index 4cd1dfcea2..065741d117 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ resolver = "2" [workspace.package] edition = "2024" -version = "8.1.2" +version = "8.1.3" [workspace.dependencies] account_utils = { path = "common/account_utils" } From bd34bb14305b11af087447df2d53c03f69685d18 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 26 Mar 2026 13:10:34 +1100 Subject: [PATCH 089/189] Remove schema migrations for v28 and earlier (#9031) With LH v8.1.3 supporting Fulu-on-Gnosis, we no longer need these DB migrations. All Lighthouse nodes running in prod will soon be updated to LH v8.0.0+ and schema v28+. This PR helps with Gloas fork choice changes, by allowing us to avoid updating old schema migrations when adding V29 for Gloas: - https://github.com/sigp/lighthouse/pull/9025 Co-Authored-By: Michael Sproul --- .../src/beacon_fork_choice_store.rs | 61 +- beacon_node/beacon_chain/src/lib.rs | 2 +- .../beacon_chain/src/persisted_fork_choice.rs | 39 +- beacon_node/beacon_chain/src/schema_change.rs | 79 +-- .../src/schema_change/migration_schema_v23.rs | 180 ------ .../src/schema_change/migration_schema_v24.rs | 607 ------------------ .../src/schema_change/migration_schema_v25.rs | 20 - .../src/schema_change/migration_schema_v26.rs | 91 --- .../src/schema_change/migration_schema_v27.rs | 26 - .../src/schema_change/migration_schema_v28.rs | 152 ----- beacon_node/beacon_chain/src/summaries_dag.rs | 198 ------ beacon_node/beacon_chain/tests/store_tests.rs | 6 +- .../store/src/database/leveldb_impl.rs | 6 +- beacon_node/store/src/hot_cold_store.rs | 56 +- beacon_node/store/src/lib.rs | 6 +- consensus/fork_choice/src/fork_choice.rs | 33 +- consensus/fork_choice/src/lib.rs | 2 +- consensus/proto_array/src/lib.rs | 2 +- consensus/proto_array/src/ssz_container.rs | 35 +- 19 files changed, 23 insertions(+), 1578 deletions(-) delete mode 100644 beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs delete mode 100644 beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs delete mode 100644 beacon_node/beacon_chain/src/schema_change/migration_schema_v25.rs delete mode 100644 beacon_node/beacon_chain/src/schema_change/migration_schema_v26.rs delete mode 100644 beacon_node/beacon_chain/src/schema_change/migration_schema_v27.rs delete mode 100644 beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs diff --git a/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs b/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs index 60487f9c46..95fde28f5b 100644 --- a/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs +++ b/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs @@ -231,35 +231,6 @@ where } } - /// Restore `Self` from a previously-generated `PersistedForkChoiceStore`. - /// - /// DEPRECATED. Can be deleted once migrations no longer require it. - pub fn from_persisted_v17( - persisted: PersistedForkChoiceStoreV17, - justified_state_root: Hash256, - unrealized_justified_state_root: Hash256, - store: Arc>, - ) -> Result { - let justified_balances = - JustifiedBalances::from_effective_balances(persisted.justified_balances)?; - - Ok(Self { - store, - balances_cache: <_>::default(), - time: persisted.time, - finalized_checkpoint: persisted.finalized_checkpoint, - justified_checkpoint: persisted.justified_checkpoint, - justified_balances, - justified_state_root, - unrealized_justified_checkpoint: persisted.unrealized_justified_checkpoint, - unrealized_justified_state_root, - unrealized_finalized_checkpoint: persisted.unrealized_finalized_checkpoint, - proposer_boost_root: persisted.proposer_boost_root, - equivocating_indices: persisted.equivocating_indices, - _phantom: PhantomData, - }) - } - /// Restore `Self` from a previously-generated `PersistedForkChoiceStore`. pub fn from_persisted( persisted: PersistedForkChoiceStore, @@ -411,45 +382,15 @@ where pub type PersistedForkChoiceStore = PersistedForkChoiceStoreV28; /// A container which allows persisting the `BeaconForkChoiceStore` to the on-disk database. -#[superstruct( - variants(V17, V28), - variant_attributes(derive(Encode, Decode)), - no_enum -)] +#[superstruct(variants(V28), variant_attributes(derive(Encode, Decode)), no_enum)] pub struct PersistedForkChoiceStore { - /// The balances cache was removed from disk storage in schema V28. - #[superstruct(only(V17))] - pub balances_cache: BalancesCacheV8, pub time: Slot, pub finalized_checkpoint: Checkpoint, pub justified_checkpoint: Checkpoint, - /// The justified balances were removed from disk storage in schema V28. - #[superstruct(only(V17))] - pub justified_balances: Vec, - /// The justified state root is stored so that it can be used to load the justified balances. - #[superstruct(only(V28))] pub justified_state_root: Hash256, pub unrealized_justified_checkpoint: Checkpoint, - #[superstruct(only(V28))] pub unrealized_justified_state_root: Hash256, pub unrealized_finalized_checkpoint: Checkpoint, pub proposer_boost_root: Hash256, pub equivocating_indices: BTreeSet, } - -// Convert V28 to V17 by adding balances and removing justified state roots. -impl From<(PersistedForkChoiceStoreV28, JustifiedBalances)> for PersistedForkChoiceStoreV17 { - fn from((v28, balances): (PersistedForkChoiceStoreV28, JustifiedBalances)) -> Self { - Self { - balances_cache: Default::default(), - time: v28.time, - finalized_checkpoint: v28.finalized_checkpoint, - justified_checkpoint: v28.justified_checkpoint, - justified_balances: balances.effective_balances, - unrealized_justified_checkpoint: v28.unrealized_justified_checkpoint, - unrealized_finalized_checkpoint: v28.unrealized_finalized_checkpoint, - proposer_boost_root: v28.proposer_boost_root, - equivocating_indices: v28.equivocating_indices, - } - } -} diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index cf427d1a40..d71aec6987 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -75,7 +75,7 @@ pub use self::errors::{BeaconChainError, BlockProductionError}; pub use self::historical_blocks::HistoricalBlockError; pub use attestation_verification::Error as AttestationError; pub use beacon_fork_choice_store::{ - BeaconForkChoiceStore, Error as ForkChoiceStoreError, PersistedForkChoiceStoreV17, + BeaconForkChoiceStore, Error as ForkChoiceStoreError, PersistedForkChoiceStore, PersistedForkChoiceStoreV28, }; pub use block_verification::{ diff --git a/beacon_node/beacon_chain/src/persisted_fork_choice.rs b/beacon_node/beacon_chain/src/persisted_fork_choice.rs index d8fcc0901b..6229544e81 100644 --- a/beacon_node/beacon_chain/src/persisted_fork_choice.rs +++ b/beacon_node/beacon_chain/src/persisted_fork_choice.rs @@ -1,52 +1,19 @@ -use crate::{ - beacon_fork_choice_store::{PersistedForkChoiceStoreV17, PersistedForkChoiceStoreV28}, - metrics, -}; +use crate::{beacon_fork_choice_store::PersistedForkChoiceStoreV28, metrics}; use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; -use store::{DBColumn, Error, KeyValueStoreOp, StoreConfig, StoreItem}; +use store::{DBColumn, Error, KeyValueStoreOp, StoreConfig}; use superstruct::superstruct; use types::Hash256; // If adding a new version you should update this type alias and fix the breakages. pub type PersistedForkChoice = PersistedForkChoiceV28; -#[superstruct( - variants(V17, V28), - variant_attributes(derive(Encode, Decode)), - no_enum -)] +#[superstruct(variants(V28), variant_attributes(derive(Encode, Decode)), no_enum)] pub struct PersistedForkChoice { - #[superstruct(only(V17))] - pub fork_choice_v17: fork_choice::PersistedForkChoiceV17, - #[superstruct(only(V28))] pub fork_choice: fork_choice::PersistedForkChoiceV28, - #[superstruct(only(V17))] - pub fork_choice_store_v17: PersistedForkChoiceStoreV17, - #[superstruct(only(V28))] pub fork_choice_store: PersistedForkChoiceStoreV28, } -macro_rules! impl_store_item { - ($type:ty) => { - impl StoreItem for $type { - fn db_column() -> DBColumn { - DBColumn::ForkChoice - } - - fn as_store_bytes(&self) -> Vec { - self.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> std::result::Result { - Self::from_ssz_bytes(bytes).map_err(Into::into) - } - } - }; -} - -impl_store_item!(PersistedForkChoiceV17); - impl PersistedForkChoiceV28 { pub fn from_bytes(bytes: &[u8], store_config: &StoreConfig) -> Result { let decompressed_bytes = store_config diff --git a/beacon_node/beacon_chain/src/schema_change.rs b/beacon_node/beacon_chain/src/schema_change.rs index ddc5978339..ed82143c38 100644 --- a/beacon_node/beacon_chain/src/schema_change.rs +++ b/beacon_node/beacon_chain/src/schema_change.rs @@ -1,11 +1,4 @@ //! Utilities for managing database schema changes. -mod migration_schema_v23; -mod migration_schema_v24; -mod migration_schema_v25; -mod migration_schema_v26; -mod migration_schema_v27; -mod migration_schema_v28; - use crate::beacon_chain::BeaconChainTypes; use std::sync::Arc; use store::Error as StoreError; @@ -13,81 +6,17 @@ use store::hot_cold_store::{HotColdDB, HotColdDBError}; use store::metadata::{CURRENT_SCHEMA_VERSION, SchemaVersion}; /// Migrate the database from one schema version to another, applying all requisite mutations. +/// +/// All migrations for schema versions up to and including v28 have been removed. Nodes on live +/// networks are already running v28, so only the current version check remains. pub fn migrate_schema( - db: Arc>, + _db: Arc>, from: SchemaVersion, to: SchemaVersion, ) -> Result<(), StoreError> { match (from, to) { // Migrating from the current schema version to itself is always OK, a no-op. (_, _) if from == to && to == CURRENT_SCHEMA_VERSION => Ok(()), - // Upgrade across multiple versions by recursively migrating one step at a time. - (_, _) if from.as_u64() + 1 < to.as_u64() => { - let next = SchemaVersion(from.as_u64() + 1); - migrate_schema::(db.clone(), from, next)?; - migrate_schema::(db, next, to) - } - // Downgrade across multiple versions by recursively migrating one step at a time. - (_, _) if to.as_u64() + 1 < from.as_u64() => { - let next = SchemaVersion(from.as_u64() - 1); - migrate_schema::(db.clone(), from, next)?; - migrate_schema::(db, next, to) - } - - // - // Migrations from before SchemaVersion(22) are deprecated. - // - (SchemaVersion(22), SchemaVersion(23)) => { - let ops = migration_schema_v23::upgrade_to_v23::(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(23), SchemaVersion(22)) => { - let ops = migration_schema_v23::downgrade_from_v23::(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(23), SchemaVersion(24)) => { - let ops = migration_schema_v24::upgrade_to_v24::(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(24), SchemaVersion(23)) => { - let ops = migration_schema_v24::downgrade_from_v24::(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(24), SchemaVersion(25)) => { - let ops = migration_schema_v25::upgrade_to_v25()?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(25), SchemaVersion(24)) => { - let ops = migration_schema_v25::downgrade_from_v25()?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(25), SchemaVersion(26)) => { - let ops = migration_schema_v26::upgrade_to_v26::(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(26), SchemaVersion(25)) => { - let ops = migration_schema_v26::downgrade_from_v26::(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(26), SchemaVersion(27)) => { - // This migration updates the blobs db. The schema version - // is bumped inside upgrade_to_v27. - migration_schema_v27::upgrade_to_v27::(db.clone()) - } - (SchemaVersion(27), SchemaVersion(26)) => { - // Downgrading is essentially a no-op and is only possible - // if peer das isn't scheduled. - migration_schema_v27::downgrade_from_v27::(db.clone())?; - db.store_schema_version_atomically(to, vec![]) - } - (SchemaVersion(27), SchemaVersion(28)) => { - let ops = migration_schema_v28::upgrade_to_v28::(db.clone())?; - db.store_schema_version_atomically(to, ops) - } - (SchemaVersion(28), SchemaVersion(27)) => { - let ops = migration_schema_v28::downgrade_from_v28::(db.clone())?; - db.store_schema_version_atomically(to, ops) - } // Anything else is an error. (_, _) => Err(HotColdDBError::UnsupportedSchemaVersion { target_version: to, diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs deleted file mode 100644 index e238e1efb6..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v23.rs +++ /dev/null @@ -1,180 +0,0 @@ -use crate::BeaconForkChoiceStore; -use crate::beacon_chain::BeaconChainTypes; -use crate::persisted_fork_choice::PersistedForkChoiceV17; -use crate::schema_change::StoreError; -use crate::test_utils::{BEACON_CHAIN_DB_KEY, FORK_CHOICE_DB_KEY, PersistedBeaconChain}; -use fork_choice::{ForkChoice, ResetPayloadStatuses}; -use ssz::{Decode, Encode}; -use ssz_derive::{Decode, Encode}; -use std::sync::Arc; -use store::{DBColumn, Error, HotColdDB, KeyValueStore, KeyValueStoreOp, StoreItem}; -use tracing::{debug, info}; -use types::{Hash256, Slot}; - -/// Dummy value to use for the canonical head block root, see below. -pub const DUMMY_CANONICAL_HEAD_BLOCK_ROOT: Hash256 = Hash256::repeat_byte(0xff); - -pub fn upgrade_to_v23( - db: Arc>, -) -> Result, Error> { - info!("Upgrading DB schema from v22 to v23"); - - // 1) Set the head-tracker to empty - let Some(persisted_beacon_chain_v22) = - db.get_item::(&BEACON_CHAIN_DB_KEY)? - else { - return Err(Error::MigrationError( - "No persisted beacon chain found in DB. Datadir could be incorrect or DB could be corrupt".to_string() - )); - }; - - let persisted_beacon_chain = PersistedBeaconChain { - genesis_block_root: persisted_beacon_chain_v22.genesis_block_root, - }; - - let mut ops = vec![persisted_beacon_chain.as_kv_store_op(BEACON_CHAIN_DB_KEY)]; - - // 2) Wipe out all state temporary flags. While un-used in V23, if there's a rollback we could - // end-up with an inconsistent DB. - for state_root_result in db - .hot_db - .iter_column_keys::(DBColumn::BeaconStateTemporary) - { - let state_root = state_root_result?; - debug!( - ?state_root, - "Deleting temporary state on v23 schema migration" - ); - ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconStateTemporary, - state_root.as_slice().to_vec(), - )); - - // We also delete the temporary states themselves. Although there are known issue with - // temporary states and this could lead to DB corruption, we will only corrupt the DB in - // cases where the DB would be corrupted by restarting on v7.0.x. We consider these DBs - // "too far gone". Deleting here has the advantage of not generating warnings about - // disjoint state DAGs in the v24 upgrade, or the first pruning after migration. - ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconState, - state_root.as_slice().to_vec(), - )); - ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconStateSummary, - state_root.as_slice().to_vec(), - )); - } - - Ok(ops) -} - -pub fn downgrade_from_v23( - db: Arc>, -) -> Result, Error> { - let Some(persisted_beacon_chain) = db.get_item::(&BEACON_CHAIN_DB_KEY)? - else { - // The `PersistedBeaconChain` must exist if fork choice exists. - return Err(Error::MigrationError( - "No persisted beacon chain found in DB. Datadir could be incorrect or DB could be corrupt".to_string(), - )); - }; - - // Recreate head-tracker from fork choice. - let Some(persisted_fork_choice) = db.get_item::(&FORK_CHOICE_DB_KEY)? - else { - // Fork choice should exist if the database exists. - return Err(Error::MigrationError( - "No fork choice found in DB".to_string(), - )); - }; - - // We use dummy roots for the justified states because we can source the balances from the v17 - // persited fork choice. The justified state root isn't required to look up the justified state's - // balances (as it would be in V28). This fork choice object with corrupt state roots SHOULD NOT - // be written to disk. - let dummy_justified_state_root = Hash256::repeat_byte(0x66); - let dummy_unrealized_justified_state_root = Hash256::repeat_byte(0x77); - - let fc_store = BeaconForkChoiceStore::from_persisted_v17( - persisted_fork_choice.fork_choice_store_v17, - dummy_justified_state_root, - dummy_unrealized_justified_state_root, - db.clone(), - ) - .map_err(|e| { - Error::MigrationError(format!( - "Error loading fork choice store from persisted: {e:?}" - )) - })?; - - // Doesn't matter what policy we use for invalid payloads, as our head calculation just - // considers descent from finalization. - let reset_payload_statuses = ResetPayloadStatuses::OnlyWithInvalidPayload; - let fork_choice = ForkChoice::from_persisted( - persisted_fork_choice.fork_choice_v17.try_into()?, - reset_payload_statuses, - fc_store, - &db.spec, - ) - .map_err(|e| { - Error::MigrationError(format!("Error loading fork choice from persisted: {e:?}")) - })?; - - let heads = fork_choice - .proto_array() - .heads_descended_from_finalization::(fork_choice.finalized_checkpoint()); - - let head_roots = heads.iter().map(|node| node.root).collect(); - let head_slots = heads.iter().map(|node| node.slot).collect(); - - let persisted_beacon_chain_v22 = PersistedBeaconChainV22 { - _canonical_head_block_root: DUMMY_CANONICAL_HEAD_BLOCK_ROOT, - genesis_block_root: persisted_beacon_chain.genesis_block_root, - ssz_head_tracker: SszHeadTracker { - roots: head_roots, - slots: head_slots, - }, - }; - - let ops = vec![persisted_beacon_chain_v22.as_kv_store_op(BEACON_CHAIN_DB_KEY)]; - - Ok(ops) -} - -/// Helper struct that is used to encode/decode the state of the `HeadTracker` as SSZ bytes. -/// -/// This is used when persisting the state of the `BeaconChain` to disk. -#[derive(Encode, Decode, Clone)] -pub struct SszHeadTracker { - roots: Vec, - slots: Vec, -} - -#[derive(Clone, Encode, Decode)] -pub struct PersistedBeaconChainV22 { - /// This value is ignored to resolve the issue described here: - /// - /// https://github.com/sigp/lighthouse/pull/1639 - /// - /// Its removal is tracked here: - /// - /// https://github.com/sigp/lighthouse/issues/1784 - pub _canonical_head_block_root: Hash256, - pub genesis_block_root: Hash256, - /// DEPRECATED - pub ssz_head_tracker: SszHeadTracker, -} - -impl StoreItem for PersistedBeaconChainV22 { - fn db_column() -> DBColumn { - DBColumn::BeaconChain - } - - fn as_store_bytes(&self) -> Vec { - self.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> Result { - Self::from_ssz_bytes(bytes).map_err(Into::into) - } -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs deleted file mode 100644 index c8dfe1ac9b..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v24.rs +++ /dev/null @@ -1,607 +0,0 @@ -use crate::{ - beacon_chain::BeaconChainTypes, - summaries_dag::{DAGStateSummary, DAGStateSummaryV22, StateSummariesDAG}, -}; -use ssz::{Decode, DecodeError, Encode}; -use ssz_derive::Encode; -use std::{ - sync::Arc, - time::{Duration, Instant}, -}; -use store::{ - DBColumn, Error, HotColdDB, HotStateSummary, KeyValueStore, KeyValueStoreOp, StoreItem, - hdiff::StorageStrategy, - hot_cold_store::{HotStateSummaryV22, OptionalDiffBaseState}, -}; -use tracing::{debug, info, warn}; -use types::{ - BeaconState, CACHED_EPOCHS, ChainSpec, Checkpoint, CommitteeCache, EthSpec, Hash256, Slot, - execution::StatePayloadStatus, -}; - -/// We stopped using the pruning checkpoint in schema v23 but never explicitly deleted it. -/// -/// We delete it as part of the v24 migration. -pub const PRUNING_CHECKPOINT_KEY: Hash256 = Hash256::repeat_byte(3); - -pub fn store_full_state_v22( - state_root: &Hash256, - state: &BeaconState, - ops: &mut Vec, -) -> Result<(), Error> { - let bytes = StorageContainer::new(state).as_ssz_bytes(); - ops.push(KeyValueStoreOp::PutKeyValue( - DBColumn::BeaconState, - state_root.as_slice().to_vec(), - bytes, - )); - Ok(()) -} - -/// Fetch a V22 state from the database either as a full state or using block replay. -pub fn get_state_v22( - db: &Arc>, - state_root: &Hash256, - spec: &ChainSpec, -) -> Result>, Error> { - let Some(summary) = db.get_item::(state_root)? else { - return Ok(None); - }; - let Some(base_state) = - get_full_state_v22(&db.hot_db, &summary.epoch_boundary_state_root, spec)? - else { - return Ok(None); - }; - // Loading hot states via block replay doesn't care about the schema version, so we can use - // the DB's current method for this. - let update_cache = false; - db.load_hot_state_using_replay( - base_state, - summary.slot, - summary.latest_block_root, - StatePayloadStatus::Pending, - update_cache, - ) - .map(Some) -} - -pub fn get_full_state_v22, E: EthSpec>( - db: &KV, - state_root: &Hash256, - spec: &ChainSpec, -) -> Result>, Error> { - match db.get_bytes(DBColumn::BeaconState, state_root.as_slice())? { - Some(bytes) => { - let container = StorageContainer::from_ssz_bytes(&bytes, spec)?; - Ok(Some(container.try_into()?)) - } - None => Ok(None), - } -} - -/// A container for storing `BeaconState` components. -/// -/// DEPRECATED. -#[derive(Encode)] -pub struct StorageContainer { - state: BeaconState, - committee_caches: Vec>, -} - -impl StorageContainer { - /// Create a new instance for storing a `BeaconState`. - pub fn new(state: &BeaconState) -> Self { - Self { - state: state.clone(), - committee_caches: state.committee_caches().to_vec(), - } - } - - pub fn from_ssz_bytes(bytes: &[u8], spec: &ChainSpec) -> Result { - // We need to use the slot-switching `from_ssz_bytes` of `BeaconState`, which doesn't - // compose with the other SSZ utils, so we duplicate some parts of `ssz_derive` here. - let mut builder = ssz::SszDecoderBuilder::new(bytes); - - builder.register_anonymous_variable_length_item()?; - builder.register_type::>()?; - - let mut decoder = builder.build()?; - - let state = decoder.decode_next_with(|bytes| BeaconState::from_ssz_bytes(bytes, spec))?; - let committee_caches = decoder.decode_next()?; - - Ok(Self { - state, - committee_caches, - }) - } -} - -impl TryInto> for StorageContainer { - type Error = Error; - - fn try_into(mut self) -> Result, Error> { - let mut state = self.state; - - for i in (0..CACHED_EPOCHS).rev() { - if i >= self.committee_caches.len() { - return Err(Error::SszDecodeError(DecodeError::BytesInvalid( - "Insufficient committees for BeaconState".to_string(), - ))); - }; - - state.committee_caches_mut()[i] = self.committee_caches.remove(i); - } - - Ok(state) - } -} - -/// The checkpoint used for pruning the database. -/// -/// Updated whenever pruning is successful. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PruningCheckpoint { - pub checkpoint: Checkpoint, -} - -impl StoreItem for PruningCheckpoint { - fn db_column() -> DBColumn { - DBColumn::BeaconMeta - } - - fn as_store_bytes(&self) -> Vec { - self.checkpoint.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> Result { - Ok(PruningCheckpoint { - checkpoint: Checkpoint::from_ssz_bytes(bytes)?, - }) - } -} - -pub fn upgrade_to_v24( - db: Arc>, -) -> Result, Error> { - let mut migrate_ops = vec![]; - let split = db.get_split_info(); - let hot_hdiff_start_slot = split.slot; - - // Delete the `PruningCheckpoint` (no longer used). - migrate_ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconMeta, - PRUNING_CHECKPOINT_KEY.as_slice().to_vec(), - )); - - // Sanity check to make sure the HDiff grid is aligned with the epoch start - if hot_hdiff_start_slot % T::EthSpec::slots_per_epoch() != 0 { - return Err(Error::MigrationError(format!( - "hot_hdiff_start_slot is not first slot in epoch {hot_hdiff_start_slot}" - ))); - } - - // After V24 hot tree states, the in-memory `anchor_info.anchor_slot` is the start slot of the - // hot HDiff grid. Before the migration, it's set to the slot of the anchor state in the DB: - // - the genesis state on a genesis sync, or - // - the checkpoint state on a checkpoint sync. - // - // If the node has been running for a while the `anchor_slot` might be less than the finalized - // checkpoint. This upgrade constructs a grid only with unfinalized states, rooted in the - // current finalized state. So we set the `anchor_slot` to `split.slot` to root the grid in the - // current finalized state. Each migration sets the split to - // ``` - // Split { slot: finalized_state.slot(), state_root: finalized_state_root } - // ``` - { - let anchor_info = db.get_anchor_info(); - - // If the node is already an archive node, we can set the anchor slot to 0 and copy - // snapshots and diffs from the freezer DB to the hot DB in order to establish an initial - // hot grid that is aligned/"perfect" (no `start_slot`/`anchor_slot` to worry about). - // - // This only works if all of the following are true: - // - // - We have the previous snapshot for the split state stored in the freezer DB, i.e. - // if `previous_snapshot_slot >= state_upper_limit`. - // - The split state itself will be stored as a diff or snapshot in the new grid. We choose - // not to support a split state that requires block replay, because computing its previous - // state root from the DAG is not straight-forward. - let dummy_start_slot = Slot::new(0); - let closest_layer_points = db - .hierarchy - .closest_layer_points(split.slot, dummy_start_slot); - - let previous_snapshot_slot = - closest_layer_points - .iter() - .copied() - .min() - .ok_or(Error::MigrationError( - "closest_layer_points must not be empty".to_string(), - ))?; - - if previous_snapshot_slot >= anchor_info.state_upper_limit - && db - .hierarchy - .storage_strategy(split.slot, dummy_start_slot) - .is_ok_and(|strategy| !strategy.is_replay_from()) - { - info!( - %previous_snapshot_slot, - split_slot = %split.slot, - "Aligning hot diff grid to freezer" - ); - - // Set anchor slot to 0 in case it was set to something else by a previous checkpoint - // sync. - let mut new_anchor_info = anchor_info.clone(); - new_anchor_info.anchor_slot = Slot::new(0); - - // Update the anchor on disk atomically if migration is successful - migrate_ops.push(db.compare_and_set_anchor_info(anchor_info, new_anchor_info)?); - - // Copy each of the freezer layers to the hot DB in slot ascending order. - for layer_slot in closest_layer_points.into_iter().rev() { - // Do not try to load the split state itself from the freezer, it won't be there. - // It will be migrated in the main loop below. - if layer_slot == split.slot { - continue; - } - - let mut freezer_state = db.load_cold_state_by_slot(layer_slot)?; - - let state_root = freezer_state.canonical_root()?; - - let mut state_ops = vec![]; - db.store_hot_state(&state_root, &freezer_state, &mut state_ops)?; - db.hot_db.do_atomically(state_ops)?; - } - } else { - // Otherwise for non-archive nodes, set the anchor slot for the hot grid to the current - // split slot (the oldest slot available). - let mut new_anchor_info = anchor_info.clone(); - new_anchor_info.anchor_slot = hot_hdiff_start_slot; - - // Update the anchor in disk atomically if migration is successful - migrate_ops.push(db.compare_and_set_anchor_info(anchor_info, new_anchor_info)?); - } - } - - let state_summaries_dag = new_dag::(&db)?; - - // We compute the state summaries DAG outside of a DB migration. Therefore if the DB is properly - // prunned, it should have a single root equal to the split. - let state_summaries_dag_roots = state_summaries_dag.tree_roots(); - if state_summaries_dag_roots.len() == 1 { - let (root_summary_state_root, root_summary) = - state_summaries_dag_roots.first().expect("len == 1"); - if *root_summary_state_root != split.state_root { - warn!( - ?root_summary_state_root, - ?root_summary, - ?split, - "State summaries DAG root is not the split" - ); - } - } else { - warn!( - location = "migration", - state_summaries_dag_roots = ?state_summaries_dag_roots, - "State summaries DAG found more than one root" - ); - } - - // Sort summaries by slot so we have their ancestor diffs already stored when we store them. - // If the summaries are sorted topologically we can insert them into the DB like if they were a - // new state, re-using existing code. As states are likely to be sequential the diff cache - // should kick in making the migration more efficient. If we just iterate the column of - // summaries we may get distance state of each iteration. - let summaries_by_slot = state_summaries_dag.summaries_by_slot_ascending(); - debug!( - summaries_count = state_summaries_dag.summaries_count(), - slots_count = summaries_by_slot.len(), - min_slot = ?summaries_by_slot.first_key_value().map(|(slot, _)| slot), - max_slot = ?summaries_by_slot.last_key_value().map(|(slot, _)| slot), - ?state_summaries_dag_roots, - %hot_hdiff_start_slot, - split_state_root = ?split.state_root, - "Starting hot states migration" - ); - - // Upgrade all hot DB state summaries to the new type: - // - Set all summaries of boundary states to `Snapshot` type - // - Set all others to `Replay` pointing to `epoch_boundary_state_root` - - let mut diffs_written = 0; - let mut summaries_written = 0; - let mut last_log_time = Instant::now(); - - for (slot, old_hot_state_summaries) in summaries_by_slot { - for (state_root, old_summary) in old_hot_state_summaries { - if slot < hot_hdiff_start_slot { - // To reach here, there must be some pruning issue with the DB where we still have - // hot states below the split slot. This states can't be migrated as we can't compute - // a storage strategy for them. After this if else block, the summary and state are - // scheduled for deletion. - debug!( - %slot, - ?state_root, - "Ignoring state summary prior to split slot" - ); - } else { - // 1. Store snapshot or diff at this slot (if required). - let storage_strategy = db.hot_storage_strategy(slot)?; - debug!( - %slot, - ?state_root, - ?storage_strategy, - "Migrating state summary" - ); - - match storage_strategy { - StorageStrategy::DiffFrom(_) | StorageStrategy::Snapshot => { - // Load the state and re-store it as a snapshot or diff. - let state = get_state_v22::(&db, &state_root, &db.spec)? - .ok_or(Error::MissingState(state_root))?; - - // Store immediately so that future diffs can load and diff from it. - let mut ops = vec![]; - // We must commit the hot state summary immediately, otherwise we can't diff - // against it and future writes will fail. That's why we write the new hot - // summaries in a different column to have both new and old data present at - // once. Otherwise if the process crashes during the migration the database will - // be broken. - db.store_hot_state_summary(&state_root, &state, &mut ops)?; - db.store_hot_state_diffs(&state_root, &state, &mut ops)?; - db.hot_db.do_atomically(ops)?; - diffs_written += 1; - } - StorageStrategy::ReplayFrom(diff_base_slot) => { - // Optimization: instead of having to load the state of each summary we load x32 - // less states by manually computing the HotStateSummary roots using the - // computed state dag. - // - // No need to store diffs for states that will be reconstructed by replaying - // blocks. - // - // 2. Convert the summary to the new format. - if state_root == split.state_root { - return Err(Error::MigrationError( - "unreachable: split state should be stored as a snapshot or diff" - .to_string(), - )); - } - let previous_state_root = state_summaries_dag - .previous_state_root(state_root) - .map_err(|e| { - Error::MigrationError(format!( - "error computing previous_state_root {e:?}" - )) - })?; - - let diff_base_state = OptionalDiffBaseState::new( - diff_base_slot, - state_summaries_dag - .ancestor_state_root_at_slot(state_root, diff_base_slot) - .map_err(|e| { - Error::MigrationError(format!( - "error computing ancestor_state_root_at_slot \ - ({state_root:?}, {diff_base_slot}): {e:?}" - )) - })?, - ); - - let new_summary = HotStateSummary { - slot, - latest_block_root: old_summary.latest_block_root, - latest_block_slot: old_summary.latest_block_slot, - previous_state_root, - diff_base_state, - }; - let op = new_summary.as_kv_store_op(state_root); - // It's not necessary to immediately commit the summaries of states that are - // ReplayFrom. However we do so for simplicity. - db.hot_db.do_atomically(vec![op])?; - } - } - } - - // 3. Stage old data for deletion. - if slot % T::EthSpec::slots_per_epoch() == 0 { - migrate_ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconState, - state_root.as_slice().to_vec(), - )); - } - - // Delete previous summaries - migrate_ops.push(KeyValueStoreOp::DeleteKey( - DBColumn::BeaconStateSummary, - state_root.as_slice().to_vec(), - )); - - summaries_written += 1; - if last_log_time.elapsed() > Duration::from_secs(5) { - last_log_time = Instant::now(); - info!( - diffs_written, - summaries_written, - summaries_count = state_summaries_dag.summaries_count(), - "Hot states migration in progress" - ); - } - } - } - - info!( - diffs_written, - summaries_written, - summaries_count = state_summaries_dag.summaries_count(), - "Hot states migration complete" - ); - - Ok(migrate_ops) -} - -pub fn downgrade_from_v24( - db: Arc>, -) -> Result, Error> { - let state_summaries = db - .load_hot_state_summaries()? - .into_iter() - .map(|(state_root, summary)| (state_root, summary.into())) - .collect::>(); - - info!( - summaries_count = state_summaries.len(), - "DB downgrade of v24 state summaries started" - ); - - let state_summaries_dag = StateSummariesDAG::new(state_summaries) - .map_err(|e| Error::MigrationError(format!("Error on new StateSumariesDAG {e:?}")))?; - - let mut migrate_ops = vec![]; - let mut states_written = 0; - let mut summaries_written = 0; - let mut summaries_skipped = 0; - let mut last_log_time = Instant::now(); - - // Rebuild the PruningCheckpoint from the split. - let split = db.get_split_info(); - let pruning_checkpoint = PruningCheckpoint { - checkpoint: Checkpoint { - epoch: split.slot.epoch(T::EthSpec::slots_per_epoch()), - root: split.block_root, - }, - }; - migrate_ops.push(pruning_checkpoint.as_kv_store_op(PRUNING_CHECKPOINT_KEY)); - - // Convert state summaries back to the old format. - for (state_root, summary) in state_summaries_dag - .summaries_by_slot_ascending() - .into_iter() - .flat_map(|(_, summaries)| summaries) - { - // No need to migrate any states prior to the split. The v22 schema does not need them, and - // they would generate warnings about a disjoint DAG when re-upgrading to V24. - if summary.slot < split.slot { - debug!( - slot = %summary.slot, - ?state_root, - "Skipping migration of pre-split state" - ); - summaries_skipped += 1; - continue; - } - - // If boundary state: persist. - // Do not cache these states as they are unlikely to be relevant later. - let update_cache = false; - if summary.slot % T::EthSpec::slots_per_epoch() == 0 { - let (state, _) = db - .load_hot_state(&state_root, update_cache)? - .ok_or(Error::MissingState(state_root))?; - - // Immediately commit the state, so we don't OOM. It's stored in a different - // column so if the migration crashes we'll just store extra harmless junk in the DB. - let mut state_write_ops = vec![]; - store_full_state_v22(&state_root, &state, &mut state_write_ops)?; - db.hot_db.do_atomically(state_write_ops)?; - states_written += 1; - } - - // Persist old summary. - let epoch_boundary_state_slot = summary.slot - summary.slot % T::EthSpec::slots_per_epoch(); - let old_summary = HotStateSummaryV22 { - slot: summary.slot, - latest_block_root: summary.latest_block_root, - epoch_boundary_state_root: state_summaries_dag - .ancestor_state_root_at_slot(state_root, epoch_boundary_state_slot) - .map_err(|e| { - Error::MigrationError(format!( - "error computing ancestor_state_root_at_slot({state_root:?}, {epoch_boundary_state_slot}) {e:?}" - )) - })?, - }; - migrate_ops.push(KeyValueStoreOp::PutKeyValue( - DBColumn::BeaconStateSummary, - state_root.as_slice().to_vec(), - old_summary.as_ssz_bytes(), - )); - summaries_written += 1; - - if last_log_time.elapsed() > Duration::from_secs(5) { - last_log_time = Instant::now(); - info!( - states_written, - summaries_written, - summaries_count = state_summaries_dag.summaries_count(), - "DB downgrade of v24 state summaries in progress" - ); - } - } - - // Delete all V24 schema data. We do this outside the loop over summaries to ensure we cover - // every piece of data and to simplify logic around skipping certain summaries that do not get - // migrated. - for db_column in [ - DBColumn::BeaconStateHotSummary, - DBColumn::BeaconStateHotDiff, - DBColumn::BeaconStateHotSnapshot, - ] { - for key in db.hot_db.iter_column_keys::(db_column) { - let state_root = key?; - migrate_ops.push(KeyValueStoreOp::DeleteKey( - db_column, - state_root.as_slice().to_vec(), - )); - } - } - - info!( - states_written, - summaries_written, - summaries_skipped, - summaries_count = state_summaries_dag.summaries_count(), - "DB downgrade of v24 state summaries completed" - ); - - Ok(migrate_ops) -} - -fn new_dag( - db: &HotColdDB, -) -> Result { - // Collect all sumaries for unfinalized states - let state_summaries_v22 = db - .hot_db - // Collect summaries from the legacy V22 column BeaconStateSummary - .iter_column::(DBColumn::BeaconStateSummary) - .map(|res| { - let (key, value) = res?; - let state_root: Hash256 = key; - let summary = HotStateSummaryV22::from_ssz_bytes(&value)?; - let block_root = summary.latest_block_root; - // Read blocks to get the block slot and parent root. In Holesky forced finalization it - // took 5100 ms to read 15072 state summaries, so it's not really necessary to - // de-duplicate block reads. - let block = db - .get_blinded_block(&block_root)? - .ok_or(Error::MissingBlock(block_root))?; - - Ok(( - state_root, - DAGStateSummaryV22 { - slot: summary.slot, - latest_block_root: summary.latest_block_root, - block_slot: block.slot(), - block_parent_root: block.parent_root(), - }, - )) - }) - .collect::, Error>>()?; - - StateSummariesDAG::new_from_v22(state_summaries_v22) - .map_err(|e| Error::MigrationError(format!("error computing states summaries dag {e:?}"))) -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v25.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v25.rs deleted file mode 100644 index 44e8894d6f..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v25.rs +++ /dev/null @@ -1,20 +0,0 @@ -use store::{DBColumn, Error, KeyValueStoreOp}; -use tracing::info; -use types::Hash256; - -pub const ETH1_CACHE_DB_KEY: Hash256 = Hash256::ZERO; - -/// Delete the on-disk eth1 data. -pub fn upgrade_to_v25() -> Result, Error> { - info!("Deleting eth1 data from disk for v25 DB upgrade"); - Ok(vec![KeyValueStoreOp::DeleteKey( - DBColumn::Eth1Cache, - ETH1_CACHE_DB_KEY.as_slice().to_vec(), - )]) -} - -/// No-op: we don't need to recreate on-disk eth1 data, as previous versions gracefully handle -/// data missing from disk. -pub fn downgrade_from_v25() -> Result, Error> { - Ok(vec![]) -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v26.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v26.rs deleted file mode 100644 index 38714ea060..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v26.rs +++ /dev/null @@ -1,91 +0,0 @@ -use crate::BeaconChainTypes; -use crate::custody_context::CustodyContextSsz; -use crate::persisted_custody::{CUSTODY_DB_KEY, PersistedCustody}; -use ssz::{Decode, Encode}; -use ssz_derive::{Decode, Encode}; -use std::sync::Arc; -use store::{DBColumn, Error, HotColdDB, KeyValueStoreOp, StoreItem}; -use tracing::info; - -#[derive(Debug, Encode, Decode, Clone)] -pub(crate) struct CustodyContextSszV24 { - pub(crate) validator_custody_at_head: u64, - pub(crate) persisted_is_supernode: bool, -} - -pub(crate) struct PersistedCustodyV24(CustodyContextSszV24); - -impl StoreItem for PersistedCustodyV24 { - fn db_column() -> DBColumn { - DBColumn::CustodyContext - } - - fn as_store_bytes(&self) -> Vec { - self.0.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> Result { - let custody_context = CustodyContextSszV24::from_ssz_bytes(bytes)?; - Ok(PersistedCustodyV24(custody_context)) - } -} - -/// Upgrade the `CustodyContext` entry to v26. -pub fn upgrade_to_v26( - db: Arc>, -) -> Result, Error> { - let ops = if db.spec.is_peer_das_scheduled() { - match db.get_item::(&CUSTODY_DB_KEY) { - Ok(Some(PersistedCustodyV24(ssz_v24))) => { - info!("Migrating `CustodyContext` to v26 schema"); - let custody_context_v2 = CustodyContextSsz { - validator_custody_at_head: ssz_v24.validator_custody_at_head, - persisted_is_supernode: ssz_v24.persisted_is_supernode, - epoch_validator_custody_requirements: vec![], - }; - vec![KeyValueStoreOp::PutKeyValue( - DBColumn::CustodyContext, - CUSTODY_DB_KEY.as_slice().to_vec(), - PersistedCustody(custody_context_v2).as_store_bytes(), - )] - } - _ => { - vec![] - } - } - } else { - // Delete it from db if PeerDAS hasn't been scheduled - vec![KeyValueStoreOp::DeleteKey( - DBColumn::CustodyContext, - CUSTODY_DB_KEY.as_slice().to_vec(), - )] - }; - - Ok(ops) -} - -pub fn downgrade_from_v26( - db: Arc>, -) -> Result, Error> { - let res = db.get_item::(&CUSTODY_DB_KEY); - let ops = match res { - Ok(Some(PersistedCustody(ssz_v26))) => { - info!("Migrating `CustodyContext` back from v26 schema"); - let custody_context_v24 = CustodyContextSszV24 { - validator_custody_at_head: ssz_v26.validator_custody_at_head, - persisted_is_supernode: ssz_v26.persisted_is_supernode, - }; - vec![KeyValueStoreOp::PutKeyValue( - DBColumn::CustodyContext, - CUSTODY_DB_KEY.as_slice().to_vec(), - PersistedCustodyV24(custody_context_v24).as_store_bytes(), - )] - } - _ => { - // no op if it's not on the db, as previous versions gracefully handle data missing from disk. - vec![] - } - }; - - Ok(ops) -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v27.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v27.rs deleted file mode 100644 index fbe865ee27..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v27.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::BeaconChainTypes; -use std::sync::Arc; -use store::{Error, HotColdDB, metadata::SchemaVersion}; - -/// Add `DataColumnCustodyInfo` entry to v27. -pub fn upgrade_to_v27( - db: Arc>, -) -> Result<(), Error> { - if db.spec.is_peer_das_scheduled() { - db.put_data_column_custody_info(None)?; - db.store_schema_version_atomically(SchemaVersion(27), vec![])?; - } - - Ok(()) -} - -pub fn downgrade_from_v27( - db: Arc>, -) -> Result<(), Error> { - if db.spec.is_peer_das_scheduled() { - return Err(Error::MigrationError( - "Cannot downgrade from v27 if peerDAS is scheduled".to_string(), - )); - } - Ok(()) -} diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs deleted file mode 100644 index 5885eaabc0..0000000000 --- a/beacon_node/beacon_chain/src/schema_change/migration_schema_v28.rs +++ /dev/null @@ -1,152 +0,0 @@ -use crate::{ - BeaconChain, BeaconChainTypes, BeaconForkChoiceStore, PersistedForkChoiceStoreV17, - beacon_chain::FORK_CHOICE_DB_KEY, - persisted_fork_choice::{PersistedForkChoiceV17, PersistedForkChoiceV28}, - summaries_dag::{DAGStateSummary, StateSummariesDAG}, -}; -use fork_choice::{ForkChoice, ForkChoiceStore, ResetPayloadStatuses}; -use std::sync::Arc; -use store::{Error, HotColdDB, KeyValueStoreOp, StoreItem}; -use tracing::{info, warn}; -use types::{EthSpec, Hash256}; - -/// Upgrade `PersistedForkChoice` from V17 to V28. -pub fn upgrade_to_v28( - db: Arc>, -) -> Result, Error> { - let Some(persisted_fork_choice_v17) = - db.get_item::(&FORK_CHOICE_DB_KEY)? - else { - warn!("No fork choice found to upgrade to v28"); - return Ok(vec![]); - }; - - // Load state DAG in order to compute justified checkpoint roots. - let state_summaries_dag = { - let state_summaries = db - .load_hot_state_summaries()? - .into_iter() - .map(|(state_root, summary)| (state_root, summary.into())) - .collect::>(); - - StateSummariesDAG::new(state_summaries).map_err(|e| { - Error::MigrationError(format!("Error loading state summaries DAG: {e:?}")) - })? - }; - - // Determine the justified state roots. - let justified_checkpoint = persisted_fork_choice_v17 - .fork_choice_store_v17 - .justified_checkpoint; - let justified_block_root = justified_checkpoint.root; - let justified_slot = justified_checkpoint - .epoch - .start_slot(T::EthSpec::slots_per_epoch()); - let justified_state_root = state_summaries_dag - .state_root_at_slot(justified_block_root, justified_slot) - .ok_or_else(|| { - Error::MigrationError(format!( - "Missing state root for justified slot {justified_slot} with latest_block_root \ - {justified_block_root:?}" - )) - })?; - - let unrealized_justified_checkpoint = persisted_fork_choice_v17 - .fork_choice_store_v17 - .unrealized_justified_checkpoint; - let unrealized_justified_block_root = unrealized_justified_checkpoint.root; - let unrealized_justified_slot = unrealized_justified_checkpoint - .epoch - .start_slot(T::EthSpec::slots_per_epoch()); - let unrealized_justified_state_root = state_summaries_dag - .state_root_at_slot(unrealized_justified_block_root, unrealized_justified_slot) - .ok_or_else(|| { - Error::MigrationError(format!( - "Missing state root for unrealized justified slot {unrealized_justified_slot} \ - with latest_block_root {unrealized_justified_block_root:?}" - )) - })?; - - let fc_store = BeaconForkChoiceStore::from_persisted_v17( - persisted_fork_choice_v17.fork_choice_store_v17, - justified_state_root, - unrealized_justified_state_root, - db.clone(), - ) - .map_err(|e| { - Error::MigrationError(format!( - "Error loading fork choice store from persisted: {e:?}" - )) - })?; - - info!( - ?justified_state_root, - %justified_slot, - "Added justified state root to fork choice" - ); - - // Construct top-level ForkChoice struct using the patched fork choice store, and the converted - // proto array. - let reset_payload_statuses = ResetPayloadStatuses::OnlyWithInvalidPayload; - let fork_choice = ForkChoice::from_persisted( - persisted_fork_choice_v17.fork_choice_v17.try_into()?, - reset_payload_statuses, - fc_store, - db.get_chain_spec(), - ) - .map_err(|e| Error::MigrationError(format!("Unable to build ForkChoice: {e:?}")))?; - - let ops = vec![BeaconChain::::persist_fork_choice_in_batch_standalone( - &fork_choice, - db.get_config(), - )?]; - - info!("Upgraded fork choice for DB schema v28"); - - Ok(ops) -} - -pub fn downgrade_from_v28( - db: Arc>, -) -> Result, Error> { - let reset_payload_statuses = ResetPayloadStatuses::OnlyWithInvalidPayload; - let Some(fork_choice) = - BeaconChain::::load_fork_choice(db.clone(), reset_payload_statuses, db.get_chain_spec()) - .map_err(|e| Error::MigrationError(format!("Unable to load fork choice: {e:?}")))? - else { - warn!("No fork choice to downgrade"); - return Ok(vec![]); - }; - - // Recreate V28 persisted fork choice, then convert each field back to its V17 version. - let persisted_fork_choice = PersistedForkChoiceV28 { - fork_choice: fork_choice.to_persisted(), - fork_choice_store: fork_choice.fc_store().to_persisted(), - }; - - let justified_balances = fork_choice.fc_store().justified_balances(); - - // 1. Create `proto_array::PersistedForkChoiceV17`. - let fork_choice_v17: fork_choice::PersistedForkChoiceV17 = ( - persisted_fork_choice.fork_choice, - justified_balances.clone(), - ) - .into(); - - let fork_choice_store_v17: PersistedForkChoiceStoreV17 = ( - persisted_fork_choice.fork_choice_store, - justified_balances.clone(), - ) - .into(); - - let persisted_fork_choice_v17 = PersistedForkChoiceV17 { - fork_choice_v17, - fork_choice_store_v17, - }; - - let ops = vec![persisted_fork_choice_v17.as_kv_store_op(FORK_CHOICE_DB_KEY)]; - - info!("Downgraded fork choice for DB schema v28"); - - Ok(ops) -} diff --git a/beacon_node/beacon_chain/src/summaries_dag.rs b/beacon_node/beacon_chain/src/summaries_dag.rs index 4ddcdaab5a..50fc0b3820 100644 --- a/beacon_node/beacon_chain/src/summaries_dag.rs +++ b/beacon_node/beacon_chain/src/summaries_dag.rs @@ -14,14 +14,6 @@ pub struct DAGStateSummary { pub previous_state_root: Hash256, } -#[derive(Debug, Clone, Copy)] -pub struct DAGStateSummaryV22 { - pub slot: Slot, - pub latest_block_root: Hash256, - pub block_slot: Slot, - pub block_parent_root: Hash256, -} - pub struct StateSummariesDAG { // state_root -> state_summary state_summaries_by_state_root: HashMap, @@ -40,10 +32,6 @@ pub enum Error { new_state_summary: (Slot, Hash256), }, MissingStateSummary(Hash256), - MissingStateSummaryByBlockRoot { - state_root: Hash256, - latest_block_root: Hash256, - }, MissingChildStateRoot(Hash256), RequestedSlotAboveSummary { starting_state_root: Hash256, @@ -109,89 +97,6 @@ impl StateSummariesDAG { }) } - /// Computes a DAG from a sequence of state summaries, including their parent block - /// relationships. - /// - /// - Expects summaries to be contiguous per slot: there must exist a summary at every slot - /// of each tree branch - /// - Maybe include multiple disjoint trees. The root of each tree will have a ZERO parent state - /// root, which will error later when calling `previous_state_root`. - pub fn new_from_v22( - state_summaries_v22: Vec<(Hash256, DAGStateSummaryV22)>, - ) -> Result { - // Group them by latest block root, and sorted state slot - let mut state_summaries_by_block_root = HashMap::<_, BTreeMap<_, _>>::new(); - for (state_root, summary) in state_summaries_v22.iter() { - let summaries = state_summaries_by_block_root - .entry(summary.latest_block_root) - .or_default(); - - // Sanity check to ensure no duplicate summaries for the tuple (block_root, state_slot) - match summaries.entry(summary.slot) { - Entry::Vacant(entry) => { - entry.insert((state_root, summary)); - } - Entry::Occupied(existing) => { - return Err(Error::DuplicateStateSummary { - block_root: summary.latest_block_root, - existing_state_summary: (summary.slot, *state_root).into(), - new_state_summary: (*existing.key(), *existing.get().0), - }); - } - } - } - - let state_summaries = state_summaries_v22 - .iter() - .map(|(state_root, summary)| { - let previous_state_root = if summary.slot == 0 { - Hash256::ZERO - } else { - let previous_slot = summary.slot - 1; - - // Check the set of states in the same state's block root - let same_block_root_summaries = state_summaries_by_block_root - .get(&summary.latest_block_root) - // Should never error: we construct the HashMap here and must have at least - // one entry per block root - .ok_or(Error::MissingStateSummaryByBlockRoot { - state_root: *state_root, - latest_block_root: summary.latest_block_root, - })?; - if let Some((state_root, _)) = same_block_root_summaries.get(&previous_slot) { - // Skipped slot: block root at previous slot is the same as latest block root. - **state_root - } else { - // Common case: not a skipped slot. - // - // If we can't find a state summmary for the parent block and previous slot, - // then there is some amount of disjointedness in the DAG. We set the parent - // state root to 0x0 in this case, and will prune any dangling states. - let parent_block_root = summary.block_parent_root; - state_summaries_by_block_root - .get(&parent_block_root) - .and_then(|parent_block_summaries| { - parent_block_summaries.get(&previous_slot) - }) - .map_or(Hash256::ZERO, |(parent_state_root, _)| **parent_state_root) - } - }; - - Ok(( - *state_root, - DAGStateSummary { - slot: summary.slot, - latest_block_root: summary.latest_block_root, - latest_block_slot: summary.block_slot, - previous_state_root, - }, - )) - }) - .collect::, _>>()?; - - Self::new(state_summaries) - } - // Returns all non-unique latest block roots of a given set of states pub fn blocks_of_states<'a, I: Iterator>( &self, @@ -379,106 +284,3 @@ impl From for DAGStateSummary { } } } - -#[cfg(test)] -mod tests { - use super::{DAGStateSummaryV22, Error, StateSummariesDAG}; - use bls::FixedBytesExtended; - use types::{Hash256, Slot}; - - fn root(n: u64) -> Hash256 { - Hash256::from_low_u64_le(n) - } - - #[test] - fn new_from_v22_empty() { - StateSummariesDAG::new_from_v22(vec![]).unwrap(); - } - - fn assert_previous_state_root_is_zero(dag: &StateSummariesDAG, root: Hash256) { - assert!(matches!( - dag.previous_state_root(root).unwrap_err(), - Error::RootUnknownPreviousStateRoot { .. } - )); - } - - #[test] - fn new_from_v22_one_state() { - let root_a = root(0xa); - let root_1 = root(1); - let root_2 = root(2); - let summary_1 = DAGStateSummaryV22 { - slot: Slot::new(1), - latest_block_root: root_1, - block_parent_root: root_2, - block_slot: Slot::new(1), - }; - - let dag = StateSummariesDAG::new_from_v22(vec![(root_a, summary_1)]).unwrap(); - - // The parent of the root summary is ZERO - assert_previous_state_root_is_zero(&dag, root_a); - } - - #[test] - fn new_from_v22_multiple_states() { - let dag = StateSummariesDAG::new_from_v22(vec![ - ( - root(0xa), - DAGStateSummaryV22 { - slot: Slot::new(3), - latest_block_root: root(3), - block_parent_root: root(1), - block_slot: Slot::new(3), - }, - ), - ( - root(0xb), - DAGStateSummaryV22 { - slot: Slot::new(4), - latest_block_root: root(4), - block_parent_root: root(3), - block_slot: Slot::new(4), - }, - ), - // fork 1 - ( - root(0xc), - DAGStateSummaryV22 { - slot: Slot::new(5), - latest_block_root: root(5), - block_parent_root: root(4), - block_slot: Slot::new(5), - }, - ), - // fork 2 - // skipped slot - ( - root(0xd), - DAGStateSummaryV22 { - slot: Slot::new(5), - latest_block_root: root(4), - block_parent_root: root(3), - block_slot: Slot::new(4), - }, - ), - // normal slot - ( - root(0xe), - DAGStateSummaryV22 { - slot: Slot::new(6), - latest_block_root: root(6), - block_parent_root: root(4), - block_slot: Slot::new(6), - }, - ), - ]) - .unwrap(); - - // The parent of the root summary is ZERO - assert_previous_state_root_is_zero(&dag, root(0xa)); - assert_eq!(dag.previous_state_root(root(0xc)).unwrap(), root(0xb)); - assert_eq!(dag.previous_state_root(root(0xd)).unwrap(), root(0xb)); - assert_eq!(dag.previous_state_root(root(0xe)).unwrap(), root(0xd)); - } -} diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 0e187a8f4b..2b4152b550 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -3995,11 +3995,7 @@ async fn schema_downgrade_to_min_version(store_config: StoreConfig, archive: boo ) .await; - let min_version = if spec.is_fulu_scheduled() { - SchemaVersion(27) - } else { - SchemaVersion(22) - }; + let min_version = CURRENT_SCHEMA_VERSION; // Save the slot clock so that the new harness doesn't revert in time. let slot_clock = harness.chain.slot_clock.clone(); diff --git a/beacon_node/store/src/database/leveldb_impl.rs b/beacon_node/store/src/database/leveldb_impl.rs index 6b8c615631..6e01648263 100644 --- a/beacon_node/store/src/database/leveldb_impl.rs +++ b/beacon_node/store/src/database/leveldb_impl.rs @@ -186,10 +186,8 @@ impl LevelDB { ) }; - for (start_key, end_key) in [ - endpoints(DBColumn::BeaconState), - endpoints(DBColumn::BeaconStateSummary), - ] { + { + let (start_key, end_key) = endpoints(DBColumn::BeaconStateHotSummary); self.db.compact(&start_key, &end_key); } diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index 8ef91b3c74..78dd69e55a 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -3270,12 +3270,10 @@ impl, Cold: ItemStore> HotColdDB Some(mut split) => { debug!(?split, "Loaded split partial"); // Load the hot state summary to get the block root. - let latest_block_root = self - .load_block_root_from_summary_any_version(&split.state_root) - .ok_or(HotColdDBError::MissingSplitState( - split.state_root, - split.slot, - ))?; + let latest_block_root = + self.load_block_root_from_summary(&split.state_root).ok_or( + HotColdDBError::MissingSplitState(split.state_root, split.slot), + )?; split.block_root = latest_block_root; Ok(Some(split)) } @@ -3306,29 +3304,11 @@ impl, Cold: ItemStore> HotColdDB .map_err(|e| Error::LoadHotStateSummary(*state_root, e.into())) } - /// Load a hot state's summary in V22 format, given its root. - pub fn load_hot_state_summary_v22( - &self, - state_root: &Hash256, - ) -> Result, Error> { - self.hot_db - .get(state_root) - .map_err(|e| Error::LoadHotStateSummary(*state_root, e.into())) - } - - /// Load the latest block root for a hot state summary either in modern form, or V22 form. - /// - /// This function is required to open a V22 database for migration to V24, or vice versa. - pub fn load_block_root_from_summary_any_version( - &self, - state_root: &Hash256, - ) -> Option { + /// Load the latest block root for a hot state summary. + pub fn load_block_root_from_summary(&self, state_root: &Hash256) -> Option { if let Ok(Some(summary)) = self.load_hot_state_summary(state_root) { return Some(summary.latest_block_root); } - if let Ok(Some(summary)) = self.load_hot_state_summary_v22(state_root) { - return Some(summary.latest_block_root); - } None } @@ -4287,30 +4267,6 @@ impl HotStateSummary { } } -/// Legacy hot state summary used in schema V22 and before. -/// -/// This can be deleted when we remove V22 support. -#[derive(Debug, Clone, Copy, Encode, Decode)] -pub struct HotStateSummaryV22 { - pub slot: Slot, - pub latest_block_root: Hash256, - pub epoch_boundary_state_root: Hash256, -} - -impl StoreItem for HotStateSummaryV22 { - fn db_column() -> DBColumn { - DBColumn::BeaconStateSummary - } - - fn as_store_bytes(&self) -> Vec { - self.as_ssz_bytes() - } - - fn from_store_bytes(bytes: &[u8]) -> Result { - Ok(Self::from_ssz_bytes(bytes)?) - } -} - /// Struct for summarising a state in the freezer database. #[derive(Debug, Clone, Copy, Default, Encode, Decode)] pub(crate) struct ColdStateSummary { diff --git a/beacon_node/store/src/lib.rs b/beacon_node/store/src/lib.rs index bfa1200602..bd8caa3ad5 100644 --- a/beacon_node/store/src/lib.rs +++ b/beacon_node/store/src/lib.rs @@ -77,11 +77,7 @@ pub trait KeyValueStore: Sync + Send + Sized + 'static { fn compact(&self) -> Result<(), Error> { // Compact state and block related columns as they are likely to have the most churn, // i.e. entries being created and deleted. - for column in [ - DBColumn::BeaconState, - DBColumn::BeaconStateHotSummary, - DBColumn::BeaconBlock, - ] { + for column in [DBColumn::BeaconStateHotSummary, DBColumn::BeaconBlock] { self.compact_column(column)?; } Ok(()) diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 9744b9fa08..74b287975e 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -6,7 +6,6 @@ use proto_array::{ Block as ProtoBlock, DisallowedReOrgOffsets, ExecutionStatus, JustifiedBalances, ProposerHeadError, ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, }; -use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use state_processing::{ per_block_processing::errors::AttesterSlashingValidationError, per_epoch_processing, @@ -1529,47 +1528,17 @@ where /// /// This is used when persisting the state of the fork choice to disk. #[superstruct( - variants(V17, V28), + variants(V28), variant_attributes(derive(Encode, Decode, Clone)), no_enum )] pub struct PersistedForkChoice { - #[superstruct(only(V17))] - pub proto_array_bytes: Vec, - #[superstruct(only(V28))] pub proto_array: proto_array::core::SszContainerV28, pub queued_attestations: Vec, } pub type PersistedForkChoice = PersistedForkChoiceV28; -impl TryFrom for PersistedForkChoiceV28 { - type Error = ssz::DecodeError; - - fn try_from(v17: PersistedForkChoiceV17) -> Result { - let container_v17 = - proto_array::core::SszContainerV17::from_ssz_bytes(&v17.proto_array_bytes)?; - let container_v28 = container_v17.into(); - - Ok(Self { - proto_array: container_v28, - queued_attestations: v17.queued_attestations, - }) - } -} - -impl From<(PersistedForkChoiceV28, JustifiedBalances)> for PersistedForkChoiceV17 { - fn from((v28, balances): (PersistedForkChoiceV28, JustifiedBalances)) -> Self { - let container_v17 = proto_array::core::SszContainerV17::from((v28.proto_array, balances)); - let proto_array_bytes = container_v17.as_ssz_bytes(); - - Self { - proto_array_bytes, - queued_attestations: v28.queued_attestations, - } - } -} - #[cfg(test)] mod tests { use types::MainnetEthSpec; diff --git a/consensus/fork_choice/src/lib.rs b/consensus/fork_choice/src/lib.rs index afe06dee1b..8cf2936db4 100644 --- a/consensus/fork_choice/src/lib.rs +++ b/consensus/fork_choice/src/lib.rs @@ -5,7 +5,7 @@ mod metrics; pub use crate::fork_choice::{ AttestationFromBlock, Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, InvalidAttestation, InvalidBlock, PayloadVerificationStatus, PersistedForkChoice, - PersistedForkChoiceV17, PersistedForkChoiceV28, QueuedAttestation, ResetPayloadStatuses, + PersistedForkChoiceV28, QueuedAttestation, ResetPayloadStatuses, }; pub use fork_choice_store::ForkChoiceStore; pub use proto_array::{ diff --git a/consensus/proto_array/src/lib.rs b/consensus/proto_array/src/lib.rs index 964e836d91..04e57d791b 100644 --- a/consensus/proto_array/src/lib.rs +++ b/consensus/proto_array/src/lib.rs @@ -16,5 +16,5 @@ pub use error::Error; pub mod core { pub use super::proto_array::{ProposerBoost, ProtoArray, ProtoNode}; pub use super::proto_array_fork_choice::VoteTracker; - pub use super::ssz_container::{SszContainer, SszContainerV17, SszContainerV28}; + pub use super::ssz_container::{SszContainer, SszContainerV28}; } diff --git a/consensus/proto_array/src/ssz_container.rs b/consensus/proto_array/src/ssz_container.rs index 1e01b74c8c..42696256f7 100644 --- a/consensus/proto_array/src/ssz_container.rs +++ b/consensus/proto_array/src/ssz_container.rs @@ -17,14 +17,12 @@ four_byte_option_impl!(four_byte_option_checkpoint, Checkpoint); pub type SszContainer = SszContainerV28; #[superstruct( - variants(V17, V28), + variants(V28), variant_attributes(derive(Encode, Decode, Clone)), no_enum )] pub struct SszContainer { pub votes: Vec, - #[superstruct(only(V17))] - pub balances: Vec, pub prune_threshold: usize, // Deprecated, remove in a future schema migration justified_checkpoint: Checkpoint, @@ -73,34 +71,3 @@ impl TryFrom<(SszContainer, JustifiedBalances)> for ProtoArrayForkChoice { }) } } - -// Convert V17 to V28 by dropping balances. -impl From for SszContainerV28 { - fn from(v17: SszContainerV17) -> Self { - Self { - votes: v17.votes, - prune_threshold: v17.prune_threshold, - justified_checkpoint: v17.justified_checkpoint, - finalized_checkpoint: v17.finalized_checkpoint, - nodes: v17.nodes, - indices: v17.indices, - previous_proposer_boost: v17.previous_proposer_boost, - } - } -} - -// Convert V28 to V17 by re-adding balances. -impl From<(SszContainerV28, JustifiedBalances)> for SszContainerV17 { - fn from((v28, balances): (SszContainerV28, JustifiedBalances)) -> Self { - Self { - votes: v28.votes, - balances: balances.effective_balances.clone(), - prune_threshold: v28.prune_threshold, - justified_checkpoint: v28.justified_checkpoint, - finalized_checkpoint: v28.finalized_checkpoint, - nodes: v28.nodes, - indices: v28.indices, - previous_proposer_boost: v28.previous_proposer_boost, - } - } -} From a5e748f8086d5456db0eafb7a7a2080d63aa7462 Mon Sep 17 00:00:00 2001 From: Mac L Date: Sun, 29 Mar 2026 22:39:20 +0400 Subject: [PATCH 090/189] Use `yaml_serde` in place of deprecated `serde_yaml` (#9040) `serde_yaml` is now deprecated. The API-compatible `yaml_serde` should be used instead. Replace `serde_yaml` with `yaml_serde`. This is purely mechanical as the API is 1-to-1. Co-Authored-By: Mac L --- Cargo.lock | 60 +++++++++---------- Cargo.toml | 2 +- beacon_node/client/Cargo.toml | 2 +- beacon_node/client/src/config.rs | 4 +- common/account_utils/Cargo.toml | 2 +- .../src/validator_definitions.rs | 32 +++++----- common/clap_utils/Cargo.toml | 2 +- common/clap_utils/src/lib.rs | 2 +- common/eth2_interop_keypairs/Cargo.toml | 2 +- common/eth2_interop_keypairs/src/lib.rs | 2 +- common/eth2_network_config/Cargo.toml | 2 +- common/eth2_network_config/src/lib.rs | 10 ++-- consensus/proto_array/Cargo.toml | 2 +- consensus/proto_array/src/bin.rs | 2 +- consensus/types/Cargo.toml | 2 +- consensus/types/src/core/chain_spec.rs | 20 +++---- consensus/types/src/core/config_and_preset.rs | 4 +- consensus/types/src/core/preset.rs | 2 +- deny.toml | 1 + lcli/Cargo.toml | 2 +- lcli/src/parse_ssz.rs | 2 +- lighthouse/Cargo.toml | 2 +- lighthouse/tests/exec.rs | 4 +- testing/ef_tests/Cargo.toml | 2 +- testing/ef_tests/src/decode.rs | 4 +- testing/web3signer_tests/Cargo.toml | 2 +- testing/web3signer_tests/src/lib.rs | 2 +- 27 files changed, 88 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6dd3332d7..3ba431d62e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,10 +42,10 @@ dependencies = [ "regex", "rpassword", "serde", - "serde_yaml", "tracing", "types", "validator_dir", + "yaml_serde", "zeroize", ] @@ -1885,8 +1885,8 @@ dependencies = [ "hex", "serde", "serde_json", - "serde_yaml", "types", + "yaml_serde", ] [[package]] @@ -1917,7 +1917,6 @@ dependencies = [ "sensitive_url", "serde", "serde_json", - "serde_yaml", "slasher", "slasher_service", "slot_clock", @@ -1930,6 +1929,7 @@ dependencies = [ "tracing", "tracing-subscriber", "types", + "yaml_serde", ] [[package]] @@ -2855,7 +2855,6 @@ dependencies = [ "serde", "serde_json", "serde_repr", - "serde_yaml", "snap", "ssz_types", "state_processing", @@ -2864,6 +2863,7 @@ dependencies = [ "tree_hash_derive", "typenum", "types", + "yaml_serde", ] [[package]] @@ -3164,7 +3164,7 @@ dependencies = [ "hex", "num-bigint", "serde", - "serde_yaml", + "yaml_serde", ] [[package]] @@ -3216,13 +3216,13 @@ dependencies = [ "pretty_reqwest_error", "reqwest", "sensitive_url", - "serde_yaml", "sha2", "tempfile", "tokio", "tracing", "types", "url", + "yaml_serde", "zip", ] @@ -4882,7 +4882,6 @@ dependencies = [ "rayon", "serde", "serde_json", - "serde_yaml", "snap", "state_processing", "store", @@ -4891,6 +4890,7 @@ dependencies = [ "tree_hash", "types", "validator_dir", + "yaml_serde", ] [[package]] @@ -5320,6 +5320,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + [[package]] name = "libz-rs-sys" version = "0.5.4" @@ -5374,7 +5380,6 @@ dependencies = [ "sensitive_url", "serde", "serde_json", - "serde_yaml", "slasher", "slashing_protection", "store", @@ -5388,6 +5393,7 @@ dependencies = [ "validator_client", "validator_dir", "validator_manager", + "yaml_serde", "zeroize", ] @@ -7017,9 +7023,9 @@ dependencies = [ "fixed_bytes", "safe_arith", "serde", - "serde_yaml", "superstruct", "types", + "yaml_serde", ] [[package]] @@ -8054,19 +8060,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.12.1", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serdect" version = "0.2.0" @@ -9361,7 +9354,6 @@ dependencies = [ "safe_arith", "serde", "serde_json", - "serde_yaml", "smallvec", "ssz_types", "state_processing", @@ -9374,6 +9366,7 @@ dependencies = [ "tree_hash", "tree_hash_derive", "typenum", + "yaml_serde", ] [[package]] @@ -9461,12 +9454,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "unsigned-varint" version = "0.8.0" @@ -9969,7 +9956,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "serde_yaml", "slashing_protection", "slot_clock", "ssz_types", @@ -9979,6 +9965,7 @@ dependencies = [ "types", "url", "validator_store", + "yaml_serde", "zip", ] @@ -10493,6 +10480,19 @@ dependencies = [ "hashlink 0.11.0", ] +[[package]] +name = "yaml_serde" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" +dependencies = [ + "indexmap 2.12.1", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yamux" version = "0.12.1" diff --git a/Cargo.toml b/Cargo.toml index 340b650bca..96d57e0210 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -213,7 +213,6 @@ sensitive_url = { version = "0.1", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" -serde_yaml = "0.9" sha2 = "0.10" signing_method = { path = "validator_client/signing_method" } slasher = { path = "slasher", default-features = false } @@ -260,6 +259,7 @@ warp = { version = "0.3.7", default-features = false, features = ["tls"] } warp_utils = { path = "common/warp_utils" } workspace_members = { path = "common/workspace_members" } xdelta3 = { git = "https://github.com/sigp/xdelta3-rs", rev = "fe3906605c87b6c0515bd7c8fc671f47875e3ccc" } +yaml_serde = "0.10" zeroize = { version = "1", features = ["zeroize_derive", "serde"] } zip = { version = "6.0", default-features = false, features = ["deflate"] } zstd = "0.13" diff --git a/beacon_node/client/Cargo.toml b/beacon_node/client/Cargo.toml index 3c4b2572c9..50d76e8f19 100644 --- a/beacon_node/client/Cargo.toml +++ b/beacon_node/client/Cargo.toml @@ -42,6 +42,6 @@ types = { workspace = true } [dev-dependencies] operation_pool = { workspace = true } -serde_yaml = { workspace = true } state_processing = { workspace = true } tokio = { workspace = true } +yaml_serde = { workspace = true } diff --git a/beacon_node/client/src/config.rs b/beacon_node/client/src/config.rs index aeaa196df8..851eb5da6c 100644 --- a/beacon_node/client/src/config.rs +++ b/beacon_node/client/src/config.rs @@ -236,7 +236,7 @@ mod tests { fn serde() { let config = Config::default(); let serialized = - serde_yaml::to_string(&config).expect("should serde encode default config"); - serde_yaml::from_str::(&serialized).expect("should serde decode default config"); + yaml_serde::to_string(&config).expect("should serde encode default config"); + yaml_serde::from_str::(&serialized).expect("should serde decode default config"); } } diff --git a/common/account_utils/Cargo.toml b/common/account_utils/Cargo.toml index d0a3e487c4..b5c84bbb64 100644 --- a/common/account_utils/Cargo.toml +++ b/common/account_utils/Cargo.toml @@ -14,8 +14,8 @@ rand = { workspace = true } regex = { workspace = true } rpassword = "5.0.0" serde = { workspace = true } -serde_yaml = { workspace = true } tracing = { workspace = true } types = { workspace = true } validator_dir = { workspace = true } +yaml_serde = { workspace = true } zeroize = { workspace = true } diff --git a/common/account_utils/src/validator_definitions.rs b/common/account_utils/src/validator_definitions.rs index bffdfcc38b..0fc5bf5665 100644 --- a/common/account_utils/src/validator_definitions.rs +++ b/common/account_utils/src/validator_definitions.rs @@ -31,11 +31,11 @@ pub enum Error { /// The config file could not be opened. UnableToOpenFile(io::Error), /// The config file could not be parsed as YAML. - UnableToParseFile(serde_yaml::Error), + UnableToParseFile(yaml_serde::Error), /// There was an error whilst performing the recursive keystore search function. UnableToSearchForKeystores(io::Error), /// The config file could not be serialized as YAML. - UnableToEncodeFile(serde_yaml::Error), + UnableToEncodeFile(yaml_serde::Error), /// The config file or temp file could not be written to the filesystem. UnableToWriteFile(filesystem::Error), /// The public key from the keystore is invalid. @@ -248,7 +248,7 @@ impl ValidatorDefinitions { .create_new(false) .open(config_path) .map_err(Error::UnableToOpenFile)?; - serde_yaml::from_reader(file).map_err(Error::UnableToParseFile) + yaml_serde::from_reader(file).map_err(Error::UnableToParseFile) } /// Perform a recursive, exhaustive search through `validators_dir` and add any keystores @@ -376,7 +376,7 @@ impl ValidatorDefinitions { let config_path = validators_dir.as_ref().join(CONFIG_FILENAME); let temp_path = validators_dir.as_ref().join(CONFIG_TEMP_FILENAME); let mut bytes = vec![]; - serde_yaml::to_writer(&mut bytes, self).map_err(Error::UnableToEncodeFile)?; + yaml_serde::to_writer(&mut bytes, self).map_err(Error::UnableToEncodeFile)?; write_file_via_temporary(&config_path, &temp_path, &bytes) .map_err(Error::UnableToWriteFile)?; @@ -531,7 +531,7 @@ mod tests { voting_keystore_path: "" voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(no_graffiti).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(no_graffiti).unwrap(); assert!(def.graffiti.is_none()); let invalid_graffiti = r#"--- @@ -543,7 +543,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: Result = serde_yaml::from_str(invalid_graffiti); + let def: Result = yaml_serde::from_str(invalid_graffiti); assert!(def.is_err()); let valid_graffiti = r#"--- @@ -555,7 +555,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(valid_graffiti).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(valid_graffiti).unwrap(); assert_eq!( def.graffiti, Some(GraffitiString::from_str("mrfwashere").unwrap()) @@ -571,7 +571,7 @@ mod tests { voting_keystore_path: "" voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(no_suggested_fee_recipient).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(no_suggested_fee_recipient).unwrap(); assert!(def.suggested_fee_recipient.is_none()); let invalid_suggested_fee_recipient = r#"--- @@ -584,7 +584,7 @@ mod tests { "#; let def: Result = - serde_yaml::from_str(invalid_suggested_fee_recipient); + yaml_serde::from_str(invalid_suggested_fee_recipient); assert!(def.is_err()); let valid_suggested_fee_recipient = r#"--- @@ -596,7 +596,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(valid_suggested_fee_recipient).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(valid_suggested_fee_recipient).unwrap(); assert_eq!( def.suggested_fee_recipient, Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()) @@ -613,7 +613,7 @@ mod tests { voting_keystore_path: "" voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(no_gas_limit).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(no_gas_limit).unwrap(); assert!(def.gas_limit.is_none()); let invalid_gas_limit = r#"--- @@ -626,7 +626,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: Result = serde_yaml::from_str(invalid_gas_limit); + let def: Result = yaml_serde::from_str(invalid_gas_limit); assert!(def.is_err()); let valid_gas_limit = r#"--- @@ -639,7 +639,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(valid_gas_limit).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(valid_gas_limit).unwrap(); assert_eq!(def.gas_limit, Some(35000000)); } @@ -653,7 +653,7 @@ mod tests { voting_keystore_path: "" voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(no_builder_proposals).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(no_builder_proposals).unwrap(); assert!(def.builder_proposals.is_none()); let invalid_builder_proposals = r#"--- @@ -666,7 +666,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: Result = serde_yaml::from_str(invalid_builder_proposals); + let def: Result = yaml_serde::from_str(invalid_builder_proposals); assert!(def.is_err()); let valid_builder_proposals = r#"--- @@ -679,7 +679,7 @@ mod tests { voting_public_key: "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" "#; - let def: ValidatorDefinition = serde_yaml::from_str(valid_builder_proposals).unwrap(); + let def: ValidatorDefinition = yaml_serde::from_str(valid_builder_proposals).unwrap(); assert_eq!(def.builder_proposals, Some(true)); } } diff --git a/common/clap_utils/Cargo.toml b/common/clap_utils/Cargo.toml index f3c166bda9..02c9ac97f1 100644 --- a/common/clap_utils/Cargo.toml +++ b/common/clap_utils/Cargo.toml @@ -14,5 +14,5 @@ ethereum_ssz = { workspace = true } hex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } types = { workspace = true } +yaml_serde = { workspace = true } diff --git a/common/clap_utils/src/lib.rs b/common/clap_utils/src/lib.rs index bc904c78e3..e969ab95d4 100644 --- a/common/clap_utils/src/lib.rs +++ b/common/clap_utils/src/lib.rs @@ -159,7 +159,7 @@ where let chain_config = Config::from_chain_spec::(spec); let mut file = std::fs::File::create(dump_path) .map_err(|e| format!("Failed to open file for writing chain config: {:?}", e))?; - serde_yaml::to_writer(&mut file, &chain_config) + yaml_serde::to_writer(&mut file, &chain_config) .map_err(|e| format!("Error serializing config: {:?}", e))?; } Ok(()) diff --git a/common/eth2_interop_keypairs/Cargo.toml b/common/eth2_interop_keypairs/Cargo.toml index 309ff233e6..7eed6032c9 100644 --- a/common/eth2_interop_keypairs/Cargo.toml +++ b/common/eth2_interop_keypairs/Cargo.toml @@ -12,7 +12,7 @@ ethereum_hashing = { workspace = true } hex = { workspace = true } num-bigint = "0.4.2" serde = { workspace = true } -serde_yaml = { workspace = true } +yaml_serde = { workspace = true } [dev-dependencies] base64 = "0.13.0" diff --git a/common/eth2_interop_keypairs/src/lib.rs b/common/eth2_interop_keypairs/src/lib.rs index 0d24eb92f4..d00984a2d1 100644 --- a/common/eth2_interop_keypairs/src/lib.rs +++ b/common/eth2_interop_keypairs/src/lib.rs @@ -118,7 +118,7 @@ fn string_to_bytes(string: &str) -> Result, String> { pub fn keypairs_from_yaml_file(path: PathBuf) -> Result, String> { let file = File::open(path).map_err(|e| format!("Unable to open YAML key file: {}", e))?; - serde_yaml::from_reader::<_, Vec>(file) + yaml_serde::from_reader::<_, Vec>(file) .map_err(|e| format!("Could not parse YAML: {:?}", e))? .into_iter() .map(TryInto::try_into) diff --git a/common/eth2_network_config/Cargo.toml b/common/eth2_network_config/Cargo.toml index 416ffb1975..d2bdfea1fa 100644 --- a/common/eth2_network_config/Cargo.toml +++ b/common/eth2_network_config/Cargo.toml @@ -15,11 +15,11 @@ kzg = { workspace = true } pretty_reqwest_error = { workspace = true } reqwest = { workspace = true } sensitive_url = { workspace = true } -serde_yaml = { workspace = true } sha2 = { workspace = true } tracing = { workspace = true } types = { workspace = true } url = { workspace = true } +yaml_serde = { workspace = true } [build-dependencies] eth2_config = { workspace = true } diff --git a/common/eth2_network_config/src/lib.rs b/common/eth2_network_config/src/lib.rs index 6fd8567bed..408ce6135d 100644 --- a/common/eth2_network_config/src/lib.rs +++ b/common/eth2_network_config/src/lib.rs @@ -101,14 +101,14 @@ impl Eth2NetworkConfig { /// Instantiates `Self` from a `HardcodedNet`. fn from_hardcoded_net(net: &HardcodedNet) -> Result { - let config: Config = serde_yaml::from_reader(net.config) + let config: Config = yaml_serde::from_reader(net.config) .map_err(|e| format!("Unable to parse yaml config: {:?}", e))?; let kzg_trusted_setup = get_trusted_setup(); Ok(Self { - deposit_contract_deploy_block: serde_yaml::from_reader(net.deploy_block) + deposit_contract_deploy_block: yaml_serde::from_reader(net.deploy_block) .map_err(|e| format!("Unable to parse deploy block: {:?}", e))?, boot_enr: Some( - serde_yaml::from_reader(net.boot_enr) + yaml_serde::from_reader(net.boot_enr) .map_err(|e| format!("Unable to parse boot enr: {:?}", e))?, ), genesis_state_source: net.genesis_state_source, @@ -286,7 +286,7 @@ impl Eth2NetworkConfig { File::create(base_dir.join($file)) .map_err(|e| format!("Unable to create {}: {:?}", $file, e)) .and_then(|mut file| { - let yaml = serde_yaml::to_string(&$variable) + let yaml = yaml_serde::to_string(&$variable) .map_err(|e| format!("Unable to YAML encode {}: {:?}", $file, e))?; // Remove the doc header from the YAML file. @@ -334,7 +334,7 @@ impl Eth2NetworkConfig { File::open(base_dir.join($file)) .map_err(|e| format!("Unable to open {}: {:?}", $file, e)) .and_then(|file| { - serde_yaml::from_reader(file) + yaml_serde::from_reader(file) .map_err(|e| format!("Unable to parse {}: {:?}", $file, e)) })? }; diff --git a/consensus/proto_array/Cargo.toml b/consensus/proto_array/Cargo.toml index 782610e0d3..7419ad813b 100644 --- a/consensus/proto_array/Cargo.toml +++ b/consensus/proto_array/Cargo.toml @@ -14,6 +14,6 @@ ethereum_ssz_derive = { workspace = true } fixed_bytes = { workspace = true } safe_arith = { workspace = true } serde = { workspace = true } -serde_yaml = { workspace = true } superstruct = { workspace = true } types = { workspace = true } +yaml_serde = { workspace = true } diff --git a/consensus/proto_array/src/bin.rs b/consensus/proto_array/src/bin.rs index e1d307affb..38ba3150e7 100644 --- a/consensus/proto_array/src/bin.rs +++ b/consensus/proto_array/src/bin.rs @@ -22,5 +22,5 @@ fn main() { fn write_test_def_to_yaml(filename: &str, def: ForkChoiceTestDefinition) { let file = File::create(filename).expect("Should be able to open file"); - serde_yaml::to_writer(file, &def).expect("Should be able to write YAML to file"); + yaml_serde::to_writer(file, &def).expect("Should be able to write YAML to file"); } diff --git a/consensus/types/Cargo.toml b/consensus/types/Cargo.toml index c09e3d6931..4aae4b7f39 100644 --- a/consensus/types/Cargo.toml +++ b/consensus/types/Cargo.toml @@ -53,7 +53,6 @@ rusqlite = { workspace = true, optional = true } safe_arith = { workspace = true } serde = { workspace = true, features = ["rc"] } serde_json = { workspace = true } -serde_yaml = { workspace = true } smallvec = { workspace = true } ssz_types = { workspace = true } superstruct = { workspace = true } @@ -64,6 +63,7 @@ tracing = { workspace = true } tree_hash = { workspace = true } tree_hash_derive = { workspace = true } typenum = { workspace = true } +yaml_serde = { workspace = true } [dev-dependencies] beacon_chain = { workspace = true } diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index 458622d7e6..8a2b3a23e8 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -2531,7 +2531,7 @@ impl Config { pub fn from_file(filename: &Path) -> Result { let f = File::open(filename) .map_err(|e| format!("Error opening spec at {}: {:?}", filename.display(), e))?; - serde_yaml::from_reader(f) + yaml_serde::from_reader(f) .map_err(|e| format!("Error parsing spec at {}: {:?}", filename.display(), e)) } @@ -2869,7 +2869,7 @@ mod yaml_tests { let yamlconfig = Config::from_chain_spec::(&minimal_spec); // write fresh minimal config to file - serde_yaml::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); + yaml_serde::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); let reader = File::options() .read(true) @@ -2877,7 +2877,7 @@ mod yaml_tests { .open(tmp_file.as_ref()) .expect("error while opening the file"); // deserialize minimal config from file - let from: Config = serde_yaml::from_reader(reader).expect("error while deserializing"); + let from: Config = yaml_serde::from_reader(reader).expect("error while deserializing"); assert_eq!(from, yamlconfig); } @@ -2891,14 +2891,14 @@ mod yaml_tests { .expect("error opening file"); let mainnet_spec = ChainSpec::mainnet(); let yamlconfig = Config::from_chain_spec::(&mainnet_spec); - serde_yaml::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); + yaml_serde::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); let reader = File::options() .read(true) .write(false) .open(tmp_file.as_ref()) .expect("error while opening the file"); - let from: Config = serde_yaml::from_reader(reader).expect("error while deserializing"); + let from: Config = yaml_serde::from_reader(reader).expect("error while deserializing"); assert_eq!(from, yamlconfig); } @@ -2960,7 +2960,7 @@ mod yaml_tests { MAX_BLOBS_PER_BLOCK: 20 "#; let config: Config = - serde_yaml::from_str(spec_contents).expect("error while deserializing"); + yaml_serde::from_str(spec_contents).expect("error while deserializing"); let spec = ChainSpec::from_config::(&config).expect("error while creating spec"); @@ -3042,11 +3042,11 @@ mod yaml_tests { assert_eq!(spec.max_blobs_per_block_within_fork(ForkName::Fulu), 20); // Check that serialization is in ascending order - let yaml = serde_yaml::to_string(&spec.blob_schedule).expect("should serialize"); + let yaml = yaml_serde::to_string(&spec.blob_schedule).expect("should serialize"); // Deserialize back to Vec to check order let deserialized: Vec = - serde_yaml::from_str(&yaml).expect("should deserialize"); + yaml_serde::from_str(&yaml).expect("should deserialize"); // Should be in ascending order by epoch assert!( @@ -3113,7 +3113,7 @@ mod yaml_tests { MAX_BLOBS_PER_BLOCK: 300 "#; let config: Config = - serde_yaml::from_str(spec_contents).expect("error while deserializing"); + yaml_serde::from_str(spec_contents).expect("error while deserializing"); let spec = ChainSpec::from_config::(&config).expect("error while creating spec"); @@ -3203,7 +3203,7 @@ mod yaml_tests { SAMPLES_PER_SLOT: 8 "#; - let chain_spec: Config = serde_yaml::from_str(spec).unwrap(); + let chain_spec: Config = yaml_serde::from_str(spec).unwrap(); // Asserts that `chain_spec.$name` and `default_$name()` are equal. macro_rules! check_default { diff --git a/consensus/types/src/core/config_and_preset.rs b/consensus/types/src/core/config_and_preset.rs index 5b8b27b02e..06f080e82b 100644 --- a/consensus/types/src/core/config_and_preset.rs +++ b/consensus/types/src/core/config_and_preset.rs @@ -174,7 +174,7 @@ mod test { yamlconfig.extra_fields_mut().insert(k3.into(), v3.into()); yamlconfig.extra_fields_mut().insert(k4.into(), v4); - serde_yaml::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); + yaml_serde::to_writer(writer, &yamlconfig).expect("failed to write or serialize"); let reader = File::options() .read(true) @@ -182,7 +182,7 @@ mod test { .open(tmp_file.as_ref()) .expect("error while opening the file"); let from: ConfigAndPresetGloas = - serde_yaml::from_reader(reader).expect("error while deserializing"); + yaml_serde::from_reader(reader).expect("error while deserializing"); assert_eq!(ConfigAndPreset::Gloas(from), yamlconfig); } diff --git a/consensus/types/src/core/preset.rs b/consensus/types/src/core/preset.rs index 5b1978f8e9..4fa7a28204 100644 --- a/consensus/types/src/core/preset.rs +++ b/consensus/types/src/core/preset.rs @@ -359,7 +359,7 @@ mod test { fn preset_from_file(preset_name: &str, filename: &str) -> T { let f = File::open(presets_base_path().join(preset_name).join(filename)) .expect("preset file exists"); - serde_yaml::from_reader(f).unwrap() + yaml_serde::from_reader(f).unwrap() } fn preset_test() { diff --git a/deny.toml b/deny.toml index cf0cd7d3cd..015f2ec88b 100644 --- a/deny.toml +++ b/deny.toml @@ -12,6 +12,7 @@ deny = [ { crate = "ark-ff", reason = "present in Cargo.lock but not needed by Lighthouse" }, { crate = "openssl", reason = "non-Rust dependency, use rustls instead" }, { crate = "c-kzg", reason = "non-Rust dependency, use rust_eth_kzg instead" }, + { crate = "serde_yaml", reason = "deprecated, use yaml_serde instead" }, { crate = "strum", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "reqwest", deny-multiple-versions = true, reason = "takes a long time to compile" }, { crate = "aes", deny-multiple-versions = true, reason = "takes a long time to compile" }, diff --git a/lcli/Cargo.toml b/lcli/Cargo.toml index 43e361b60d..84525c05b9 100644 --- a/lcli/Cargo.toml +++ b/lcli/Cargo.toml @@ -35,7 +35,6 @@ network_utils = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } snap = { workspace = true } state_processing = { workspace = true } store = { workspace = true } @@ -44,6 +43,7 @@ tracing-subscriber = { workspace = true } tree_hash = { workspace = true } types = { workspace = true } validator_dir = { workspace = true } +yaml_serde = { workspace = true } [target.'cfg(not(target_os = "windows"))'.dependencies] malloc_utils = { workspace = true, features = ["jemalloc"] } diff --git a/lcli/src/parse_ssz.rs b/lcli/src/parse_ssz.rs index f1e5c5759a..cd739b2a9e 100644 --- a/lcli/src/parse_ssz.rs +++ b/lcli/src/parse_ssz.rs @@ -141,7 +141,7 @@ fn decode_and_print( OutputFormat::Yaml => { println!( "{}", - serde_yaml::to_string(&item) + yaml_serde::to_string(&item) .map_err(|e| format!("Unable to write object to YAML: {e:?}"))? ); } diff --git a/lighthouse/Cargo.toml b/lighthouse/Cargo.toml index 000c6fd0da..3595cf04e7 100644 --- a/lighthouse/Cargo.toml +++ b/lighthouse/Cargo.toml @@ -62,7 +62,6 @@ opentelemetry-otlp = { workspace = true } opentelemetry_sdk = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } slasher = { workspace = true } store = { workspace = true } task_executor = { workspace = true } @@ -73,6 +72,7 @@ tracing_samplers = { workspace = true } types = { workspace = true } validator_client = { workspace = true } validator_manager = { path = "../validator_manager" } +yaml_serde = { workspace = true } [target.'cfg(not(target_os = "windows"))'.dependencies] malloc_utils = { workspace = true, features = ["jemalloc"] } diff --git a/lighthouse/tests/exec.rs b/lighthouse/tests/exec.rs index 5379912c13..a25558bc2f 100644 --- a/lighthouse/tests/exec.rs +++ b/lighthouse/tests/exec.rs @@ -65,7 +65,7 @@ pub trait CommandLineTestExec { let spec_file = File::open(tmp_chain_config_path).expect("Unable to open dumped chain spec"); let chain_config: Config = - serde_yaml::from_reader(spec_file).expect("Unable to deserialize config"); + yaml_serde::from_reader(spec_file).expect("Unable to deserialize config"); CompletedTest::new(config, chain_config, tmp_dir) } @@ -102,7 +102,7 @@ pub trait CommandLineTestExec { let spec_file = File::open(tmp_chain_config_path).expect("Unable to open dumped chain spec"); let chain_config: Config = - serde_yaml::from_reader(spec_file).expect("Unable to deserialize config"); + yaml_serde::from_reader(spec_file).expect("Unable to deserialize config"); CompletedTest::new(config, chain_config, tmp_dir) } diff --git a/testing/ef_tests/Cargo.toml b/testing/ef_tests/Cargo.toml index cef201ee91..9d09c3dfe6 100644 --- a/testing/ef_tests/Cargo.toml +++ b/testing/ef_tests/Cargo.toml @@ -32,7 +32,6 @@ rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_repr = { workspace = true } -serde_yaml = { workspace = true } snap = { workspace = true } ssz_types = { workspace = true } state_processing = { workspace = true } @@ -41,3 +40,4 @@ tree_hash = { workspace = true } tree_hash_derive = { workspace = true } typenum = { workspace = true } types = { workspace = true } +yaml_serde = { workspace = true } diff --git a/testing/ef_tests/src/decode.rs b/testing/ef_tests/src/decode.rs index 2074ffce23..f4aa17fb08 100644 --- a/testing/ef_tests/src/decode.rs +++ b/testing/ef_tests/src/decode.rs @@ -33,14 +33,14 @@ pub fn log_file_access>(file_accessed: P) { } pub fn yaml_decode(string: &str) -> Result { - serde_yaml::from_str(string).map_err(|e| Error::FailedToParseTest(format!("{:?}", e))) + yaml_serde::from_str(string).map_err(|e| Error::FailedToParseTest(format!("{:?}", e))) } pub fn context_yaml_decode<'de, T, C>(string: &'de str, context: C) -> Result where T: ContextDeserialize<'de, C>, { - let deserializer = serde_yaml::Deserializer::from_str(string); + let deserializer = yaml_serde::Deserializer::from_str(string); T::context_deserialize(deserializer, context) .map_err(|e| Error::FailedToParseTest(format!("{:?}", e))) } diff --git a/testing/web3signer_tests/Cargo.toml b/testing/web3signer_tests/Cargo.toml index 3ef2e0f7f7..1cac45fe52 100644 --- a/testing/web3signer_tests/Cargo.toml +++ b/testing/web3signer_tests/Cargo.toml @@ -23,7 +23,6 @@ parking_lot = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } slashing_protection = { workspace = true } slot_clock = { workspace = true } ssz_types = { workspace = true } @@ -33,4 +32,5 @@ tokio = { workspace = true } types = { workspace = true } url = { workspace = true } validator_store = { workspace = true } +yaml_serde = { workspace = true } zip = { workspace = true } diff --git a/testing/web3signer_tests/src/lib.rs b/testing/web3signer_tests/src/lib.rs index 1f36f8d4ce..1e1e83d339 100644 --- a/testing/web3signer_tests/src/lib.rs +++ b/testing/web3signer_tests/src/lib.rs @@ -210,7 +210,7 @@ mod tests { }; let key_config_file = File::create(keystore_dir.path().join("key-config.yaml")).unwrap(); - serde_yaml::to_writer(key_config_file, &key_config).unwrap(); + yaml_serde::to_writer(key_config_file, &key_config).unwrap(); let tls_keystore_file = tls_dir().join("web3signer").join("key.p12"); let tls_keystore_password_file = tls_dir().join("web3signer").join("password.txt"); From 5efaf85c90507894bde85ec99569673a642d1626 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Mon, 30 Mar 2026 13:52:08 +0900 Subject: [PATCH 091/189] Gloas new payload v5 (#9037) Use the new payload v5 engine api for gloas. This is required for ePBS devnets In a separate PR we can implement the full engine api spec changes for glamsterdam https://github.com/ethereum/execution-apis/blob/main/src/engine/amsterdam.md Co-Authored-By: Eitan Seri- Levi --- beacon_node/execution_layer/src/engine_api.rs | 6 ++++- .../execution_layer/src/engine_api/http.rs | 13 ++++++---- .../src/test_utils/handle_rpc.rs | 24 ++++++++++++------- .../execution_layer/src/test_utils/mod.rs | 1 + 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index 32090bccfc..774eac5fe2 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -5,7 +5,7 @@ use crate::http::{ ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1, ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V1, ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, - ENGINE_NEW_PAYLOAD_V4, + ENGINE_NEW_PAYLOAD_V4, ENGINE_NEW_PAYLOAD_V5, }; use eth2::types::{ BlobsBundle, SsePayloadAttributes, SsePayloadAttributesV1, SsePayloadAttributesV2, @@ -551,6 +551,7 @@ pub struct EngineCapabilities { pub new_payload_v2: bool, pub new_payload_v3: bool, pub new_payload_v4: bool, + pub new_payload_v5: bool, pub forkchoice_updated_v1: bool, pub forkchoice_updated_v2: bool, pub forkchoice_updated_v3: bool, @@ -581,6 +582,9 @@ impl EngineCapabilities { if self.new_payload_v4 { response.push(ENGINE_NEW_PAYLOAD_V4); } + if self.new_payload_v5 { + response.push(ENGINE_NEW_PAYLOAD_V5); + } if self.forkchoice_updated_v1 { response.push(ENGINE_FORKCHOICE_UPDATED_V1); } diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index c421491f80..bcd95d1ae4 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -35,6 +35,7 @@ pub const ENGINE_NEW_PAYLOAD_V1: &str = "engine_newPayloadV1"; pub const ENGINE_NEW_PAYLOAD_V2: &str = "engine_newPayloadV2"; pub const ENGINE_NEW_PAYLOAD_V3: &str = "engine_newPayloadV3"; pub const ENGINE_NEW_PAYLOAD_V4: &str = "engine_newPayloadV4"; +pub const ENGINE_NEW_PAYLOAD_V5: &str = "engine_newPayloadV5"; pub const ENGINE_NEW_PAYLOAD_TIMEOUT: Duration = Duration::from_secs(8); pub const ENGINE_GET_PAYLOAD_V1: &str = "engine_getPayloadV1"; @@ -74,6 +75,7 @@ pub static LIGHTHOUSE_CAPABILITIES: &[&str] = &[ ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, + ENGINE_NEW_PAYLOAD_V5, ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, ENGINE_GET_PAYLOAD_V3, @@ -883,7 +885,7 @@ impl HttpJsonRpc { Ok(response.into()) } - pub async fn new_payload_v4_gloas( + pub async fn new_payload_v5_gloas( &self, new_payload_request_gloas: NewPayloadRequestGloas<'_, E>, ) -> Result { @@ -903,7 +905,7 @@ impl HttpJsonRpc { let response: JsonPayloadStatusV1 = self .rpc_request( - ENGINE_NEW_PAYLOAD_V4, + ENGINE_NEW_PAYLOAD_V5, params, ENGINE_NEW_PAYLOAD_TIMEOUT * self.execution_timeout_multiplier, ) @@ -1198,6 +1200,7 @@ impl HttpJsonRpc { new_payload_v2: capabilities.contains(ENGINE_NEW_PAYLOAD_V2), new_payload_v3: capabilities.contains(ENGINE_NEW_PAYLOAD_V3), new_payload_v4: capabilities.contains(ENGINE_NEW_PAYLOAD_V4), + new_payload_v5: capabilities.contains(ENGINE_NEW_PAYLOAD_V5), forkchoice_updated_v1: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V1), forkchoice_updated_v2: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V2), forkchoice_updated_v3: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V3), @@ -1353,10 +1356,10 @@ impl HttpJsonRpc { } } NewPayloadRequest::Gloas(new_payload_request_gloas) => { - if engine_capabilities.new_payload_v4 { - self.new_payload_v4_gloas(new_payload_request_gloas).await + if engine_capabilities.new_payload_v5 { + self.new_payload_v5_gloas(new_payload_request_gloas).await } else { - Err(Error::RequiredMethodUnsupported("engine_newPayloadV4")) + Err(Error::RequiredMethodUnsupported("engine_newPayloadV5")) } } } diff --git a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs index 7a81017b3f..e263e5402a 100644 --- a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs +++ b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs @@ -102,7 +102,8 @@ pub async fn handle_rpc( ENGINE_NEW_PAYLOAD_V1 | ENGINE_NEW_PAYLOAD_V2 | ENGINE_NEW_PAYLOAD_V3 - | ENGINE_NEW_PAYLOAD_V4 => { + | ENGINE_NEW_PAYLOAD_V4 + | ENGINE_NEW_PAYLOAD_V5 => { let request = match method { ENGINE_NEW_PAYLOAD_V1 => JsonExecutionPayload::Bellatrix( get_param::>(params, 0) @@ -118,17 +119,16 @@ pub async fn handle_rpc( ENGINE_NEW_PAYLOAD_V3 => get_param::>(params, 0) .map(|jep| JsonExecutionPayload::Deneb(jep)) .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?, - ENGINE_NEW_PAYLOAD_V4 => get_param::>(params, 0) - .map(|jep| JsonExecutionPayload::Gloas(jep)) - .or_else(|_| { - get_param::>(params, 0) - .map(|jep| JsonExecutionPayload::Fulu(jep)) - }) + ENGINE_NEW_PAYLOAD_V4 => get_param::>(params, 0) + .map(|jep| JsonExecutionPayload::Fulu(jep)) .or_else(|_| { get_param::>(params, 0) .map(|jep| JsonExecutionPayload::Electra(jep)) }) .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?, + ENGINE_NEW_PAYLOAD_V5 => get_param::>(params, 0) + .map(|jep| JsonExecutionPayload::Gloas(jep)) + .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?, _ => unreachable!(), }; @@ -192,7 +192,7 @@ pub async fn handle_rpc( )); } } - ForkName::Electra | ForkName::Fulu | ForkName::Gloas => { + ForkName::Electra | ForkName::Fulu => { if method == ENGINE_NEW_PAYLOAD_V1 || method == ENGINE_NEW_PAYLOAD_V2 || method == ENGINE_NEW_PAYLOAD_V3 @@ -230,6 +230,14 @@ pub async fn handle_rpc( )); } } + ForkName::Gloas => { + if method != ENGINE_NEW_PAYLOAD_V5 { + return Err(( + format!("{} called after Gloas fork!", method), + GENERIC_ERROR_CODE, + )); + } + } _ => unreachable!(), }; diff --git a/beacon_node/execution_layer/src/test_utils/mod.rs b/beacon_node/execution_layer/src/test_utils/mod.rs index d8e1e70e49..47e3c9064c 100644 --- a/beacon_node/execution_layer/src/test_utils/mod.rs +++ b/beacon_node/execution_layer/src/test_utils/mod.rs @@ -43,6 +43,7 @@ pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = EngineCapabilities { new_payload_v2: true, new_payload_v3: true, new_payload_v4: true, + new_payload_v5: true, forkchoice_updated_v1: true, forkchoice_updated_v2: true, forkchoice_updated_v3: true, From 991dc92d8f9d02482750387d34698a51aae81a0d Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 30 Mar 2026 17:43:57 +1100 Subject: [PATCH 092/189] Check `ChainSpec` consistency with upstream `config.yaml` (#9008) Closes: - https://github.com/sigp/lighthouse/issues/9002 - Commit `config.yaml` for minimal and mainnet to `consensus/types/configs`. For now we omit any auto-downloading logic, to avoid the hassles of dealing with Github rate limits etc on CI. Unfortunately these files are NOT bundled inside the spec tests. - Fix the values of `min_builder_withdrawability_delay` for minimal and mainnet. These discrepancies aren't caught by the current spec tests, because the spec tests are missing data: https://github.com/ethereum/consensus-specs/pull/5005. Will be fixed in the next release/when we update to nightly. - Fix the blob schedule for `minimal`, which should be empty, NOT inherited from mainnet. - Keep `SECONDS_PER_SLOT` for now because the Kurtosis tests fail upon their complete removal. We will be able to completely remove `SECONDS_PER_SLOT` soon. Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/test_utils.rs | 2 +- beacon_node/beacon_chain/tests/store_tests.rs | 8 +- .../http_api/tests/interactive_tests.rs | 14 +- .../mainnet/config.yaml | 40 +-- consensus/types/configs/mainnet.yaml | 227 ++++++++++++++++ consensus/types/configs/minimal.yaml | 220 ++++++++++++++++ consensus/types/src/core/chain_spec.rs | 242 +++++++++++++++++- 7 files changed, 703 insertions(+), 50 deletions(-) create mode 100644 consensus/types/configs/mainnet.yaml create mode 100644 consensus/types/configs/minimal.yaml diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index c53c29438e..13dcf22108 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -223,7 +223,7 @@ pub fn test_da_checker( let slot_clock = TestingSlotClock::new( Slot::new(0), Duration::from_secs(0), - Duration::from_secs(spec.seconds_per_slot), + spec.get_slot_duration(), ); let kzg = get_kzg(&spec); let ordered_custody_column_indices = generate_data_column_indices_rand_order::(); diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 2b4152b550..fb5262b893 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -2910,7 +2910,7 @@ async fn reproduction_unaligned_checkpoint_sync_pruned_payload() { let slot_clock = TestingSlotClock::new( Slot::new(0), Duration::from_secs(harness.chain.genesis_time), - Duration::from_secs(spec.seconds_per_slot), + spec.get_slot_duration(), ); slot_clock.set_slot(harness.get_current_slot().as_u64()); @@ -5334,8 +5334,8 @@ async fn test_safely_backfill_data_column_custody_info() { .await; let epoch_before_increase = Epoch::new(start_epochs); - let effective_delay_slots = - CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS / harness.chain.spec.seconds_per_slot; + let effective_delay_slots = CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS + / harness.chain.spec.get_slot_duration().as_secs(); let cgc_change_slot = epoch_before_increase.end_slot(E::slots_per_epoch()); @@ -6131,7 +6131,7 @@ async fn bellatrix_produce_and_store_payloads() { .genesis_time() .safe_add( slot.as_u64() - .safe_mul(harness.spec.seconds_per_slot) + .safe_mul(harness.spec.get_slot_duration().as_secs()) .unwrap(), ) .unwrap(); diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index e0e4029875..15f61537a0 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -975,9 +975,10 @@ async fn proposer_duties_with_gossip_tolerance() { assert_eq!(harness.chain.slot().unwrap(), num_initial); // Set the clock to just before the next epoch. - harness.chain.slot_clock.advance_time( - Duration::from_secs(spec.seconds_per_slot) - spec.maximum_gossip_clock_disparity(), - ); + harness + .chain + .slot_clock + .advance_time(spec.get_slot_duration() - spec.maximum_gossip_clock_disparity()); assert_eq!( harness .chain @@ -1081,9 +1082,10 @@ async fn proposer_duties_v2_with_gossip_tolerance() { assert_eq!(harness.chain.slot().unwrap(), num_initial); // Set the clock to just before the next epoch. - harness.chain.slot_clock.advance_time( - Duration::from_secs(spec.seconds_per_slot) - spec.maximum_gossip_clock_disparity(), - ); + harness + .chain + .slot_clock + .advance_time(spec.get_slot_duration() - spec.maximum_gossip_clock_disparity()); assert_eq!( harness .chain diff --git a/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml b/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml index 5df6370abe..02bf37cb55 100644 --- a/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml @@ -56,21 +56,18 @@ ELECTRA_FORK_EPOCH: 364032 # May 7, 2025, 10:05:11am UTC FULU_FORK_VERSION: 0x06000000 FULU_FORK_EPOCH: 411392 # December 3, 2025, 09:49:11pm UTC # Gloas -GLOAS_FORK_VERSION: 0x07000000 # temporary stub +GLOAS_FORK_VERSION: 0x07000000 GLOAS_FORK_EPOCH: 18446744073709551615 -# EIP7441 -EIP7441_FORK_VERSION: 0x08000000 # temporary stub -EIP7441_FORK_EPOCH: 18446744073709551615 -# EIP7805 -EIP7805_FORK_VERSION: 0x0a000000 # temporary stub -EIP7805_FORK_EPOCH: 18446744073709551615 +# Heze +HEZE_FORK_VERSION: 0x08000000 +HEZE_FORK_EPOCH: 18446744073709551615 # EIP7928 -EIP7928_FORK_VERSION: 0x0b000000 # temporary stub +EIP7928_FORK_VERSION: 0xe7928000 # temporary stub EIP7928_FORK_EPOCH: 18446744073709551615 # Time parameters # --------------------------------------------------------------- -# 12 seconds (*deprecated*) +# 12 seconds SECONDS_PER_SLOT: 12 # 12000 milliseconds SLOT_DURATION_MS: 12000 @@ -96,8 +93,8 @@ SYNC_MESSAGE_DUE_BPS: 3333 CONTRIBUTION_DUE_BPS: 6667 # Gloas -# 2**12 (= 4,096) epochs -MIN_BUILDER_WITHDRAWABILITY_DELAY: 4096 +# 2**6 (= 64) epochs +MIN_BUILDER_WITHDRAWABILITY_DELAY: 64 # 2500 basis points, 25% of SLOT_DURATION_MS ATTESTATION_DUE_BPS_GLOAS: 2500 # 5000 basis points, 50% of SLOT_DURATION_MS @@ -109,7 +106,7 @@ CONTRIBUTION_DUE_BPS_GLOAS: 5000 # 7500 basis points, 75% of SLOT_DURATION_MS PAYLOAD_ATTESTATION_DUE_BPS: 7500 -# EIP7805 +# Heze # 7500 basis points, 75% of SLOT_DURATION_MS VIEW_FREEZE_CUTOFF_BPS: 7500 # 6667 basis points, ~67% of SLOT_DURATION_MS @@ -166,8 +163,6 @@ MAX_PAYLOAD_SIZE: 10485760 MAX_REQUEST_BLOCKS: 1024 # 2**8 (= 256) epochs EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 -# MIN_VALIDATOR_WITHDRAWABILITY_DELAY + CHURN_LIMIT_QUOTIENT // 2 (= 33,024) epochs -MIN_EPOCHS_FOR_BLOCK_REQUESTS: 33024 # 2**5 (= 32) slots ATTESTATION_PROPAGATION_SLOT_RANGE: 32 # 500ms @@ -180,8 +175,6 @@ SUBNETS_PER_NODE: 2 ATTESTATION_SUBNET_COUNT: 64 # 0 bits ATTESTATION_SUBNET_EXTRA_BITS: 0 -# ceillog2(ATTESTATION_SUBNET_COUNT) + ATTESTATION_SUBNET_EXTRA_BITS (= 6 + 0) bits -ATTESTATION_SUBNET_PREFIX_BITS: 6 # Deneb # 2**7 (= 128) blocks @@ -192,24 +185,18 @@ MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 BLOB_SIDECAR_SUBNET_COUNT: 6 # 6 blobs MAX_BLOBS_PER_BLOCK: 6 -# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK (= 128 * 6) sidecars -MAX_REQUEST_BLOB_SIDECARS: 768 # Electra # 9 subnets BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 # 9 blobs MAX_BLOBS_PER_BLOCK_ELECTRA: 9 -# MAX_REQUEST_BLOCKS_DENEB * MAX_BLOBS_PER_BLOCK_ELECTRA (= 128 * 9) sidecars -MAX_REQUEST_BLOB_SIDECARS_ELECTRA: 1152 # Fulu # 2**7 (= 128) groups NUMBER_OF_CUSTODY_GROUPS: 128 # 2**7 (= 128) subnets DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 -# MAX_REQUEST_BLOCKS_DENEB * NUMBER_OF_COLUMNS (= 128 * 128) sidecars -MAX_REQUEST_DATA_COLUMN_SIDECARS: 16384 # 2**3 (= 8) samples SAMPLES_PER_SLOT: 8 # 2**2 (= 4) sidecars @@ -225,18 +212,13 @@ MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 # 2**7 (= 128) payloads MAX_REQUEST_PAYLOADS: 128 -# EIP7441 -# 2**8 (= 256) epochs -EPOCHS_PER_SHUFFLING_PHASE: 256 -# 2**1 (= 2) epochs -PROPOSER_SELECTION_GAP: 2 - -# EIP7805 +# Heze # 2**4 (= 16) inclusion lists MAX_REQUEST_INCLUSION_LIST: 16 # 2**13 (= 8,192) bytes MAX_BYTES_PER_INCLUSION_LIST: 8192 + # Blob Scheduling # --------------------------------------------------------------- diff --git a/consensus/types/configs/mainnet.yaml b/consensus/types/configs/mainnet.yaml new file mode 100644 index 0000000000..ab85bd9e71 --- /dev/null +++ b/consensus/types/configs/mainnet.yaml @@ -0,0 +1,227 @@ +# Mainnet config + +# Extends the mainnet preset +PRESET_BASE: 'mainnet' + +# Free-form short name of the network that this configuration applies to - known +# canonical network names include: +# * 'mainnet' - there can be only one +# * 'sepolia' - testnet +# * 'holesky' - testnet +# * 'hoodi' - testnet +# Must match the regex: [a-z0-9\-] +CONFIG_NAME: 'mainnet' + +# Transition +# --------------------------------------------------------------- +# Estimated on Sept 15, 2022 +TERMINAL_TOTAL_DIFFICULTY: 58750000000000000000000 +# By default, don't use these params +TERMINAL_BLOCK_HASH: 0x0000000000000000000000000000000000000000000000000000000000000000 +TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 + +# Genesis +# --------------------------------------------------------------- +# 2**14 (= 16,384) validators +MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 16384 +# Dec 1, 2020, 12pm UTC +MIN_GENESIS_TIME: 1606824000 +# Initial fork version for mainnet +GENESIS_FORK_VERSION: 0x00000000 +# 7 * 24 * 3,600 (= 604,800) seconds, 7 days +GENESIS_DELAY: 604800 + +# Forking +# --------------------------------------------------------------- +# Some forks are disabled for now: +# - These may be re-assigned to another fork-version later +# - Temporarily set to max uint64 value: 2**64 - 1 + +# Altair +ALTAIR_FORK_VERSION: 0x01000000 +ALTAIR_FORK_EPOCH: 74240 # Oct 27, 2021, 10:56:23am UTC +# Bellatrix +BELLATRIX_FORK_VERSION: 0x02000000 +BELLATRIX_FORK_EPOCH: 144896 # Sept 6, 2022, 11:34:47am UTC +# Capella +CAPELLA_FORK_VERSION: 0x03000000 +CAPELLA_FORK_EPOCH: 194048 # April 12, 2023, 10:27:35pm UTC +# Deneb +DENEB_FORK_VERSION: 0x04000000 +DENEB_FORK_EPOCH: 269568 # March 13, 2024, 01:55:35pm UTC +# Electra +ELECTRA_FORK_VERSION: 0x05000000 +ELECTRA_FORK_EPOCH: 364032 # May 7, 2025, 10:05:11am UTC +# Fulu +FULU_FORK_VERSION: 0x06000000 +FULU_FORK_EPOCH: 411392 # December 3, 2025, 09:49:11pm UTC +# Gloas +GLOAS_FORK_VERSION: 0x07000000 +GLOAS_FORK_EPOCH: 18446744073709551615 +# Heze +HEZE_FORK_VERSION: 0x08000000 +HEZE_FORK_EPOCH: 18446744073709551615 +# EIP7928 +EIP7928_FORK_VERSION: 0xe7928000 # temporary stub +EIP7928_FORK_EPOCH: 18446744073709551615 + +# Time parameters +# --------------------------------------------------------------- +# 12000 milliseconds +SLOT_DURATION_MS: 12000 +# 14 (estimate from Eth1 mainnet) +SECONDS_PER_ETH1_BLOCK: 14 +# 2**8 (= 256) epochs +MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 +# 2**8 (= 256) epochs +SHARD_COMMITTEE_PERIOD: 256 +# 2**11 (= 2,048) Eth1 blocks +ETH1_FOLLOW_DISTANCE: 2048 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 + +# Gloas +# 2**6 (= 64) epochs +MIN_BUILDER_WITHDRAWABILITY_DELAY: 64 +# 2500 basis points, 25% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS_GLOAS: 5000 +# 2500 basis points, 25% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS_GLOAS: 5000 +# 7500 basis points, 75% of SLOT_DURATION_MS +PAYLOAD_ATTESTATION_DUE_BPS: 7500 + +# Heze +# 7500 basis points, 75% of SLOT_DURATION_MS +VIEW_FREEZE_CUTOFF_BPS: 7500 +# 6667 basis points, ~67% of SLOT_DURATION_MS +INCLUSION_LIST_SUBMISSION_DUE_BPS: 6667 +# 9167 basis points, ~92% of SLOT_DURATION_MS +PROPOSER_INCLUSION_LIST_CUTOFF_BPS: 9167 + +# Validator cycle +# --------------------------------------------------------------- +# 2**2 (= 4) +INACTIVITY_SCORE_BIAS: 4 +# 2**4 (= 16) +INACTIVITY_SCORE_RECOVERY_RATE: 16 +# 2**4 * 10**9 (= 16,000,000,000) Gwei +EJECTION_BALANCE: 16000000000 +# 2**2 (= 4) validators +MIN_PER_EPOCH_CHURN_LIMIT: 4 +# 2**16 (= 65,536) +CHURN_LIMIT_QUOTIENT: 65536 + +# Deneb +# 2**3 (= 8) (*deprecated*) +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 8 + +# Electra +# 2**7 * 10**9 (= 128,000,000,000) Gwei +MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 128000000000 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 256000000000 + +# Fork choice +# --------------------------------------------------------------- +# 40% +PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# 2 epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + +# Deposit contract +# --------------------------------------------------------------- +# Ethereum PoW Mainnet +DEPOSIT_CHAIN_ID: 1 +DEPOSIT_NETWORK_ID: 1 +DEPOSIT_CONTRACT_ADDRESS: 0x00000000219ab540356cBB839Cbe05303d7705Fa + +# Networking +# --------------------------------------------------------------- +# 10 * 2**20 (= 10,485,760) bytes, 10 MiB +MAX_PAYLOAD_SIZE: 10485760 +# 2**10 (= 1,024) blocks +MAX_REQUEST_BLOCKS: 1024 +# 2**8 (= 256) epochs +EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 +# 2**5 (= 32) slots +ATTESTATION_PROPAGATION_SLOT_RANGE: 32 +# 500ms +MAXIMUM_GOSSIP_CLOCK_DISPARITY: 500 +MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 +MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 +# 2 subnets per node +SUBNETS_PER_NODE: 2 +# 2**6 (= 64) subnets +ATTESTATION_SUBNET_COUNT: 64 +# 0 bits +ATTESTATION_SUBNET_EXTRA_BITS: 0 + +# Deneb +# 2**7 (= 128) blocks +MAX_REQUEST_BLOCKS_DENEB: 128 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 +# 6 subnets +BLOB_SIDECAR_SUBNET_COUNT: 6 +# 6 blobs +MAX_BLOBS_PER_BLOCK: 6 + +# Electra +# 9 subnets +BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 +# 9 blobs +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 + +# Fulu +# 2**7 (= 128) groups +NUMBER_OF_CUSTODY_GROUPS: 128 +# 2**7 (= 128) subnets +DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +# 2**3 (= 8) samples +SAMPLES_PER_SLOT: 8 +# 2**2 (= 4) sidecars +CUSTODY_REQUIREMENT: 4 +# 2**3 (= 8) sidecars +VALIDATOR_CUSTODY_REQUIREMENT: 8 +# 2**5 * 10**9 (= 32,000,000,000) Gwei +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 + +# Gloas +# 2**7 (= 128) payloads +MAX_REQUEST_PAYLOADS: 128 + +# Heze +# 2**4 (= 16) inclusion lists +MAX_REQUEST_INCLUSION_LIST: 16 +# 2**13 (= 8,192) bytes +MAX_BYTES_PER_INCLUSION_LIST: 8192 + + +# Blob Scheduling +# --------------------------------------------------------------- + +BLOB_SCHEDULE: + - EPOCH: 412672 # December 9, 2025, 02:21:11pm UTC + MAX_BLOBS_PER_BLOCK: 15 + - EPOCH: 419072 # January 7, 2026, 01:01:11am UTC + MAX_BLOBS_PER_BLOCK: 21 diff --git a/consensus/types/configs/minimal.yaml b/consensus/types/configs/minimal.yaml new file mode 100644 index 0000000000..8c0d7254fe --- /dev/null +++ b/consensus/types/configs/minimal.yaml @@ -0,0 +1,220 @@ +# Minimal config + +# Extends the minimal preset +PRESET_BASE: 'minimal' + +# Free-form short name of the network that this configuration applies to - known +# canonical network names include: +# * 'minimal' - spec-testing +# Must match the regex: [a-z0-9\-] +CONFIG_NAME: 'minimal' + +# Transition +# --------------------------------------------------------------- +# 2**256-2**10 for testing minimal network +TERMINAL_TOTAL_DIFFICULTY: 115792089237316195423570985008687907853269984665640564039457584007913129638912 +# By default, don't use these params +TERMINAL_BLOCK_HASH: 0x0000000000000000000000000000000000000000000000000000000000000000 +TERMINAL_BLOCK_HASH_ACTIVATION_EPOCH: 18446744073709551615 + +# Genesis +# --------------------------------------------------------------- +# [customized] 2**6 (= 64) validators +MIN_GENESIS_ACTIVE_VALIDATOR_COUNT: 64 +# [customized] Jan 3, 2020, 12am UTC +MIN_GENESIS_TIME: 1578009600 +# [customized] Initial fork version for minimal +GENESIS_FORK_VERSION: 0x00000001 +# [customized] 5 * 60 (= 300) seconds +GENESIS_DELAY: 300 + +# Forking +# --------------------------------------------------------------- +# Values provided for illustrative purposes. +# Individual tests/testnets may set different values. + +# [customized] Altair +ALTAIR_FORK_VERSION: 0x01000001 +ALTAIR_FORK_EPOCH: 18446744073709551615 +# [customized] Bellatrix +BELLATRIX_FORK_VERSION: 0x02000001 +BELLATRIX_FORK_EPOCH: 18446744073709551615 +# [customized] Capella +CAPELLA_FORK_VERSION: 0x03000001 +CAPELLA_FORK_EPOCH: 18446744073709551615 +# [customized] Deneb +DENEB_FORK_VERSION: 0x04000001 +DENEB_FORK_EPOCH: 18446744073709551615 +# [customized] Electra +ELECTRA_FORK_VERSION: 0x05000001 +ELECTRA_FORK_EPOCH: 18446744073709551615 +# [customized] Fulu +FULU_FORK_VERSION: 0x06000001 +FULU_FORK_EPOCH: 18446744073709551615 +# [customized] Gloas +GLOAS_FORK_VERSION: 0x07000001 +GLOAS_FORK_EPOCH: 18446744073709551615 +# [customized] Heze +HEZE_FORK_VERSION: 0x08000001 +HEZE_FORK_EPOCH: 18446744073709551615 +# [customized] EIP7928 +EIP7928_FORK_VERSION: 0xe7928001 +EIP7928_FORK_EPOCH: 18446744073709551615 + +# Time parameters +# --------------------------------------------------------------- +# [customized] 6000 milliseconds +SLOT_DURATION_MS: 6000 +# 14 (estimate from Eth1 mainnet) +SECONDS_PER_ETH1_BLOCK: 14 +# 2**8 (= 256) epochs +MIN_VALIDATOR_WITHDRAWABILITY_DELAY: 256 +# [customized] 2**6 (= 64) epochs +SHARD_COMMITTEE_PERIOD: 64 +# [customized] 2**4 (= 16) Eth1 blocks +ETH1_FOLLOW_DISTANCE: 16 +# 1667 basis points, ~17% of SLOT_DURATION_MS +PROPOSER_REORG_CUTOFF_BPS: 1667 +# 3333 basis points, ~33% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS: 6667 + +# Altair +# 3333 basis points, ~33% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS: 3333 +# 6667 basis points, ~67% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS: 6667 + +# Gloas +# [customized] 2**1 (= 2) epochs +MIN_BUILDER_WITHDRAWABILITY_DELAY: 2 +# 2500 basis points, 25% of SLOT_DURATION_MS +ATTESTATION_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +AGGREGATE_DUE_BPS_GLOAS: 5000 +# 2500 basis points, 25% of SLOT_DURATION_MS +SYNC_MESSAGE_DUE_BPS_GLOAS: 2500 +# 5000 basis points, 50% of SLOT_DURATION_MS +CONTRIBUTION_DUE_BPS_GLOAS: 5000 +# 7500 basis points, 75% of SLOT_DURATION_MS +PAYLOAD_ATTESTATION_DUE_BPS: 7500 + +# Heze +# 7500 basis points, 75% of SLOT_DURATION_MS +VIEW_FREEZE_CUTOFF_BPS: 7500 +# 6667 basis points, ~67% of SLOT_DURATION_MS +INCLUSION_LIST_SUBMISSION_DUE_BPS: 6667 +# 9167 basis points, ~92% of SLOT_DURATION_MS +PROPOSER_INCLUSION_LIST_CUTOFF_BPS: 9167 + +# Validator cycle +# --------------------------------------------------------------- +# 2**2 (= 4) +INACTIVITY_SCORE_BIAS: 4 +# 2**4 (= 16) +INACTIVITY_SCORE_RECOVERY_RATE: 16 +# 2**4 * 10**9 (= 16,000,000,000) Gwei +EJECTION_BALANCE: 16000000000 +# [customized] 2**1 (= 2) validators +MIN_PER_EPOCH_CHURN_LIMIT: 2 +# [customized] 2**5 (= 32) +CHURN_LIMIT_QUOTIENT: 32 + +# Deneb +# [customized] 2**2 (= 4) (*deprecated*) +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT: 4 + +# Electra +# [customized] 2**6 * 10**9 (= 64,000,000,000) Gwei +MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 64000000000 +# [customized] 2**7 * 10**9 (= 128,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 128000000000 + +# Fork choice +# --------------------------------------------------------------- +# 40% +PROPOSER_SCORE_BOOST: 40 +# 20% +REORG_HEAD_WEIGHT_THRESHOLD: 20 +# 160% +REORG_PARENT_WEIGHT_THRESHOLD: 160 +# 2 epochs +REORG_MAX_EPOCHS_SINCE_FINALIZATION: 2 + +# Deposit contract +# --------------------------------------------------------------- +# Ethereum Goerli testnet +DEPOSIT_CHAIN_ID: 5 +DEPOSIT_NETWORK_ID: 5 +# Configured on a per testnet basis +DEPOSIT_CONTRACT_ADDRESS: 0x1234567890123456789012345678901234567890 + +# Networking +# --------------------------------------------------------------- +# 10 * 2**20 (= 10,485,760) bytes, 10 MiB +MAX_PAYLOAD_SIZE: 10485760 +# 2**10 (= 1,024) blocks +MAX_REQUEST_BLOCKS: 1024 +# 2**8 (= 256) epochs +EPOCHS_PER_SUBNET_SUBSCRIPTION: 256 +# 2**5 (= 32) slots +ATTESTATION_PROPAGATION_SLOT_RANGE: 32 +# 500ms +MAXIMUM_GOSSIP_CLOCK_DISPARITY: 500 +MESSAGE_DOMAIN_INVALID_SNAPPY: 0x00000000 +MESSAGE_DOMAIN_VALID_SNAPPY: 0x01000000 +# 2 subnets per node +SUBNETS_PER_NODE: 2 +# 2**6 (= 64) subnets +ATTESTATION_SUBNET_COUNT: 64 +# 0 bits +ATTESTATION_SUBNET_EXTRA_BITS: 0 + +# Deneb +# 2**7 (= 128) blocks +MAX_REQUEST_BLOCKS_DENEB: 128 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS: 4096 +# 6 subnets +BLOB_SIDECAR_SUBNET_COUNT: 6 +# 6 blobs +MAX_BLOBS_PER_BLOCK: 6 + +# Electra +# 9 subnets +BLOB_SIDECAR_SUBNET_COUNT_ELECTRA: 9 +# 9 blobs +MAX_BLOBS_PER_BLOCK_ELECTRA: 9 + +# Fulu +# 2**7 (= 128) groups +NUMBER_OF_CUSTODY_GROUPS: 128 +# 2**7 (= 128) subnets +DATA_COLUMN_SIDECAR_SUBNET_COUNT: 128 +# 2**3 (= 8) samples +SAMPLES_PER_SLOT: 8 +# 2**2 (= 4) sidecars +CUSTODY_REQUIREMENT: 4 +# 2**3 (= 8) sidecars +VALIDATOR_CUSTODY_REQUIREMENT: 8 +# 2**5 * 10**9 (= 32,000,000,000) Gwei +BALANCE_PER_ADDITIONAL_CUSTODY_GROUP: 32000000000 +# 2**12 (= 4,096) epochs +MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS: 4096 + +# Gloas +# 2**7 (= 128) payloads +MAX_REQUEST_PAYLOADS: 128 + +# Heze +# 2**4 (= 16) inclusion lists +MAX_REQUEST_INCLUSION_LIST: 16 +# 2**13 (= 8,192) bytes +MAX_BYTES_PER_INCLUSION_LIST: 8192 + + +# Blob Scheduling +# --------------------------------------------------------------- + +BLOB_SCHEDULE: [] diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index 8a2b3a23e8..01c4c7bbfd 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -96,8 +96,7 @@ pub struct ChainSpec { * Time parameters */ pub genesis_delay: u64, - // TODO deprecate seconds_per_slot - pub seconds_per_slot: u64, + seconds_per_slot: u64, // Private so that this value can't get changed except via the `set_slot_duration_ms` function. slot_duration_ms: u64, pub min_attestation_inclusion_delay: u64, @@ -914,6 +913,7 @@ impl ChainSpec { /// Set the duration of a slot (in ms). pub fn set_slot_duration_ms(mut self, slot_duration_ms: u64) -> Self { self.slot_duration_ms = slot_duration_ms; + self.seconds_per_slot = slot_duration_ms.saturating_div(1000); self.compute_derived_values::() } @@ -1235,7 +1235,7 @@ impl ChainSpec { gloas_fork_epoch: None, builder_payment_threshold_numerator: 6, builder_payment_threshold_denominator: 10, - min_builder_withdrawability_delay: Epoch::new(4096), + min_builder_withdrawability_delay: Epoch::new(64), max_request_payloads: 128, /* @@ -1381,6 +1381,7 @@ impl ChainSpec { // Gloas gloas_fork_version: [0x07, 0x00, 0x00, 0x01], gloas_fork_epoch: None, + min_builder_withdrawability_delay: Epoch::new(2), /* * Derived time values (set by `compute_derived_values()`) @@ -1391,6 +1392,9 @@ impl ChainSpec { sync_message_due: Duration::from_millis(1999), contribution_and_proof_due: Duration::from_millis(4000), + // Networking Fulu + blob_schedule: BlobSchedule::default(), + // Other network_id: 2, // lighthouse testnet network id deposit_chain_id: 5, @@ -1631,7 +1635,7 @@ impl ChainSpec { gloas_fork_epoch: None, builder_payment_threshold_numerator: 6, builder_payment_threshold_denominator: 10, - min_builder_withdrawability_delay: Epoch::new(4096), + min_builder_withdrawability_delay: Epoch::new(64), max_request_payloads: 128, /* @@ -1908,8 +1912,9 @@ pub struct Config { #[serde(deserialize_with = "deserialize_fork_epoch")] pub gloas_fork_epoch: Option>, - #[serde(with = "serde_utils::quoted_u64")] - seconds_per_slot: u64, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + seconds_per_slot: Option>, #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] slot_duration_ms: Option>, @@ -2064,6 +2069,10 @@ pub struct Config { #[serde(default = "default_contribution_due_bps")] #[serde(with = "serde_utils::quoted_u64")] contribution_due_bps: u64, + + #[serde(default = "default_min_builder_withdrawability_delay")] + #[serde(with = "serde_utils::quoted_u64")] + min_builder_withdrawability_delay: u64, } fn default_bellatrix_fork_version() -> [u8; 4] { @@ -2289,6 +2298,10 @@ const fn default_contribution_due_bps() -> u64 { 6667 } +const fn default_min_builder_withdrawability_delay() -> u64 { + 64 +} + fn max_blocks_by_root_request_common(max_request_blocks: u64) -> usize { let max_request_blocks = max_request_blocks as usize; RuntimeVariableList::::new( @@ -2459,7 +2472,9 @@ impl Config { .gloas_fork_epoch .map(|epoch| MaybeQuoted { value: epoch }), - seconds_per_slot: spec.seconds_per_slot, + seconds_per_slot: Some(MaybeQuoted { + value: spec.seconds_per_slot, + }), slot_duration_ms: Some(MaybeQuoted { value: spec.slot_duration_ms, }), @@ -2525,6 +2540,8 @@ impl Config { aggregate_due_bps: spec.aggregate_due_bps, sync_message_due_bps: spec.sync_message_due_bps, contribution_due_bps: spec.contribution_due_bps, + + min_builder_withdrawability_delay: spec.min_builder_withdrawability_delay.as_u64(), } } @@ -2616,12 +2633,21 @@ impl Config { aggregate_due_bps, sync_message_due_bps, contribution_due_bps, + min_builder_withdrawability_delay, } = self; if preset_base != E::spec_name().to_string().as_str() { return None; } + // Fail if seconds_per_slot and slot_duration_ms are both set but are inconsistent. + if let (Some(seconds_per_slot), Some(slot_duration_ms)) = + (seconds_per_slot, slot_duration_ms) + && seconds_per_slot.value.saturating_mul(1000) != slot_duration_ms.value + { + return None; + } + let spec = ChainSpec { config_name: config_name.clone(), min_genesis_active_validator_count, @@ -2642,10 +2668,12 @@ impl Config { fulu_fork_version, gloas_fork_version, gloas_fork_epoch: gloas_fork_epoch.map(|q| q.value), - seconds_per_slot, + seconds_per_slot: seconds_per_slot + .map(|q| q.value) + .or_else(|| slot_duration_ms.and_then(|q| q.value.checked_div(1000)))?, slot_duration_ms: slot_duration_ms .map(|q| q.value) - .unwrap_or_else(|| seconds_per_slot.saturating_mul(1000)), + .or_else(|| seconds_per_slot.map(|q| q.value.saturating_mul(1000)))?, seconds_per_eth1_block, min_validator_withdrawability_delay, shard_committee_period, @@ -2705,6 +2733,8 @@ impl Config { sync_message_due_bps, contribution_due_bps, + min_builder_withdrawability_delay: Epoch::new(min_builder_withdrawability_delay), + ..chain_spec.clone() }; Some(spec.compute_derived_values::()) @@ -2853,6 +2883,9 @@ mod yaml_tests { use super::*; use crate::core::MinimalEthSpec; use paste::paste; + use std::collections::BTreeSet; + use std::env; + use std::path::PathBuf; use std::sync::Arc; use tempfile::NamedTempFile; @@ -2902,6 +2935,67 @@ mod yaml_tests { assert_eq!(from, yamlconfig); } + #[test] + fn slot_duration_fallback_both_fields() { + let mainnet = ChainSpec::mainnet(); + let mut config = Config::from_chain_spec::(&mainnet); + config.seconds_per_slot = Some(MaybeQuoted { value: 12 }); + config.slot_duration_ms = Some(MaybeQuoted { value: 12000 }); + let spec = config + .apply_to_chain_spec::(&mainnet) + .unwrap(); + assert_eq!(spec.seconds_per_slot, 12); + assert_eq!(spec.slot_duration_ms, 12000); + } + + #[test] + fn slot_duration_fallback_both_fields_inconsistent() { + let mainnet = ChainSpec::mainnet(); + let mut config = Config::from_chain_spec::(&mainnet); + config.seconds_per_slot = Some(MaybeQuoted { value: 10 }); + config.slot_duration_ms = Some(MaybeQuoted { value: 12000 }); + assert_eq!(config.apply_to_chain_spec::(&mainnet), None); + } + + #[test] + fn slot_duration_fallback_seconds_only() { + let mainnet = ChainSpec::mainnet(); + let mut config = Config::from_chain_spec::(&mainnet); + config.seconds_per_slot = Some(MaybeQuoted { value: 12 }); + config.slot_duration_ms = None; + let spec = config + .apply_to_chain_spec::(&mainnet) + .unwrap(); + assert_eq!(spec.seconds_per_slot, 12); + assert_eq!(spec.slot_duration_ms, 12000); + } + + #[test] + fn slot_duration_fallback_ms_only() { + let mainnet = ChainSpec::mainnet(); + let mut config = Config::from_chain_spec::(&mainnet); + config.seconds_per_slot = None; + config.slot_duration_ms = Some(MaybeQuoted { value: 12000 }); + let spec = config + .apply_to_chain_spec::(&mainnet) + .unwrap(); + assert_eq!(spec.seconds_per_slot, 12); + assert_eq!(spec.slot_duration_ms, 12000); + } + + #[test] + fn slot_duration_fallback_neither() { + let mainnet = ChainSpec::mainnet(); + let mut config = Config::from_chain_spec::(&mainnet); + config.seconds_per_slot = None; + config.slot_duration_ms = None; + assert!( + config + .apply_to_chain_spec::(&mainnet) + .is_none() + ); + } + #[test] fn blob_schedule_max_blobs_per_block() { let spec_contents = r#" @@ -3375,7 +3469,6 @@ mod yaml_tests { // Test slot duration let slot_duration = spec.get_slot_duration(); assert_eq!(slot_duration, Duration::from_millis(12000)); - assert_eq!(slot_duration, Duration::from_secs(spec.seconds_per_slot)); // Test edge cases with custom spec let mut custom_spec = spec.clone(); @@ -3485,4 +3578,133 @@ mod yaml_tests { spec.attestation_due_bps = 15000; spec.compute_derived_values::(); } + + fn configs_base_path() -> PathBuf { + env::var("CARGO_MANIFEST_DIR") + .expect("should know manifest dir") + .parse::() + .expect("should parse manifest dir as path") + .join("configs") + } + + /// Upstream config keys that Lighthouse intentionally does not include in its + /// `Config` struct. These are forks/features not yet implemented. Update this + /// list as new forks are added. + const UPSTREAM_KEYS_NOT_IN_LIGHTHOUSE: &[&str] = &[ + // Forks not yet implemented + "HEZE_FORK_VERSION", + "HEZE_FORK_EPOCH", + "EIP7928_FORK_VERSION", + "EIP7928_FORK_EPOCH", + // Gloas params not yet in Config + "ATTESTATION_DUE_BPS_GLOAS", + "AGGREGATE_DUE_BPS_GLOAS", + "SYNC_MESSAGE_DUE_BPS_GLOAS", + "CONTRIBUTION_DUE_BPS_GLOAS", + "PAYLOAD_ATTESTATION_DUE_BPS", + "MAX_REQUEST_PAYLOADS", + // Gloas fork choice params not yet in Config + "REORG_HEAD_WEIGHT_THRESHOLD", + "REORG_PARENT_WEIGHT_THRESHOLD", + "REORG_MAX_EPOCHS_SINCE_FINALIZATION", + // Heze networking + "VIEW_FREEZE_CUTOFF_BPS", + "INCLUSION_LIST_SUBMISSION_DUE_BPS", + "PROPOSER_INCLUSION_LIST_CUTOFF_BPS", + "MAX_REQUEST_INCLUSION_LIST", + "MAX_BYTES_PER_INCLUSION_LIST", + ]; + + /// Compare a `ChainSpec` against an upstream consensus-specs config YAML file. + /// + /// 1. Extracts keys from the raw YAML text (to avoid yaml_serde's inability + /// to parse integers > u64 into `Value`/`Mapping` types) and checks that + /// every key is either known to `Config` or explicitly listed in + /// `UPSTREAM_KEYS_NOT_IN_LIGHTHOUSE`. + /// 2. Deserializes the upstream YAML as `Config` (which has custom + /// deserializers for large values like `TERMINAL_TOTAL_DIFFICULTY`) and + /// compares against `Config::from_chain_spec`. + fn config_test(spec: &ChainSpec, config_name: &str) { + let file_path = configs_base_path().join(format!("{config_name}.yaml")); + let upstream_yaml = std::fs::read_to_string(&file_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", file_path.display())); + + // Extract top-level keys from the raw YAML text. We can't parse as + // yaml_serde::Mapping because yaml_serde cannot represent integers + // exceeding u64 (e.g. TERMINAL_TOTAL_DIFFICULTY). Config YAML uses a + // simple `KEY: value` format with no indentation for top-level keys. + let upstream_keys: BTreeSet = upstream_yaml + .lines() + .filter_map(|line| { + // Skip comments, blank lines, and indented lines (nested YAML). + if line.is_empty() + || line.starts_with('#') + || line.starts_with(' ') + || line.starts_with('\t') + { + return None; + } + line.split(':').next().map(|k| k.to_string()) + }) + .collect(); + + // Get the set of keys that Config knows about by serializing and collecting + // keys. Also include keys for optional fields that may be skipped during + // serialization (e.g. CONFIG_NAME). + let our_config = Config::from_chain_spec::(spec); + let our_yaml = yaml_serde::to_string(&our_config).expect("failed to serialize Config"); + let our_mapping: yaml_serde::Mapping = + yaml_serde::from_str(&our_yaml).expect("failed to re-parse our Config"); + let mut known_keys: BTreeSet = our_mapping + .keys() + .filter_map(|k| k.as_str().map(String::from)) + .collect(); + // Fields that Config knows but may skip during serialization. + known_keys.insert("CONFIG_NAME".to_string()); + + // Check for upstream keys that our Config doesn't know about. + let mut missing_keys: Vec<&String> = upstream_keys + .iter() + .filter(|k| { + !known_keys.contains(k.as_str()) + && !UPSTREAM_KEYS_NOT_IN_LIGHTHOUSE.contains(&k.as_str()) + }) + .collect(); + missing_keys.sort(); + + assert!( + missing_keys.is_empty(), + "Upstream {config_name} config has keys not present in Lighthouse Config \ + (add to Config or to UPSTREAM_KEYS_NOT_IN_LIGHTHOUSE): {missing_keys:?}" + ); + + // Compare values for all fields Config knows about. + let mut upstream_config: Config = yaml_serde::from_str(&upstream_yaml) + .unwrap_or_else(|e| panic!("failed to parse {config_name} as Config: {e}")); + + // CONFIG_NAME is network metadata (not a spec parameter), so align it + // before comparing. + upstream_config.config_name = our_config.config_name.clone(); + // SECONDS_PER_SLOT is deprecated upstream but we still emit it, so + // fill it in if the upstream YAML omitted it. + if upstream_config.seconds_per_slot.is_none() { + upstream_config.seconds_per_slot = our_config.seconds_per_slot; + } + assert_eq!( + upstream_config, our_config, + "Config mismatch for {config_name}" + ); + } + + #[test] + fn mainnet_config_consistent() { + let spec = ChainSpec::mainnet(); + config_test::(&spec, "mainnet"); + } + + #[test] + fn minimal_config_consistent() { + let spec = ChainSpec::minimal(); + config_test::(&spec, "minimal"); + } } From 2fb69f84c0a32e3af707cff44d7b228ed5a7dcf7 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Tue, 31 Mar 2026 11:19:18 +1100 Subject: [PATCH 093/189] Fix local testnet Tempo and Prometheus/Grafana config (#9054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin Tempo image to `grafana/tempo:2.10.3` — `grafana/tempo:latest` now resolves to an unreleased 3.0 build that removed the `compactor` config field, causing startup failure - Replace deprecated `prometheus_grafana` additional service with separate `prometheus` + `grafana` services Co-Authored-By: Jimmy Chen --- scripts/local_testnet/network_params.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/local_testnet/network_params.yaml b/scripts/local_testnet/network_params.yaml index 0c36e5c49c..083f719c60 100644 --- a/scripts/local_testnet/network_params.yaml +++ b/scripts/local_testnet/network_params.yaml @@ -21,10 +21,13 @@ network_params: slot_duration_ms: 3000 snooper_enabled: false global_log_level: debug +tempo_params: + image: grafana/tempo:2.10.3 additional_services: - dora - spamoor - - prometheus_grafana + - prometheus + - grafana - tempo spamoor_params: image: ethpandaops/spamoor:master From bc5d8c9f90916600ed48bdf3d0463a610dcd7053 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:07:22 -0500 Subject: [PATCH 094/189] Add range sync tests (#8989) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/beacon_processor/src/lib.rs | 8 +- .../src/network_beacon_processor/mod.rs | 7 +- beacon_node/network/src/sync/tests/lookups.rs | 305 +++++- beacon_node/network/src/sync/tests/range.rs | 877 +++++++----------- scripts/range-sync-coverage.sh | 136 +++ 5 files changed, 781 insertions(+), 552 deletions(-) create mode 100755 scripts/range-sync-coverage.sh diff --git a/beacon_node/beacon_processor/src/lib.rs b/beacon_node/beacon_processor/src/lib.rs index 724c41cfc9..a6c76beb31 100644 --- a/beacon_node/beacon_processor/src/lib.rs +++ b/beacon_node/beacon_processor/src/lib.rs @@ -421,7 +421,11 @@ pub enum Work { IgnoredRpcBlock { process_fn: BlockingFn, }, - ChainSegment(AsyncFn), + ChainSegment { + process_fn: AsyncFn, + /// (chain_id, batch_epoch) for test observability + process_id: (u32, u64), + }, ChainSegmentBackfill(BlockingFn), Status(BlockingFn), BlocksByRangeRequest(AsyncFn), @@ -1473,7 +1477,7 @@ impl BeaconProcessor { } => task_spawner.spawn_blocking(move || { process_batch(aggregates); }), - Work::ChainSegment(process_fn) => task_spawner.spawn_async(async move { + Work::ChainSegment { process_fn, .. } => task_spawner.spawn_async(async move { process_fn.await; }), Work::UnknownBlockAttestation { process_fn } diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index f74e7dacfb..b3d6874b8a 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -620,11 +620,14 @@ impl NetworkBeaconProcessor { // Back-sync batches are dispatched with a different `Work` variant so // they can be rate-limited. let work = match process_id { - ChainSegmentProcessId::RangeBatchId(_, _) => { + ChainSegmentProcessId::RangeBatchId(chain_id, epoch) => { let process_fn = async move { processor.process_chain_segment(process_id, blocks).await; }; - Work::ChainSegment(Box::pin(process_fn)) + Work::ChainSegment { + process_fn: Box::pin(process_fn), + process_id: (chain_id, epoch.as_u64()), + } } ChainSegmentProcessId::BackSyncBatchId(_) => { let process_fn = diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index cd872df887..a26996ec5e 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -1,16 +1,18 @@ use super::*; use crate::NetworkMessage; -use crate::network_beacon_processor::{InvalidBlockStorage, NetworkBeaconProcessor}; +use crate::network_beacon_processor::{ + ChainSegmentProcessId, InvalidBlockStorage, NetworkBeaconProcessor, +}; use crate::sync::block_lookups::{BlockLookupSummary, PARENT_DEPTH_TOLERANCE}; use crate::sync::{ SyncMessage, - manager::{BlockProcessType, BlockProcessingResult, SyncManager}, + manager::{BatchProcessResult, BlockProcessType, BlockProcessingResult, SyncManager}, }; use beacon_chain::blob_verification::KzgVerifiedBlob; use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::{ - AvailabilityProcessingStatus, BlockError, NotifyExecutionLayer, + AvailabilityProcessingStatus, BlockError, EngineState, NotifyExecutionLayer, block_verification_types::{AsBlock, AvailableBlockData}, data_availability_checker::Availability, test_utils::{ @@ -23,7 +25,7 @@ use educe::Educe; use itertools::Itertools; use lighthouse_network::discovery::CombinedKey; use lighthouse_network::{ - NetworkConfig, NetworkGlobals, PeerId, + NetworkConfig, NetworkGlobals, PeerAction, PeerId, rpc::{RPCError, RequestType}, service::api_types::{AppRequestId, SyncRequestId}, types::SyncState, @@ -64,14 +66,33 @@ pub struct SimulateConfig { Option Option + Send + Sync>>, // Import a block directly before processing it (for simulating race conditions) import_block_before_process: HashSet, + /// Number of range batch processing attempts that return FaultyFailure + range_faulty_failures: usize, + /// Number of range batch processing attempts that return NonFaultyFailure + range_non_faulty_failures: usize, + /// Number of BlocksByRange requests that return empty (no blocks) + return_no_range_blocks_n_times: usize, + /// Number of DataColumnsByRange requests that return empty (no columns) + return_no_range_columns_n_times: usize, + /// Number of DataColumnsByRange requests that return columns with unrequested indices + return_wrong_range_column_indices_n_times: usize, + /// Number of DataColumnsByRange requests that return columns with unrequested slots + return_wrong_range_column_slots_n_times: usize, + /// Number of DataColumnsByRange requests that return fewer columns than requested + /// (drops half the columns). Triggers CouplingError::DataColumnPeerFailure → retry_partial_batch + return_partial_range_columns_n_times: usize, + /// Set EE offline at start, bring back online after this many BlocksByRange responses + ee_offline_for_n_range_responses: Option, + /// Disconnect all peers after this many successful BlocksByRange responses. + successful_range_responses_before_disconnect: Option, } impl SimulateConfig { - fn new() -> Self { + pub(super) fn new() -> Self { Self::default() } - fn happy_path() -> Self { + pub(super) fn happy_path() -> Self { Self::default() } @@ -111,7 +132,7 @@ impl SimulateConfig { self } - fn return_rpc_error(mut self, error: RPCError) -> Self { + pub(super) fn return_rpc_error(mut self, error: RPCError) -> Self { self.return_rpc_error = Some(error); self } @@ -133,6 +154,51 @@ impl SimulateConfig { self.import_block_before_process.insert(block_root); self } + + pub(super) fn with_range_faulty_failures(mut self, n: usize) -> Self { + self.range_faulty_failures = n; + self + } + + pub(super) fn with_range_non_faulty_failures(mut self, n: usize) -> Self { + self.range_non_faulty_failures = n; + self + } + + pub(super) fn with_no_range_blocks_n_times(mut self, n: usize) -> Self { + self.return_no_range_blocks_n_times = n; + self + } + + pub(super) fn with_no_range_columns_n_times(mut self, n: usize) -> Self { + self.return_no_range_columns_n_times = n; + self + } + + pub(super) fn with_wrong_range_column_indices_n_times(mut self, n: usize) -> Self { + self.return_wrong_range_column_indices_n_times = n; + self + } + + pub(super) fn with_wrong_range_column_slots_n_times(mut self, n: usize) -> Self { + self.return_wrong_range_column_slots_n_times = n; + self + } + + pub(super) fn with_partial_range_columns_n_times(mut self, n: usize) -> Self { + self.return_partial_range_columns_n_times = n; + self + } + + pub(super) fn with_ee_offline_for_n_range_responses(mut self, n: usize) -> Self { + self.ee_offline_for_n_range_responses = Some(n); + self + } + + pub(super) fn with_disconnect_after_range_requests(mut self, n: usize) -> Self { + self.successful_range_responses_before_disconnect = Some(n); + self + } } fn genesis_fork() -> ForkName { @@ -256,6 +322,7 @@ impl TestRig { }) } + #[allow(dead_code)] pub fn with_custody_type(node_custody_type: NodeCustodyType) -> Self { Self::new(TestRigConfig { fulu_test_type: FuluTestType::WeFullnodeThemSupernode, @@ -267,13 +334,23 @@ impl TestRig { /// /// Processes events from sync_rx (sink), beacon processor, and network queues in fixed /// priority order each tick. Handles completed work before pulling new requests. - async fn simulate(&mut self, complete_strategy: SimulateConfig) { + pub(super) async fn simulate(&mut self, complete_strategy: SimulateConfig) { self.complete_strategy = complete_strategy; self.log(&format!( "Running simulate with config {:?}", self.complete_strategy )); + // Set EE offline at the start if configured + if self + .complete_strategy + .ee_offline_for_n_range_responses + .is_some() + { + self.sync_manager + .update_execution_engine_state(EngineState::Offline); + } + let mut i = 0; loop { @@ -352,9 +429,34 @@ impl TestRig { process_fn.await } } - Work::RpcBlobs { process_fn } - | Work::RpcCustodyColumn(process_fn) - | Work::ChainSegment(process_fn) => process_fn.await, + Work::RpcBlobs { process_fn } | Work::RpcCustodyColumn(process_fn) => { + process_fn.await + } + Work::ChainSegment { + process_fn, + process_id: (chain_id, batch_epoch), + } => { + let sync_type = + ChainSegmentProcessId::RangeBatchId(chain_id, batch_epoch.into()); + if self.complete_strategy.range_faulty_failures > 0 { + self.complete_strategy.range_faulty_failures -= 1; + self.push_sync_message(SyncMessage::BatchProcessed { + sync_type, + result: BatchProcessResult::FaultyFailure { + imported_blocks: 0, + penalty: PeerAction::LowToleranceError, + }, + }); + } else if self.complete_strategy.range_non_faulty_failures > 0 { + self.complete_strategy.range_non_faulty_failures -= 1; + self.push_sync_message(SyncMessage::BatchProcessed { + sync_type, + result: BatchProcessResult::NonFaultyFailure, + }); + } else { + process_fn.await; + } + } Work::Reprocess(_) => {} // ignore other => panic!("Unsupported Work event {}", other.str_id()), } @@ -573,15 +675,50 @@ impl TestRig { if self.complete_strategy.skip_by_range_routes { return; } - let blocks = (*req.start_slot()..req.start_slot() + req.count()) - .filter_map(|slot| { - self.network_blocks_by_slot - .get(&Slot::new(slot)) - .map(|block| block.block_cloned()) - }) - .collect::>(); - self.send_rpc_blocks_response(req_id, peer_id, &blocks); + // Check if we should disconnect all peers instead of continuing + if let Some(ref mut remaining) = self + .complete_strategy + .successful_range_responses_before_disconnect + { + if *remaining == 0 { + // Disconnect all peers — remaining responses become "late" + for peer in self.get_connected_peers() { + self.peer_disconnected(peer); + } + return; + } else { + *remaining -= 1; + } + } + + // Return empty response N times to simulate peer returning no blocks + if self.complete_strategy.return_no_range_blocks_n_times > 0 { + self.complete_strategy.return_no_range_blocks_n_times -= 1; + self.send_rpc_blocks_response(req_id, peer_id, &[]); + } else { + let blocks = (*req.start_slot()..req.start_slot() + req.count()) + .filter_map(|slot| { + self.network_blocks_by_slot + .get(&Slot::new(slot)) + .map(|block| block.block_cloned()) + }) + .collect::>(); + self.send_rpc_blocks_response(req_id, peer_id, &blocks); + } + + // Bring EE back online after N range responses + if let Some(ref mut remaining) = + self.complete_strategy.ee_offline_for_n_range_responses + { + if *remaining == 0 { + self.sync_manager + .update_execution_engine_state(EngineState::Online); + self.complete_strategy.ee_offline_for_n_range_responses = None; + } else { + *remaining -= 1; + } + } } (RequestType::BlobsByRange(req), AppRequestId::Sync(req_id)) => { @@ -605,10 +742,80 @@ impl TestRig { if self.complete_strategy.skip_by_range_routes { return; } - // Note: This function is permissive, blocks may have zero columns and it won't - // error. Some caveats: - // - The genesis block never has columns - // - Some blocks may not have columns as the blob count is random + + // Return empty columns N times + if self.complete_strategy.return_no_range_columns_n_times > 0 { + self.complete_strategy.return_no_range_columns_n_times -= 1; + self.send_rpc_columns_response(req_id, peer_id, &[]); + return; + } + + // Return columns with unrequested indices N times. + // Note: for supernodes this returns no columns since they custody all indices. + if self + .complete_strategy + .return_wrong_range_column_indices_n_times + > 0 + { + self.complete_strategy + .return_wrong_range_column_indices_n_times -= 1; + let wrong_columns = (req.start_slot..req.start_slot + req.count) + .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) + .filter_map(|block| block.block_data().data_columns()) + .flat_map(|columns| { + columns + .into_iter() + .filter(|c| !req.columns.contains(c.index())) + }) + .collect::>(); + self.send_rpc_columns_response(req_id, peer_id, &wrong_columns); + return; + } + + // Return columns from an out-of-range slot N times + if self + .complete_strategy + .return_wrong_range_column_slots_n_times + > 0 + { + self.complete_strategy + .return_wrong_range_column_slots_n_times -= 1; + // Get a column from a slot AFTER the requested range + let wrong_slot = req.start_slot + req.count; + let wrong_columns = self + .network_blocks_by_slot + .get(&Slot::new(wrong_slot)) + .and_then(|block| block.block_data().data_columns()) + .into_iter() + .flat_map(|columns| { + columns + .into_iter() + .filter(|c| req.columns.contains(c.index())) + }) + .collect::>(); + self.send_rpc_columns_response(req_id, peer_id, &wrong_columns); + return; + } + + // Return only half the requested columns N times — triggers CouplingError + if self.complete_strategy.return_partial_range_columns_n_times > 0 { + self.complete_strategy.return_partial_range_columns_n_times -= 1; + let columns = (req.start_slot..req.start_slot + req.count) + .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) + .filter_map(|block| block.block_data().data_columns()) + .flat_map(|columns| { + columns + .into_iter() + .filter(|c| req.columns.contains(c.index())) + }) + .enumerate() + .filter(|(i, _)| i % 2 == 0) // keep every other column + .map(|(_, c)| c) + .collect::>(); + self.send_rpc_columns_response(req_id, peer_id, &columns); + return; + } + let columns = (req.start_slot..req.start_slot + req.count) .filter_map(|slot| self.network_blocks_by_slot.get(&Slot::new(slot))) .filter_map(|block| block.block_data().data_columns()) @@ -726,7 +933,7 @@ impl TestRig { // Preparation steps /// Returns the block root of the tip of the built chain - async fn build_chain(&mut self, block_count: usize) -> Hash256 { + pub(super) async fn build_chain(&mut self, block_count: usize) -> Hash256 { let mut blocks = vec![]; // Initialise a new beacon chain @@ -947,6 +1154,30 @@ impl TestRig { self.trigger_with_last_block(); } + /// Import blocks for slots 1..=up_to_slot into the local chain (advance local head) + pub(super) async fn import_blocks_up_to_slot(&mut self, up_to_slot: u64) { + for slot in 1..=up_to_slot { + let rpc_block = self + .network_blocks_by_slot + .get(&Slot::new(slot)) + .unwrap_or_else(|| panic!("No block at slot {slot}")) + .clone(); + let block_root = rpc_block.canonical_root(); + self.harness + .chain + .process_block( + block_root, + rpc_block, + NotifyExecutionLayer::Yes, + BlockImportSource::Gossip, + || Ok(()), + ) + .await + .unwrap(); + } + self.harness.chain.recompute_head_at_current_slot().await; + } + /// Import a block directly into the chain without going through lookup sync async fn import_block_by_root(&mut self, block_root: Hash256) { let range_sync_block = self @@ -1000,23 +1231,32 @@ impl TestRig { // Post-test assertions - fn head_slot(&self) -> Slot { + pub(super) fn head_slot(&self) -> Slot { self.harness.chain.head().head_slot() } - fn assert_head_slot(&self, slot: u64) { + pub(super) fn assert_head_slot(&self, slot: u64) { assert_eq!(self.head_slot(), Slot::new(slot), "Unexpected head slot"); } - fn max_known_slot(&self) -> Slot { + pub(super) fn max_known_slot(&self) -> Slot { self.network_blocks_by_slot .keys() .max() .copied() - .expect("no blocks") + .unwrap_or_default() } - fn assert_penalties(&self, expected_penalties: &[&'static str]) { + pub(super) fn finalized_epoch(&self) -> types::Epoch { + self.harness + .chain + .canonical_head + .cached_head() + .finalized_checkpoint() + .epoch + } + + pub(super) fn assert_penalties(&self, expected_penalties: &[&'static str]) { let penalties = self .penalties .iter() @@ -1034,7 +1274,7 @@ impl TestRig { } } - fn assert_penalties_of_type(&self, expected_penalty: &'static str) { + pub(super) fn assert_penalties_of_type(&self, expected_penalty: &'static str) { if self.penalties.is_empty() { panic!("No penalties but expected some of type {expected_penalty}"); } @@ -1051,7 +1291,7 @@ impl TestRig { } } - fn assert_no_penalties(&mut self) { + pub(super) fn assert_no_penalties(&mut self) { if !self.penalties.is_empty() { panic!("Some downscore events: {:?}", self.penalties); } @@ -1102,7 +1342,7 @@ impl TestRig { } /// Assert there is at least one range sync chain created and that all sync chains completed - fn assert_successful_range_sync(&self) { + pub(super) fn assert_successful_range_sync(&self) { assert!( self.range_sync_chains_added() > 0, "No created range sync chains" @@ -1425,6 +1665,7 @@ impl TestRig { } } + #[allow(dead_code)] pub fn pop_received_processor_event) -> Option>( &mut self, predicate_transform: F, diff --git a/beacon_node/network/src/sync/tests/range.rs b/beacon_node/network/src/sync/tests/range.rs index c19ee8eb6d..891d9d1e97 100644 --- a/beacon_node/network/src/sync/tests/range.rs +++ b/beacon_node/network/src/sync/tests/range.rs @@ -1,110 +1,47 @@ +//! Range sync tests for `BlocksByRange`, `BlobsByRange`, `DataColumnsByRange`. +//! +//! Tests follow the pattern from `lookups.rs`: +//! ```ignore +//! async fn test_name() { +//! let mut r = TestRig::default(); +//! r.setup_xyz().await; +//! r.simulate(SimulateConfig::happy_path()).await; +//! r.assert_range_sync_completed(); +//! } +//! ``` +//! +//! Rules: +//! - Tests must be succinct and readable (3-10 lines per test body) +//! - All complex logic lives in helpers (setup, SimulateConfig, assert) +//! - Test bodies must not manually grab requests, send SyncMessages, or do anything overly specific +//! - All tests use `simulate()` if they need peers to fulfill requests +//! - Extend `SimulateConfig` for new range-specific behaviors +//! - Extend `simulate()` to support by_range methods + +use super::lookups::SimulateConfig; use super::*; -use crate::network_beacon_processor::ChainSegmentProcessId; use crate::status::ToStatusMessage; use crate::sync::SyncMessage; use crate::sync::manager::SLOT_IMPORT_TOLERANCE; -use crate::sync::network_context::RangeRequestId; use crate::sync::range_sync::RangeSyncType; -use beacon_chain::BeaconChain; -use beacon_chain::block_verification_types::AvailableBlockData; -use beacon_chain::custody_context::NodeCustodyType; -use beacon_chain::data_column_verification::CustodyDataColumn; -use beacon_chain::test_utils::{AttestationStrategy, BlockStrategy}; -use beacon_chain::{EngineState, NotifyExecutionLayer, block_verification_types::RangeSyncBlock}; -use beacon_processor::WorkType; -use lighthouse_network::rpc::RequestType; -use lighthouse_network::rpc::methods::{ - BlobsByRangeRequest, DataColumnsByRangeRequest, OldBlocksByRangeRequest, - OldBlocksByRangeRequestV2, StatusMessageV2, -}; -use lighthouse_network::service::api_types::{ - AppRequestId, BlobsByRangeRequestId, BlocksByRangeRequestId, DataColumnsByRangeRequestId, - SyncRequestId, -}; +use lighthouse_network::rpc::RPCError; +use lighthouse_network::rpc::methods::StatusMessageV2; use lighthouse_network::{PeerId, SyncInfo}; -use std::time::Duration; -use types::{ - BlobSidecarList, BlockImportSource, Epoch, EthSpec, Hash256, MinimalEthSpec as E, - SignedBeaconBlock, SignedBeaconBlockHash, Slot, -}; +use types::{Epoch, EthSpec, Hash256, MinimalEthSpec as E, Slot}; -const D: Duration = Duration::new(0, 0); - -pub(crate) enum DataSidecars { - Blobs(BlobSidecarList), - DataColumns(Vec>), -} - -enum ByRangeDataRequestIds { - PreDeneb, - PrePeerDAS(BlobsByRangeRequestId, PeerId), - PostPeerDAS(Vec<(DataColumnsByRangeRequestId, PeerId)>), -} - -/// Sync tests are usually written in the form: -/// - Do some action -/// - Expect a request to be sent -/// - Complete the above request -/// -/// To make writting tests succint, the machinery in this testing rig automatically identifies -/// _which_ request to complete. Picking the right request is critical for tests to pass, so this -/// filter allows better expressivity on the criteria to identify the right request. -#[derive(Default, Debug, Clone)] -struct RequestFilter { - peer: Option, - epoch: Option, -} - -impl RequestFilter { - fn peer(mut self, peer: PeerId) -> Self { - self.peer = Some(peer); - self - } - - fn epoch(mut self, epoch: u64) -> Self { - self.epoch = Some(epoch); - self - } -} - -fn filter() -> RequestFilter { - RequestFilter::default() -} +/// MinimalEthSpec has 8 slots per epoch +const SLOTS_PER_EPOCH: usize = 8; impl TestRig { - /// Produce a head peer with an advanced head fn add_head_peer(&mut self) -> PeerId { - self.add_head_peer_with_root(Hash256::random()) - } - - /// Produce a head peer with an advanced head - fn add_head_peer_with_root(&mut self, head_root: Hash256) -> PeerId { let local_info = self.local_info(); self.add_supernode_peer(SyncInfo { - head_root, + head_root: Hash256::random(), head_slot: local_info.head_slot + 1 + Slot::new(SLOT_IMPORT_TOLERANCE as u64), ..local_info }) } - // Produce a finalized peer with an advanced finalized epoch - fn add_finalized_peer(&mut self) -> PeerId { - self.add_finalized_peer_with_root(Hash256::random()) - } - - // Produce a finalized peer with an advanced finalized epoch - fn add_finalized_peer_with_root(&mut self, finalized_root: Hash256) -> PeerId { - let local_info = self.local_info(); - let finalized_epoch = local_info.finalized_epoch + 2; - self.add_supernode_peer(SyncInfo { - finalized_epoch, - finalized_root, - head_slot: finalized_epoch.start_slot(E::slots_per_epoch()), - head_root: Hash256::random(), - earliest_available_slot: None, - }) - } - fn finalized_remote_info_advanced_by(&self, advanced_epochs: Epoch) -> SyncInfo { let local_info = self.local_info(); let finalized_epoch = local_info.finalized_epoch + advanced_epochs; @@ -142,11 +79,7 @@ impl TestRig { } fn add_supernode_peer(&mut self, remote_info: SyncInfo) -> PeerId { - // Create valid peer known to network globals - // TODO(fulu): Using supernode peers to ensure we have peer across all column - // subnets for syncing. Should add tests connecting to full node peers. let peer_id = self.new_connected_supernode_peer(); - // Send peer to sync self.send_sync_message(SyncMessage::AddPeer(peer_id, remote_info)); peer_id } @@ -184,450 +117,362 @@ impl TestRig { ) } - #[track_caller] - fn assert_chain_segments(&mut self, count: usize) { - for i in 0..count { - self.pop_received_processor_event(|ev| { - (ev.work_type() == beacon_processor::WorkType::ChainSegment).then_some(()) - }) - .unwrap_or_else(|e| panic!("Expect ChainSegment work event count {i}: {e:?}")); - } + // -- Setup helpers -- + + /// Head sync: peers whose finalized root/epoch match ours (known to fork choice), + /// but whose head is ahead. Only head chain is created. + async fn setup_head_sync(&mut self) { + self.build_chain(SLOTS_PER_EPOCH).await; + self.add_head_peer(); + self.assert_state(RangeSyncType::Head); } - fn update_execution_engine_state(&mut self, state: EngineState) { - self.log(&format!("execution engine state updated: {state:?}")); - self.sync_manager.update_execution_engine_state(state); + /// Finalized sync: peers whose finalized epoch is advanced and head == finalized start slot. + /// Returns the remote SyncInfo (needed for blacklist tests). + async fn setup_finalized_sync(&mut self) -> SyncInfo { + let advanced_epochs = 5; + self.build_chain(advanced_epochs * SLOTS_PER_EPOCH).await; + let remote_info = self.finalized_remote_info_advanced_by((advanced_epochs as u64).into()); + self.add_fullnode_peers(remote_info.clone(), 100); + self.add_supernode_peer(remote_info.clone()); + self.assert_state(RangeSyncType::Finalized); + remote_info } - fn find_blocks_by_range_request( - &mut self, - request_filter: RequestFilter, - ) -> ((BlocksByRangeRequestId, PeerId), ByRangeDataRequestIds) { - let filter_f = |peer: PeerId, start_slot: u64| { - if let Some(expected_epoch) = request_filter.epoch { - let epoch = Slot::new(start_slot).epoch(E::slots_per_epoch()).as_u64(); - if epoch != expected_epoch { - return false; - } - } - if let Some(expected_peer) = request_filter.peer - && peer != expected_peer - { - return false; - } - - true + /// Finalized-to-head: peers whose finalized is advanced AND head is beyond finalized. + /// After finalized sync completes, head chains are created from awaiting_head_peers. + async fn setup_finalized_and_head_sync(&mut self) { + let finalized_epochs = 5; + let head_epochs = 7; + self.build_chain(head_epochs * SLOTS_PER_EPOCH).await; + let local_info = self.local_info(); + let finalized_epoch = local_info.finalized_epoch + Epoch::new(finalized_epochs as u64); + let head_slot = Slot::new((head_epochs * SLOTS_PER_EPOCH) as u64); + let remote_info = SyncInfo { + finalized_epoch, + finalized_root: Hash256::random(), + head_slot, + head_root: Hash256::random(), + earliest_available_slot: None, }; - - let block_req = self - .pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id, - request: - RequestType::BlocksByRange(OldBlocksByRangeRequest::V2( - OldBlocksByRangeRequestV2 { start_slot, .. }, - )), - app_request_id: AppRequestId::Sync(SyncRequestId::BlocksByRange(id)), - } if filter_f(*peer_id, *start_slot) => Some((*id, *peer_id)), - _ => None, - }) - .unwrap_or_else(|e| { - panic!("Should have a BlocksByRange request, filter {request_filter:?}: {e:?}") - }); - - let by_range_data_requests = if self.is_after_fulu() { - let mut data_columns_requests = vec![]; - while let Ok(data_columns_request) = self.pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id, - request: - RequestType::DataColumnsByRange(DataColumnsByRangeRequest { - start_slot, .. - }), - app_request_id: AppRequestId::Sync(SyncRequestId::DataColumnsByRange(id)), - } if filter_f(*peer_id, *start_slot) => Some((*id, *peer_id)), - _ => None, - }) { - data_columns_requests.push(data_columns_request); - } - if data_columns_requests.is_empty() { - panic!("Found zero DataColumnsByRange requests, filter {request_filter:?}"); - } - ByRangeDataRequestIds::PostPeerDAS(data_columns_requests) - } else if self.is_after_deneb() { - let (id, peer) = self - .pop_received_network_event(|ev| match ev { - NetworkMessage::SendRequest { - peer_id, - request: RequestType::BlobsByRange(BlobsByRangeRequest { start_slot, .. }), - app_request_id: AppRequestId::Sync(SyncRequestId::BlobsByRange(id)), - } if filter_f(*peer_id, *start_slot) => Some((*id, *peer_id)), - _ => None, - }) - .unwrap_or_else(|e| { - panic!("Should have a blobs by range request, filter {request_filter:?}: {e:?}") - }); - ByRangeDataRequestIds::PrePeerDAS(id, peer) - } else { - ByRangeDataRequestIds::PreDeneb - }; - - (block_req, by_range_data_requests) + self.add_fullnode_peers(remote_info.clone(), 100); + self.add_supernode_peer(remote_info); + self.assert_state(RangeSyncType::Finalized); } - fn find_and_complete_blocks_by_range_request( - &mut self, - request_filter: RequestFilter, - ) -> RangeRequestId { - let ((blocks_req_id, block_peer), by_range_data_request_ids) = - self.find_blocks_by_range_request(request_filter); - - // Complete the request with a single stream termination - self.log(&format!( - "Completing BlocksByRange request {blocks_req_id:?} with empty stream" - )); - self.send_sync_message(SyncMessage::RpcBlock { - sync_request_id: SyncRequestId::BlocksByRange(blocks_req_id), - peer_id: block_peer, - beacon_block: None, - seen_timestamp: D, - }); - - match by_range_data_request_ids { - ByRangeDataRequestIds::PreDeneb => {} - ByRangeDataRequestIds::PrePeerDAS(id, peer_id) => { - // Complete the request with a single stream termination - self.log(&format!( - "Completing BlobsByRange request {id:?} with empty stream" - )); - self.send_sync_message(SyncMessage::RpcBlob { - sync_request_id: SyncRequestId::BlobsByRange(id), - peer_id, - blob_sidecar: None, - seen_timestamp: D, - }); - } - ByRangeDataRequestIds::PostPeerDAS(data_column_req_ids) => { - // Complete the request with a single stream termination - for (id, peer_id) in data_column_req_ids { - self.log(&format!( - "Completing DataColumnsByRange request {id:?} with empty stream" - )); - self.send_sync_message(SyncMessage::RpcDataColumn { - sync_request_id: SyncRequestId::DataColumnsByRange(id), - peer_id, - data_column: None, - seen_timestamp: D, - }); - } - } - } - - blocks_req_id.parent_request_id.requester + /// Finalized sync with only 1 fullnode peer (insufficient custody coverage). + /// Returns remote_info to pass to `add_remaining_finalized_peers`. + async fn setup_finalized_sync_insufficient_peers(&mut self) -> SyncInfo { + let advanced_epochs = 5; + self.build_chain(advanced_epochs * SLOTS_PER_EPOCH).await; + let remote_info = self.finalized_remote_info_advanced_by((advanced_epochs as u64).into()); + self.add_fullnode_peer(remote_info.clone()); + self.assert_state(RangeSyncType::Finalized); + remote_info } - fn find_and_complete_processing_chain_segment(&mut self, id: ChainSegmentProcessId) { - self.pop_received_processor_event(|ev| { - (ev.work_type() == WorkType::ChainSegment).then_some(()) - }) - .unwrap_or_else(|e| panic!("Expected chain segment work event: {e}")); - - self.log(&format!( - "Completing ChainSegment processing work {id:?} with success" - )); - self.send_sync_message(SyncMessage::BatchProcessed { - sync_type: id, - result: crate::sync::BatchProcessResult::Success { - sent_blocks: 8, - imported_blocks: 8, - }, - }); - } - - fn complete_and_process_range_sync_until( - &mut self, - last_epoch: u64, - request_filter: RequestFilter, - ) { - for epoch in 0..last_epoch { - // Note: In this test we can't predict the block peer - let id = - self.find_and_complete_blocks_by_range_request(request_filter.clone().epoch(epoch)); - if let RangeRequestId::RangeSync { batch_id, .. } = id { - assert_eq!(batch_id.as_u64(), epoch, "Unexpected batch_id"); - } else { - panic!("unexpected RangeRequestId {id:?}"); - } - - let id = match id { - RangeRequestId::RangeSync { chain_id, batch_id } => { - ChainSegmentProcessId::RangeBatchId(chain_id, batch_id) - } - RangeRequestId::BackfillSync { batch_id } => { - ChainSegmentProcessId::BackSyncBatchId(batch_id) - } - }; - - self.find_and_complete_processing_chain_segment(id); - if epoch < last_epoch - 1 { - self.assert_state(RangeSyncType::Finalized); - } else { - self.assert_no_chains_exist(); - self.assert_no_failed_chains(); - } - } - } - - async fn create_canonical_block(&mut self) -> (SignedBeaconBlock, Option>) { - self.harness.advance_slot(); - - let block_root = self - .harness - .extend_chain( - 1, - BlockStrategy::OnCanonicalHead, - AttestationStrategy::AllValidators, - ) + /// Finalized sync where local node already has blocks up to `local_epochs`. + /// Triggers optimistic start: the chain tries to download a batch at the local head + /// epoch concurrently with sequential processing from the start. + async fn setup_finalized_sync_with_local_head(&mut self, local_epochs: usize) { + let target_epochs = local_epochs + 3; // target beyond local head + self.build_chain(target_epochs * SLOTS_PER_EPOCH).await; + self.import_blocks_up_to_slot((local_epochs * SLOTS_PER_EPOCH) as u64) .await; - - let store = &self.harness.chain.store; - let block = store.get_full_block(&block_root).unwrap().unwrap(); - let fork = block.fork_name_unchecked(); - - let data_sidecars = if fork.fulu_enabled() { - store - .get_data_columns(&block_root, fork) - .unwrap() - .map(|columns| { - columns - .into_iter() - .map(CustodyDataColumn::from_asserted_custody) - .collect() - }) - .map(DataSidecars::DataColumns) - } else if fork.deneb_enabled() { - store - .get_blobs(&block_root) - .unwrap() - .blobs() - .map(DataSidecars::Blobs) - } else { - None - }; - - (block, data_sidecars) + let remote_info = self.finalized_remote_info_advanced_by((target_epochs as u64).into()); + self.add_fullnode_peers(remote_info.clone(), 100); + self.add_supernode_peer(remote_info); + self.assert_state(RangeSyncType::Finalized); } - async fn remember_block( - &mut self, - (block, data_sidecars): (SignedBeaconBlock, Option>), - ) { - // This code is kind of duplicated from Harness::process_block, but takes sidecars directly. - let block_root = block.canonical_root(); - self.harness.set_current_slot(block.slot()); - let _: SignedBeaconBlockHash = self - .harness - .chain - .process_block( - block_root, - build_range_sync_block(block.into(), &data_sidecars, self.harness.chain.clone()), - NotifyExecutionLayer::Yes, - BlockImportSource::RangeSync, - || Ok(()), - ) - .await - .unwrap() - .try_into() - .unwrap(); - self.harness.chain.recompute_head_at_current_slot().await; + /// Add enough peers to cover all custody columns (same chain as insufficient setup) + fn add_remaining_finalized_peers(&mut self, remote_info: SyncInfo) { + self.add_fullnode_peers(remote_info.clone(), 100); + self.add_supernode_peer(remote_info); + } + + // -- Assert helpers -- + + /// Assert range sync completed: chains created and removed, all blocks ingested, + /// finalized epoch advanced, no penalties, no leftover events. + fn assert_range_sync_completed(&mut self) { + self.assert_successful_range_sync(); + self.assert_no_failed_chains(); + assert_eq!( + self.head_slot(), + self.max_known_slot(), + "Head slot should match the last built block (all blocks ingested)" + ); + assert!( + self.finalized_epoch() > types::Epoch::new(0), + "Finalized epoch should have advanced past genesis, got {}", + self.finalized_epoch() + ); + self.assert_no_penalties(); + self.assert_empty_network(); + self.assert_empty_processor(); + } + + /// Assert head sync completed (no finalization expected for short ranges) + fn assert_head_sync_completed(&mut self) { + self.assert_successful_range_sync(); + self.assert_no_failed_chains(); + assert_eq!( + self.head_slot(), + self.max_known_slot(), + "Head slot should match the last built block (all blocks ingested)" + ); + self.assert_no_penalties(); + } + + /// Assert chain was removed and peers received faulty_chain penalty + fn assert_range_sync_chain_failed(&mut self) { + self.assert_no_chains_exist(); + assert!( + self.penalties.iter().any(|p| p.msg == "faulty_chain"), + "Expected faulty_chain penalty, got {:?}", + self.penalties + ); + } + + /// Assert range sync removed chains (e.g., all peers disconnected) + fn assert_range_sync_chain_removed(&mut self) { + self.assert_no_chains_exist(); + } + + /// Assert a new peer with a blacklisted root gets disconnected + fn assert_peer_blacklisted(&mut self, remote_info: SyncInfo) { + let new_peer = self.add_supernode_peer(remote_info); + self.pop_received_network_event(|ev| match ev { + NetworkMessage::GoodbyePeer { peer_id, .. } if *peer_id == new_peer => Some(()), + _ => None, + }) + .expect("Peer with blacklisted root should receive Goodbye"); } } -fn build_range_sync_block( - block: Arc>, - data_sidecars: &Option>, - chain: Arc>, -) -> RangeSyncBlock { - match data_sidecars { - Some(DataSidecars::Blobs(blobs)) => { - let block_data = AvailableBlockData::new_with_blobs(blobs.clone()); - RangeSyncBlock::new( - block, - block_data, - &chain.data_availability_checker, - chain.spec.clone(), - ) - .unwrap() - } - Some(DataSidecars::DataColumns(columns)) => { - let block_data = AvailableBlockData::new_with_data_columns( - columns - .iter() - .map(|c| c.as_data_column().clone()) - .collect::>(), - ); - RangeSyncBlock::new( - block, - block_data, - &chain.data_availability_checker, - chain.spec.clone(), - ) - .unwrap() - } - // Block has no data, expects zero columns - None => RangeSyncBlock::new( - block, - AvailableBlockData::NoData, - &chain.data_availability_checker, - chain.spec.clone(), - ) - .unwrap(), - } -} - -#[test] -fn head_chain_removed_while_finalized_syncing() { - // NOTE: this is a regression test. - // Added in PR https://github.com/sigp/lighthouse/pull/2821 - let mut rig = TestRig::default(); - - // Get a peer with an advanced head - let head_peer = rig.add_head_peer(); - rig.assert_state(RangeSyncType::Head); - - // Sync should have requested a batch, grab the request. - let _ = rig.find_blocks_by_range_request(filter().peer(head_peer)); - - // Now get a peer with an advanced finalized epoch. - let finalized_peer = rig.add_finalized_peer(); - rig.assert_state(RangeSyncType::Finalized); - - // Sync should have requested a batch, grab the request - let _ = rig.find_blocks_by_range_request(filter().peer(finalized_peer)); - - // Fail the head chain by disconnecting the peer. - rig.peer_disconnected(head_peer); - rig.assert_state(RangeSyncType::Finalized); -} +// ============================================================================================ +// Tests +// ============================================================================================ +/// Head sync: single peer slightly ahead → download batches → all blocks ingested. #[tokio::test] -async fn state_update_while_purging() { - // NOTE: this is a regression test. - // Added in PR https://github.com/sigp/lighthouse/pull/2827 - let mut rig = TestRig::with_custody_type(NodeCustodyType::SemiSupernode); - - // Create blocks on a separate harness - // SemiSupernode ensures enough columns are stored for sampling + custody RPC block validation - let mut rig_2 = TestRig::with_custody_type(NodeCustodyType::SemiSupernode); - // Need to create blocks that can be inserted into the fork-choice and fit the "known - // conditions" below. - let head_peer_block = rig_2.create_canonical_block().await; - let head_peer_root = head_peer_block.0.canonical_root(); - let finalized_peer_block = rig_2.create_canonical_block().await; - let finalized_peer_root = finalized_peer_block.0.canonical_root(); - - // Get a peer with an advanced head - let head_peer = rig.add_head_peer_with_root(head_peer_root); - rig.assert_state(RangeSyncType::Head); - - // Sync should have requested a batch, grab the request. - let _ = rig.find_blocks_by_range_request(filter().peer(head_peer)); - - // Now get a peer with an advanced finalized epoch. - let finalized_peer = rig.add_finalized_peer_with_root(finalized_peer_root); - rig.assert_state(RangeSyncType::Finalized); - - // Sync should have requested a batch, grab the request - let _ = rig.find_blocks_by_range_request(filter().peer(finalized_peer)); - - // Now the chain knows both chains target roots. - rig.remember_block(head_peer_block).await; - rig.remember_block(finalized_peer_block).await; - - // Add an additional peer to the second chain to make range update it's status - rig.add_finalized_peer(); -} - -#[test] -fn pause_and_resume_on_ee_offline() { - let mut rig = TestRig::default(); - - // add some peers - let peer1 = rig.add_head_peer(); - // make the ee offline - rig.update_execution_engine_state(EngineState::Offline); - // send the response to the request - rig.find_and_complete_blocks_by_range_request(filter().peer(peer1).epoch(0)); - // the beacon processor shouldn't have received any work - rig.assert_empty_processor(); - - // while the ee is offline, more peers might arrive. Add a new finalized peer. - let _peer2 = rig.add_finalized_peer(); - - // send the response to the request - // Don't filter requests and the columns requests may be sent to peer1 or peer2 - // We need to filter by epoch, because the previous batch eagerly sent requests for the next - // epoch for the other batch. So we can either filter by epoch of by sync type. - rig.find_and_complete_blocks_by_range_request(filter().epoch(0)); - // the beacon processor shouldn't have received any work - rig.assert_empty_processor(); - // make the beacon processor available again. - // update_execution_engine_state implicitly calls resume - // now resume range, we should have two processing requests in the beacon processor. - rig.update_execution_engine_state(EngineState::Online); - - // The head chain and finalized chain (2) should be in the processing queue - rig.assert_chain_segments(2); -} - -/// To attempt to finalize the peer's status finalized checkpoint we synced to its finalized epoch + -/// 2 epochs + 1 slot. -const EXTRA_SYNCED_EPOCHS: u64 = 2 + 1; - -#[test] -fn finalized_sync_enough_global_custody_peers_few_chain_peers() { - // Run for all forks +async fn head_sync_completes() { let mut r = TestRig::default(); - - let advanced_epochs: u64 = 2; - let remote_info = r.finalized_remote_info_advanced_by(advanced_epochs.into()); - - // Generate enough peers and supernodes to cover all custody columns - let peer_count = 100; - r.add_fullnode_peers(remote_info.clone(), peer_count); - r.add_supernode_peer(remote_info); - r.assert_state(RangeSyncType::Finalized); - - let last_epoch = advanced_epochs + EXTRA_SYNCED_EPOCHS; - r.complete_and_process_range_sync_until(last_epoch, filter()); + r.setup_head_sync().await; + r.simulate(SimulateConfig::happy_path()).await; + r.assert_head_sync_completed(); + r.assert_head_slot(SLOTS_PER_EPOCH as u64); } -#[test] -fn finalized_sync_not_enough_custody_peers_on_start() { +/// Peers with advanced finalized AND head beyond finalized. Finalized sync completes first, +/// then head chains are created from awaiting_head_peers to sync the remaining gap. +#[tokio::test] +async fn finalized_to_head_transition() { + let mut r = TestRig::default(); + r.setup_finalized_and_head_sync().await; + r.simulate(SimulateConfig::happy_path()).await; + r.assert_range_sync_completed(); + r.assert_head_slot(7 * SLOTS_PER_EPOCH as u64); +} + +/// Finalized sync happy path: all batches download and process, head advances to target, +/// finalized epoch advances past genesis. +#[tokio::test] +async fn finalized_sync_completes() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path()).await; + r.assert_range_sync_completed(); + r.assert_head_slot(5 * SLOTS_PER_EPOCH as u64); +} + +/// First BlocksByRange request gets an RPC error. Batch retries from another peer, +/// sync completes with no penalties (RPC errors are not penalized). +#[tokio::test] +async fn batch_rpc_error_retries() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().return_rpc_error(RPCError::UnsupportedProtocol)) + .await; + r.assert_range_sync_completed(); +} + +/// Peer returns zero blocks for a BlocksByRange request. Batch retries, sync completes. +#[tokio::test] +async fn batch_peer_returns_empty_then_succeeds() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_no_range_blocks_n_times(1)) + .await; + r.assert_successful_range_sync(); +} + +/// Peer returns zero columns for a DataColumnsByRange request. Batch retries, sync completes. +/// Only exercises column logic on fulu+. +#[tokio::test] +async fn batch_peer_returns_no_columns_then_succeeds() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_no_range_columns_n_times(1)) + .await; + r.assert_successful_range_sync(); +} + +/// Peer returns columns with indices it wasn't asked for → UnrequestedIndex verify error. +/// Batch retries from another peer, sync completes. +#[tokio::test] +async fn batch_peer_returns_wrong_column_indices_then_succeeds() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_wrong_range_column_indices_n_times(1)) + .await; + r.assert_successful_range_sync(); +} + +/// Peer returns columns from a slot outside the requested range → UnrequestedSlot verify error. +/// Batch retries from another peer, sync completes. +#[tokio::test] +async fn batch_peer_returns_wrong_column_slots_then_succeeds() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_wrong_range_column_slots_n_times(1)) + .await; + r.assert_successful_range_sync(); +} + +/// PeerDAS: peer returns only half the requested columns. Block-sidecar coupling detects +/// missing columns → CouplingError::DataColumnPeerFailure → retry_partial_batch from other peers. +#[tokio::test] +async fn batch_peer_returns_partial_columns_then_succeeds() { let mut r = TestRig::default(); - // Only run post-PeerDAS if !r.fork_name.fulu_enabled() { return; } - - let advanced_epochs: u64 = 2; - let remote_info = r.finalized_remote_info_advanced_by(advanced_epochs.into()); - - // Unikely that the single peer we added has enough columns for us. Tests are deterministic and - // this error should never be hit - r.add_fullnode_peer(remote_info.clone()); - r.assert_state(RangeSyncType::Finalized); - - // Because we don't have enough peers on all columns we haven't sent any request. - // NOTE: There's a small chance that this single peer happens to custody exactly the set we - // expect, in that case the test will fail. Find a way to make the test deterministic. - r.assert_empty_network(); - - // Generate enough peers and supernodes to cover all custody columns - let peer_count = 100; - r.add_fullnode_peers(remote_info.clone(), peer_count); - r.add_supernode_peer(remote_info); - - let last_epoch = advanced_epochs + EXTRA_SYNCED_EPOCHS; - r.complete_and_process_range_sync_until(last_epoch, filter()); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_partial_range_columns_n_times(1)) + .await; + r.assert_successful_range_sync(); +} + +/// Batch processing returns NonFaultyFailure (e.g. transient error). Batch goes back to +/// AwaitingDownload, retries without penalty, sync completes. +#[tokio::test] +async fn batch_non_faulty_failure_retries() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_range_non_faulty_failures(1)) + .await; + r.assert_range_sync_completed(); +} + +/// Batch processing returns FaultyFailure once. Peer penalized with "faulty_batch", +/// batch redownloaded from a different peer, sync completes. +#[tokio::test] +async fn batch_faulty_failure_redownloads() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_range_faulty_failures(1)) + .await; + r.assert_successful_range_sync(); + r.assert_penalties_of_type("faulty_batch"); +} + +/// Batch processing fails MAX_BATCH_PROCESSING_ATTEMPTS (3) times with FaultyFailure. +/// Chain removed, all peers penalized with "faulty_chain". +#[tokio::test] +async fn batch_max_failures_removes_chain() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_range_faulty_failures(3)) + .await; + r.assert_range_sync_chain_failed(); +} + +/// Chain fails via max faulty retries → finalized root added to failed_chains LRU. +/// A new peer advertising the same finalized root gets disconnected with GoodbyeReason. +#[tokio::test] +async fn failed_chain_blacklisted() { + let mut r = TestRig::default(); + let remote_info = r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_range_faulty_failures(3)) + .await; + r.assert_range_sync_chain_failed(); + r.assert_peer_blacklisted(remote_info); +} + +/// All peers disconnect before any request is fulfilled → chain removed (EmptyPeerPool). +#[tokio::test] +async fn all_peers_disconnect_removes_chain() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_disconnect_after_range_requests(0)) + .await; + r.assert_range_sync_chain_removed(); +} + +/// Peers disconnect after 1 request is served. Remaining in-flight responses arrive +/// for a chain that no longer exists — verified as a no-op (no crash). +#[tokio::test] +async fn late_response_for_removed_chain() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_disconnect_after_range_requests(1)) + .await; + r.assert_range_sync_chain_removed(); +} + +/// Execution engine goes offline at sync start. Batch responses complete but processing +/// is paused. After 2 responses, EE comes back online, queued batches process, sync completes. +#[tokio::test] +async fn ee_offline_then_online_resumes_sync() { + let mut r = TestRig::default(); + r.setup_finalized_sync().await; + r.simulate(SimulateConfig::happy_path().with_ee_offline_for_n_range_responses(2)) + .await; + r.assert_range_sync_completed(); +} + +/// Local node already has blocks up to epoch 3. Finalized sync starts targeting epoch 6. +/// The chain uses optimistic start: downloads a batch at the local head epoch concurrently +/// with sequential processing from the start. All blocks ingested. +#[tokio::test] +async fn finalized_sync_with_local_head_partial() { + let mut r = TestRig::default(); + r.setup_finalized_sync_with_local_head(3).await; + r.simulate(SimulateConfig::happy_path()).await; + r.assert_range_sync_completed(); +} + +/// Local node has all blocks except the last one. Finalized sync only needs to fill the +/// final gap. Tests optimistic start where local head is near the target. +#[tokio::test] +async fn finalized_sync_with_local_head_near_target() { + let mut r = TestRig::default(); + 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; + r.import_blocks_up_to_slot(local_slots as u64).await; + let remote_info = r.finalized_remote_info_advanced_by((target_epochs as u64).into()); + r.add_fullnode_peers(remote_info.clone(), 100); + r.add_supernode_peer(remote_info); + r.assert_state(RangeSyncType::Finalized); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_range_sync_completed(); + r.assert_head_slot((target_epochs * SLOTS_PER_EPOCH) as u64); +} + +/// PeerDAS only: single fullnode peer doesn't cover all custody columns → no requests sent. +/// Once enough fullnodes + a supernode arrive, sync proceeds and completes. +#[tokio::test] +async fn not_enough_custody_peers_then_peers_arrive() { + let mut r = TestRig::default(); + if !r.fork_name.fulu_enabled() { + return; + } + let remote_info = r.setup_finalized_sync_insufficient_peers().await; + r.assert_empty_network(); + r.add_remaining_finalized_peers(remote_info); + r.simulate(SimulateConfig::happy_path()).await; + r.assert_range_sync_completed(); } diff --git a/scripts/range-sync-coverage.sh b/scripts/range-sync-coverage.sh new file mode 100755 index 0000000000..df438c0c7f --- /dev/null +++ b/scripts/range-sync-coverage.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# Aggregate range sync test coverage across all forks +# Usage: ./scripts/range-sync-coverage.sh [--html] +set -e + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +TARGET_DIR="${CARGO_TARGET_DIR:-/mnt/ssd/builds/lighthouse-range-sync-tests}" +FORKS=(base altair bellatrix capella deneb electra fulu) +LCOV_DIR="/tmp/range-cov-forks" +MERGED="/tmp/range-cov-merged.lcov" + +rm -rf "$LCOV_DIR" +mkdir -p "$LCOV_DIR" + +echo "=== Running coverage for each fork ===" +for fork in "${FORKS[@]}"; do + echo "--- $fork ---" + CARGO_TARGET_DIR="$TARGET_DIR" FORK_NAME="$fork" \ + cargo llvm-cov --features "network/fake_crypto,network/fork_from_env" \ + -p network --lib --lcov --output-path "$LCOV_DIR/$fork.lcov" \ + -- "sync::tests::range" 2>&1 | grep -E "test result|running" +done + +echo "" +echo "=== Merging lcov files ===" + +# Merge all lcov files: for each source file, take max hit count per line +python3 - "$LCOV_DIR" "$MERGED" << 'PYEOF' +import sys, os, glob +from collections import defaultdict + +lcov_dir = sys.argv[1] +output = sys.argv[2] + +# Parse all lcov files: file -> line -> max hits +coverage = defaultdict(lambda: defaultdict(int)) +fn_coverage = defaultdict(lambda: defaultdict(int)) +current_sf = None + +for lcov_file in sorted(glob.glob(os.path.join(lcov_dir, "*.lcov"))): + with open(lcov_file) as f: + for line in f: + line = line.strip() + if line.startswith("SF:"): + current_sf = line[3:] + elif line.startswith("DA:") and current_sf: + parts = line[3:].split(",") + lineno = int(parts[0]) + hits = int(parts[1]) + coverage[current_sf][lineno] = max(coverage[current_sf][lineno], hits) + elif line.startswith("FNDA:") and current_sf: + parts = line[5:].split(",", 1) + hits = int(parts[0]) + fn_name = parts[1] + fn_coverage[current_sf][fn_name] = max(fn_coverage[current_sf][fn_name], hits) + +# Write merged lcov +with open(output, "w") as f: + for sf in sorted(coverage.keys()): + f.write(f"SF:{sf}\n") + for fn_name, hits in sorted(fn_coverage.get(sf, {}).items()): + f.write(f"FNDA:{hits},{fn_name}\n") + for lineno in sorted(coverage[sf].keys()): + f.write(f"DA:{lineno},{coverage[sf][lineno]}\n") + total = len(coverage[sf]) + covered = sum(1 for h in coverage[sf].values() if h > 0) + f.write(f"LH:{covered}\n") + f.write(f"LF:{total}\n") + f.write("end_of_record\n") + +print(f"Merged {len(glob.glob(os.path.join(lcov_dir, '*.lcov')))} lcov files -> {output}") +PYEOF + +echo "" +echo "=== Range sync coverage (merged across all forks) ===" + +# Extract and display range sync files +python3 - "$MERGED" << 'PYEOF' +import sys +from collections import defaultdict + +current_sf = None +files = {} # short_name -> (total_lines, covered_lines) +lines = defaultdict(dict) + +with open(sys.argv[1]) as f: + for line in f: + line = line.strip() + if line.startswith("SF:"): + current_sf = line[3:] + elif line.startswith("DA:") and current_sf: + parts = line[3:].split(",") + lineno, hits = int(parts[0]), int(parts[1]) + lines[current_sf][lineno] = hits + +# Filter to range sync files +targets = [ + "range_sync/chain.rs", + "range_sync/chain_collection.rs", + "range_sync/range.rs", + "requests/blocks_by_range.rs", + "requests/blobs_by_range.rs", + "requests/data_columns_by_range.rs", +] + +print(f"{'File':<45} {'Lines':>6} {'Covered':>8} {'Missed':>7} {'Coverage':>9}") +print("-" * 80) + +total_all = 0 +covered_all = 0 + +for sf in sorted(lines.keys()): + short = sf.split("sync/")[-1] if "sync/" in sf else sf.split("/")[-1] + if not any(t in sf for t in targets): + continue + total = len(lines[sf]) + covered = sum(1 for h in lines[sf].values() if h > 0) + missed = total - covered + pct = covered / total * 100 if total > 0 else 0 + total_all += total + covered_all += covered + print(f"{short:<45} {total:>6} {covered:>8} {missed:>7} {pct:>8.1f}%") + +print("-" * 80) +pct_all = covered_all / total_all * 100 if total_all > 0 else 0 +print(f"{'TOTAL':<45} {total_all:>6} {covered_all:>8} {total_all - covered_all:>7} {pct_all:>8.1f}%") +PYEOF + +if [ "$1" = "--html" ]; then + echo "" + echo "=== Generating HTML report ===" + genhtml "$MERGED" -o /tmp/range-cov-html --ignore-errors source 2>/dev/null + echo "HTML report: /tmp/range-cov-html/index.html" +fi From d92efc1e0fe664be31c0b583fa2b876c077cd446 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 31 Mar 2026 16:59:36 +1100 Subject: [PATCH 095/189] Update to spec v1.7.0-alpha.4 (#9046) Update our consensus code to v1.7.0-alpha.4 Co-Authored-By: Michael Sproul --- .../src/per_epoch_processing/altair.rs | 11 ++- .../src/per_epoch_processing/single_pass.rs | 76 +++++++++++++++++- .../state_processing/src/upgrade/gloas.rs | 43 +++++++++- consensus/types/src/core/eth_spec.rs | 20 ++++- consensus/types/src/state/beacon_state.rs | 79 ++++++++++++++++++- consensus/types/src/state/committee_cache.rs | 37 ++++++++- testing/ef_tests/Makefile | 2 +- testing/ef_tests/check_all_files_accessed.py | 6 +- .../ef_tests/src/cases/epoch_processing.rs | 19 ++++- testing/ef_tests/src/cases/operations.rs | 6 +- testing/ef_tests/src/lib.rs | 2 +- testing/ef_tests/tests/tests.rs | 6 ++ 12 files changed, 279 insertions(+), 28 deletions(-) diff --git a/consensus/state_processing/src/per_epoch_processing/altair.rs b/consensus/state_processing/src/per_epoch_processing/altair.rs index d9e6964730..683d92d836 100644 --- a/consensus/state_processing/src/per_epoch_processing/altair.rs +++ b/consensus/state_processing/src/per_epoch_processing/altair.rs @@ -51,8 +51,8 @@ pub fn process_epoch( // without loss of correctness. let current_epoch_progressive_balances = state.progressive_balances_cache().clone(); let current_epoch_total_active_balance = state.get_total_active_balance()?; - let participation_summary = - process_epoch_single_pass(state, spec, SinglePassConfig::default())?; + let epoch_result = process_epoch_single_pass(state, spec, SinglePassConfig::default())?; + let participation_summary = epoch_result.summary; // Reset eth1 data votes. process_eth1_data_reset(state)?; @@ -79,6 +79,13 @@ pub fn process_epoch( // Rotate the epoch caches to suit the epoch transition. state.advance_caches()?; + + // Install the lookahead committee cache (built during PTC window processing) as the Next + // cache. After advance_caches, the lookahead epoch becomes the Next relative epoch. + if let Some(cache) = epoch_result.lookahead_committee_cache { + state.set_committee_cache(RelativeEpoch::Next, cache)?; + } + update_progressive_balances_on_epoch_transition(state, spec)?; Ok(EpochProcessingSummary::Altair { diff --git a/consensus/state_processing/src/per_epoch_processing/single_pass.rs b/consensus/state_processing/src/per_epoch_processing/single_pass.rs index 4eb1e36628..976607aa76 100644 --- a/consensus/state_processing/src/per_epoch_processing/single_pass.rs +++ b/consensus/state_processing/src/per_epoch_processing/single_pass.rs @@ -12,12 +12,13 @@ use milhouse::{Cow, List, Vector}; use safe_arith::{SafeArith, SafeArithIter}; use std::cmp::{max, min}; use std::collections::{BTreeSet, HashMap}; +use std::sync::Arc; use tracing::instrument; use typenum::Unsigned; use types::{ ActivationQueue, BeaconState, BeaconStateError, BuilderPendingPayment, ChainSpec, Checkpoint, - DepositData, Epoch, EthSpec, ExitCache, ForkName, ParticipationFlags, PendingDeposit, - ProgressiveBalancesCache, RelativeEpoch, Validator, + CommitteeCache, DepositData, Epoch, EthSpec, ExitCache, ForkName, ParticipationFlags, + PendingDeposit, ProgressiveBalancesCache, RelativeEpoch, Validator, consts::altair::{ NUM_FLAG_INDICES, PARTICIPATION_FLAG_WEIGHTS, TIMELY_HEAD_FLAG_INDEX, TIMELY_TARGET_FLAG_INDEX, WEIGHT_DENOMINATOR, @@ -34,6 +35,7 @@ pub struct SinglePassConfig { pub effective_balance_updates: bool, pub proposer_lookahead: bool, pub builder_pending_payments: bool, + pub ptc_window: bool, } impl Default for SinglePassConfig { @@ -54,6 +56,7 @@ impl SinglePassConfig { effective_balance_updates: true, proposer_lookahead: true, builder_pending_payments: true, + ptc_window: true, } } @@ -68,6 +71,7 @@ impl SinglePassConfig { effective_balance_updates: false, proposer_lookahead: false, builder_pending_payments: false, + ptc_window: false, } } } @@ -139,12 +143,20 @@ impl ValidatorInfo { } } +/// Result of single-pass epoch processing. +pub struct SinglePassEpochResult { + pub summary: ParticipationEpochSummary, + /// Committee cache for the lookahead epoch, built during PTC window processing. + /// Can be installed as the Next committee cache after `advance_caches`. + pub lookahead_committee_cache: Option>, +} + #[instrument(skip_all)] pub fn process_epoch_single_pass( state: &mut BeaconState, spec: &ChainSpec, conf: SinglePassConfig, -) -> Result, Error> { +) -> Result, Error> { initialize_epoch_cache(state, spec)?; initialize_progressive_balances_cache(state, spec)?; state.build_exit_cache(spec)?; @@ -479,7 +491,16 @@ pub fn process_epoch_single_pass( process_proposer_lookahead(state, spec)?; } - Ok(summary) + let lookahead_committee_cache = if conf.ptc_window && fork_name.gloas_enabled() { + Some(process_ptc_window(state, spec)?) + } else { + None + }; + + Ok(SinglePassEpochResult { + summary, + lookahead_committee_cache, + }) } // TOOO(EIP-7917): use balances cache @@ -512,6 +533,53 @@ pub fn process_proposer_lookahead( Ok(()) } +/// Process the PTC window, returning the committee cache built for the lookahead epoch. +/// +/// The returned cache can be injected into the state's Next committee cache slot after +/// `advance_caches` is called during the epoch transition, avoiding redundant recomputation. +pub fn process_ptc_window( + state: &mut BeaconState, + spec: &ChainSpec, +) -> Result, Error> { + let slots_per_epoch = E::slots_per_epoch() as usize; + + // Convert Vector -> List to use tree-efficient pop_front. + let ptc_window = state.ptc_window()?.clone(); + let mut window: List<_, E::PtcWindowLength> = List::from(ptc_window); + + // Drop the oldest epoch from the front (reuses shared tree nodes). + window + .pop_front(slots_per_epoch) + .map_err(|e| Error::BeaconStateError(BeaconStateError::MilhouseError(e)))?; + + // Compute PTC for the new lookahead epoch + let next_epoch = state + .current_epoch() + .safe_add(spec.min_seed_lookahead.as_u64())? + .safe_add(1)?; + let start_slot = next_epoch.start_slot(E::slots_per_epoch()); + + // Build a committee cache for the lookahead epoch (beyond the normal Next bound) + let committee_cache = state.initialize_committee_cache_for_lookahead(next_epoch, spec)?; + + for i in 0..slots_per_epoch { + let slot = start_slot.safe_add(i as u64)?; + let ptc = state.compute_ptc_with_cache(slot, &committee_cache, spec)?; + let ptc_u64: Vec = ptc.into_iter().map(|v| v as u64).collect(); + let entry = ssz_types::FixedVector::new(ptc_u64) + .map_err(|e| Error::BeaconStateError(BeaconStateError::SszTypesError(e)))?; + window + .push(entry) + .map_err(|e| Error::BeaconStateError(BeaconStateError::MilhouseError(e)))?; + } + + // Convert List back to Vector. + *state.ptc_window_mut()? = Vector::try_from(window) + .map_err(|e| Error::BeaconStateError(BeaconStateError::MilhouseError(e)))?; + + Ok(committee_cache) +} + /// Calculate the quorum threshold for builder payments based on total active balance. fn get_builder_payment_quorum_threshold( state_ctxt: &StateContext, diff --git a/consensus/state_processing/src/upgrade/gloas.rs b/consensus/state_processing/src/upgrade/gloas.rs index 7a88383ab0..b39ee6048f 100644 --- a/consensus/state_processing/src/upgrade/gloas.rs +++ b/consensus/state_processing/src/upgrade/gloas.rs @@ -2,7 +2,9 @@ use crate::per_block_processing::{ is_valid_deposit_signature, process_operations::apply_deposit_for_builder, }; use milhouse::{List, Vector}; +use safe_arith::SafeArith; use ssz_types::BitVector; +use ssz_types::FixedVector; use std::collections::HashSet; use std::mem; use typenum::Unsigned; @@ -102,13 +104,11 @@ pub fn upgrade_state_to_gloas( vec![0xFFu8; E::SlotsPerHistoricalRoot::to_usize() / 8].into(), ) .map_err(|_| Error::InvalidBitfield)?, - builder_pending_payments: Vector::new(vec![ - BuilderPendingPayment::default(); - E::builder_pending_payments_limit() - ])?, + builder_pending_payments: Vector::from_elem(BuilderPendingPayment::default())?, builder_pending_withdrawals: List::default(), // Empty list initially, latest_block_hash: pre.latest_execution_payload_header.block_hash, payload_expected_withdrawals: List::default(), + ptc_window: Vector::from_elem(FixedVector::from_elem(0))?, // placeholder, will be initialized below // Caches total_active_balance: pre.total_active_balance, progressive_balances_cache: mem::take(&mut pre.progressive_balances_cache), @@ -120,10 +120,45 @@ pub fn upgrade_state_to_gloas( }); // [New in Gloas:EIP7732] onboard_builders_from_pending_deposits(&mut post, spec)?; + initialize_ptc_window(&mut post, spec)?; Ok(post) } +/// Initialize the `ptc_window` field in the beacon state at fork transition. +/// +/// The window contains: +/// - One epoch of empty entries (previous epoch) +/// - Computed PTC for the current epoch through `1 + MIN_SEED_LOOKAHEAD` epochs +fn initialize_ptc_window( + state: &mut BeaconState, + spec: &ChainSpec, +) -> Result<(), Error> { + let slots_per_epoch = E::slots_per_epoch() as usize; + + let empty_previous_epoch = vec![FixedVector::::from_elem(0); slots_per_epoch]; + let mut ptcs = empty_previous_epoch; + + // Compute PTC for current epoch + lookahead epochs + let current_epoch = state.current_epoch(); + for e in 0..=spec.min_seed_lookahead.as_u64() { + let epoch = current_epoch.safe_add(e)?; + let committee_cache = state.initialize_committee_cache_for_lookahead(epoch, spec)?; + let start_slot = epoch.start_slot(E::slots_per_epoch()); + for i in 0..slots_per_epoch { + let slot = start_slot.safe_add(i as u64)?; + let ptc = state.compute_ptc_with_cache(slot, &committee_cache, spec)?; + let ptc_u64: Vec = ptc.into_iter().map(|v| v as u64).collect(); + let entry = FixedVector::new(ptc_u64)?; + ptcs.push(entry); + } + } + + *state.ptc_window_mut()? = Vector::new(ptcs)?; + + Ok(()) +} + /// Applies any pending deposit for builders, effectively onboarding builders at the fork. fn onboard_builders_from_pending_deposits( state: &mut BeaconState, diff --git a/consensus/types/src/core/eth_spec.rs b/consensus/types/src/core/eth_spec.rs index a4b22da3f8..36d61fbbf9 100644 --- a/consensus/types/src/core/eth_spec.rs +++ b/consensus/types/src/core/eth_spec.rs @@ -6,9 +6,9 @@ use std::{ use safe_arith::{ArithError, SafeArith}; use serde::{Deserialize, Serialize}; use typenum::{ - U0, U1, U2, U4, U8, U16, U17, U32, U64, U128, U256, U512, U625, U1024, U2048, U4096, U8192, - U16384, U65536, U131072, U262144, U1048576, U16777216, U33554432, U134217728, U1073741824, - U1099511627776, UInt, Unsigned, bit::B0, + U0, U1, U2, U4, U8, U16, U17, U24, U32, U48, U64, U96, U128, U256, U512, U625, U1024, U2048, + U4096, U8192, U16384, U65536, U131072, U262144, U1048576, U16777216, U33554432, U134217728, + U1073741824, U1099511627776, UInt, Unsigned, bit::B0, }; use crate::core::{ChainSpec, Epoch}; @@ -176,6 +176,7 @@ pub trait EthSpec: 'static + Default + Sync + Send + Clone + Debug + PartialEq + * New in Gloas */ type PTCSize: Unsigned + Clone + Sync + Send + Debug + PartialEq; + type PtcWindowLength: Unsigned + Clone + Sync + Send + Debug + PartialEq; type MaxPayloadAttestations: Unsigned + Clone + Sync + Send + Debug + PartialEq; type BuilderPendingPaymentsLimit: Unsigned + Clone + Sync + Send + Debug + PartialEq; type BuilderPendingWithdrawalsLimit: Unsigned + Clone + Sync + Send + Debug + PartialEq; @@ -428,6 +429,11 @@ pub trait EthSpec: 'static + Default + Sync + Send + Clone + Debug + PartialEq + Self::PTCSize::to_usize() } + /// Returns the `PtcWindowLength` constant for this specification. + fn ptc_window_length() -> usize { + Self::PtcWindowLength::to_usize() + } + /// Returns the `MaxPayloadAttestations` constant for this specification. fn max_payload_attestations() -> usize { Self::MaxPayloadAttestations::to_usize() @@ -515,6 +521,7 @@ impl EthSpec for MainnetEthSpec { type MaxWithdrawalRequestsPerPayload = U16; type MaxPendingDepositsPerEpoch = U16; type PTCSize = U512; + type PtcWindowLength = U96; // (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH type MaxPayloadAttestations = U4; type MaxBuildersPerWithdrawalsSweep = U16384; @@ -561,6 +568,7 @@ impl EthSpec for MinimalEthSpec { type ProposerLookaheadSlots = U16; // Derived from (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH type BuilderPendingPaymentsLimit = U16; // 2 * SLOTS_PER_EPOCH = 2 * 8 = 16 type PTCSize = U2; + type PtcWindowLength = U24; // (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH type MaxBuildersPerWithdrawalsSweep = U16; params_from_eth_spec!(MainnetEthSpec { @@ -668,6 +676,7 @@ impl EthSpec for GnosisEthSpec { type ProposerLookaheadSlots = U32; // Derived from (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH type BuilderRegistryLimit = U1099511627776; type PTCSize = U512; + type PtcWindowLength = U48; // (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH type MaxPayloadAttestations = U2; type MaxBuildersPerWithdrawalsSweep = U16384; @@ -694,6 +703,11 @@ mod test { E::proposer_lookahead_slots(), (spec.min_seed_lookahead.as_usize() + 1) * E::slots_per_epoch() as usize ); + assert_eq!( + E::ptc_window_length(), + (spec.min_seed_lookahead.as_usize() + 2) * E::slots_per_epoch() as usize, + "PtcWindowLength must equal (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH" + ); } #[test] diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index f431055c5f..a033272b9d 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -667,6 +667,11 @@ where #[superstruct(only(Gloas))] pub payload_expected_withdrawals: List, + #[compare_fields(as_iter)] + #[test_random(default)] + #[superstruct(only(Gloas))] + pub ptc_window: Vector, E::PtcWindowLength>, + // Caching (not in the spec) #[serde(skip_serializing, skip_deserializing)] #[ssz(skip_serializing, skip_deserializing)] @@ -2431,6 +2436,18 @@ impl BeaconState { CommitteeCache::initialized(self, epoch, spec) } + /// Like [`initialize_committee_cache`](Self::initialize_committee_cache), but allows epochs + /// beyond `current_epoch + 1`. Only checks that the required randao seed is available. + /// + /// Used by PTC window computation which needs shufflings for lookahead epochs. + pub fn initialize_committee_cache_for_lookahead( + &self, + epoch: Epoch, + spec: &ChainSpec, + ) -> Result, BeaconStateError> { + CommitteeCache::initialized_for_lookahead(self, epoch, spec) + } + /// Advances the cache for this state into the next epoch. /// /// This should be used if the `slot` of this state is advanced beyond an epoch boundary. @@ -2501,6 +2518,17 @@ impl BeaconState { .ok_or(BeaconStateError::CommitteeCachesOutOfBounds(index)) } + /// Set the committee cache for the given `relative_epoch` to `cache`. + pub fn set_committee_cache( + &mut self, + relative_epoch: RelativeEpoch, + cache: Arc, + ) -> Result<(), BeaconStateError> { + let i = Self::committee_cache_index(relative_epoch); + *self.committee_cache_at_index_mut(i)? = cache; + Ok(()) + } + /// Returns the cache for some `RelativeEpoch`. Returns an error if the cache has not been /// initialized. pub fn committee_cache( @@ -3084,12 +3112,55 @@ impl BeaconState { } } - /// Get the payload timeliness committee for the given `slot`. - /// - /// Requires the committee cache to be initialized. - /// TODO(EIP-7732): definitely gonna have to cache this.. + /// Get the payload timeliness committee for the given `slot` from the `ptc_window`. pub fn get_ptc(&self, slot: Slot, spec: &ChainSpec) -> Result, BeaconStateError> { + let ptc_window = self.ptc_window()?; + let epoch = slot.epoch(E::slots_per_epoch()); + let state_epoch = self.current_epoch(); + let slots_per_epoch = E::slots_per_epoch() as usize; + let slot_in_epoch = slot.as_usize().safe_rem(slots_per_epoch)?; + + let index = if epoch < state_epoch { + if epoch.safe_add(1)? != state_epoch { + return Err(BeaconStateError::SlotOutOfBounds); + } + slot_in_epoch + } else { + if epoch > state_epoch.safe_add(spec.min_seed_lookahead)? { + return Err(BeaconStateError::SlotOutOfBounds); + } + let offset = epoch + .safe_sub(state_epoch)? + .safe_add(1)? + .as_usize() + .safe_mul(slots_per_epoch)?; + offset.safe_add(slot_in_epoch)? + }; + + let entry = ptc_window + .get(index) + .ok_or(BeaconStateError::SlotOutOfBounds)?; + + // Convert from FixedVector to PTC (FixedVector) + let indices: Vec = entry.iter().map(|&v| v as usize).collect(); + Ok(PTC(FixedVector::new(indices)?)) + } + + /// Compute the payload timeliness committee for the given `slot` from scratch. + /// + /// Requires the committee cache to be initialized for the slot's epoch. + pub fn compute_ptc(&self, slot: Slot, spec: &ChainSpec) -> Result, BeaconStateError> { let committee_cache = self.committee_cache_at_slot(slot)?; + self.compute_ptc_with_cache(slot, committee_cache, spec) + } + + /// Compute the PTC for a slot using a specific committee cache. + pub fn compute_ptc_with_cache( + &self, + slot: Slot, + committee_cache: &CommitteeCache, + spec: &ChainSpec, + ) -> Result, BeaconStateError> { let committees = committee_cache.get_beacon_committees_at_slot(slot)?; let seed = self.get_ptc_attester_seed(slot, spec)?; diff --git a/consensus/types/src/state/committee_cache.rs b/consensus/types/src/state/committee_cache.rs index 4a28f3c689..2e74ab760c 100644 --- a/consensus/types/src/state/committee_cache.rs +++ b/consensus/types/src/state/committee_cache.rs @@ -62,6 +62,9 @@ fn compare_shuffling_positions(xs: &Vec, ys: &Vec( state: &BeaconState, @@ -81,12 +84,44 @@ impl CommitteeCache { || epoch > state .current_epoch() - .safe_add(1) + .safe_add(1u64) .map_err(BeaconStateError::ArithError)? { return Err(BeaconStateError::EpochOutOfBounds); } + Self::initialized_unchecked(state, epoch, spec) + } + + /// Return a new, fully initialized cache for a lookahead epoch. + /// + /// Like [`initialized`](Self::initialized), but allows epochs beyond `current_epoch + 1`. + /// The only bound enforced is that the required randao seed is available in the state. + /// + /// This is used by PTC window computation, which needs committee shufflings for + /// `current_epoch + 1 + MIN_SEED_LOOKAHEAD`. + pub fn initialized_for_lookahead( + state: &BeaconState, + epoch: Epoch, + spec: &ChainSpec, + ) -> Result, BeaconStateError> { + let reqd_randao_epoch = epoch + .saturating_sub(spec.min_seed_lookahead) + .saturating_sub(1u64); + + if reqd_randao_epoch < state.min_randao_epoch() { + return Err(BeaconStateError::EpochOutOfBounds); + } + + Self::initialized_unchecked(state, epoch, spec) + } + + /// Core committee cache construction. Callers are responsible for bounds-checking `epoch`. + fn initialized_unchecked( + state: &BeaconState, + epoch: Epoch, + spec: &ChainSpec, + ) -> Result, BeaconStateError> { // May cause divide-by-zero errors. if E::slots_per_epoch() == 0 { return Err(BeaconStateError::ZeroSlotsPerEpoch); diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index 48378a4c95..ab24ea35a0 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.3 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.4 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index dd6be14306..2daafada31 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -53,6 +53,8 @@ excluded_paths = [ "tests/.*/gloas/fork_choice/.*", # Ignore MatrixEntry SSZ tests for now. "tests/.*/.*/ssz_static/MatrixEntry/.*", + # TODO: partial data column not implemented yet + "tests/.*/.*/ssz_static/PartialDataColumn.*/.*", # TODO(gloas): Ignore Gloas light client stuff for now "tests/.*/gloas/ssz_static/LightClient.*/.*", # Execution payload header is irrelevant after Gloas, this type will probably be deleted. @@ -73,7 +75,9 @@ excluded_paths = [ "tests/.*/compute_verify_cell_kzg_proof_batch_challenge/.*", "tests/.*/compute_challenge/.*", # We don't need these manifest files at the moment. - "tests/.*/manifest.yaml" + "tests/.*/manifest.yaml", + # TODO: gossip condition tests not implemented yet + "tests/.*/.*/networking/.*" ] diff --git a/testing/ef_tests/src/cases/epoch_processing.rs b/testing/ef_tests/src/cases/epoch_processing.rs index 7a90fc70d0..a032aa917f 100644 --- a/testing/ef_tests/src/cases/epoch_processing.rs +++ b/testing/ef_tests/src/cases/epoch_processing.rs @@ -12,7 +12,7 @@ use state_processing::per_epoch_processing::effective_balance_updates::{ process_effective_balance_updates, process_effective_balance_updates_slow, }; use state_processing::per_epoch_processing::single_pass::{ - SinglePassConfig, process_epoch_single_pass, process_proposer_lookahead, + SinglePassConfig, process_epoch_single_pass, process_proposer_lookahead, process_ptc_window, }; use state_processing::per_epoch_processing::{ altair, base, @@ -80,6 +80,8 @@ pub struct ParticipationFlagUpdates; #[derive(Debug)] pub struct ProposerLookahead; #[derive(Debug)] +pub struct PtcWindow; +#[derive(Debug)] pub struct BuilderPendingPayments; type_name!( @@ -102,6 +104,7 @@ type_name!(SyncCommitteeUpdates, "sync_committee_updates"); type_name!(InactivityUpdates, "inactivity_updates"); type_name!(ParticipationFlagUpdates, "participation_flag_updates"); type_name!(ProposerLookahead, "proposer_lookahead"); +type_name!(PtcWindow, "ptc_window"); type_name!(BuilderPendingPayments, "builder_pending_payments"); impl EpochTransition for JustificationAndFinalization { @@ -296,6 +299,16 @@ impl EpochTransition for ProposerLookahead { } } +impl EpochTransition for PtcWindow { + fn run(state: &mut BeaconState, spec: &ChainSpec) -> Result<(), EpochProcessingError> { + if state.fork_name_unchecked().gloas_enabled() { + process_ptc_window(state, spec).map(|_| ()) + } else { + Ok(()) + } + } +} + impl EpochTransition for BuilderPendingPayments { fn run(state: &mut BeaconState, spec: &ChainSpec) -> Result<(), EpochProcessingError> { process_epoch_single_pass( @@ -373,7 +386,9 @@ impl> Case for EpochProcessing { return false; } - if !fork_name.gloas_enabled() && T::name() == "builder_pending_payments" { + if !fork_name.gloas_enabled() + && (T::name() == "builder_pending_payments" || T::name() == "ptc_window") + { return false; } diff --git a/testing/ef_tests/src/cases/operations.rs b/testing/ef_tests/src/cases/operations.rs index 798c66b666..1399815763 100644 --- a/testing/ef_tests/src/cases/operations.rs +++ b/testing/ef_tests/src/cases/operations.rs @@ -717,11 +717,7 @@ impl> LoadCase for Operations { // Check BLS setting here before SSZ deserialization, as most types require signatures // to be valid. let operation_path = path.join(O::filename()); - let (operation, bls_error) = if !operation_path.is_file() { - // Some test cases (e.g. builder_voluntary_exit__success) have no operation file. - // TODO(gloas): remove this once the test vectors are fixed - (None, None) - } else if metadata.bls_setting.unwrap_or_default().check().is_ok() { + let (operation, bls_error) = if metadata.bls_setting.unwrap_or_default().check().is_ok() { match O::decode(&operation_path, fork_name, spec) { Ok(op) => (Some(op), None), Err(Error::InvalidBLSInput(error)) => (None, Some(error)), diff --git a/testing/ef_tests/src/lib.rs b/testing/ef_tests/src/lib.rs index 94b19b6644..5587bbed41 100644 --- a/testing/ef_tests/src/lib.rs +++ b/testing/ef_tests/src/lib.rs @@ -3,7 +3,7 @@ pub use cases::{ BuilderPendingPayments, Case, EffectiveBalanceUpdates, Eth1DataReset, ExecutionPayloadBidBlock, FeatureName, HistoricalRootsUpdate, HistoricalSummariesUpdate, InactivityUpdates, JustificationAndFinalization, ParticipationFlagUpdates, ParticipationRecordUpdates, - PendingBalanceDeposits, PendingConsolidations, ProposerLookahead, RandaoMixesReset, + PendingBalanceDeposits, PendingConsolidations, ProposerLookahead, PtcWindow, RandaoMixesReset, RegistryUpdates, RewardsAndPenalties, Slashings, SlashingsReset, SyncCommitteeUpdates, WithdrawalsPayload, }; diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 3893df2ef7..3254bb6e90 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -960,6 +960,12 @@ fn epoch_processing_proposer_lookahead() { EpochProcessingHandler::::default().run(); } +#[test] +fn epoch_processing_ptc_window() { + EpochProcessingHandler::::default().run(); + EpochProcessingHandler::::default().run(); +} + #[test] fn epoch_processing_builder_pending_payments() { EpochProcessingHandler::::default().run(); From 2b224c59f7a71df6fd13803763456351350a1def Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:16:34 +0200 Subject: [PATCH 096/189] Add Gloas SSE event boilerplate (#9053) Implement boilerplate for new SSE events as specified in - https://github.com/ethereum/beacon-APIs/pull/588 While that one is not merged yet, I believe the SSE events might be utilized in Dora already. Implement the boilerplate, i.e. subscription tracking and publish queues. A PR to implement to fully implement already implementable events will follow. Co-Authored-By: Daniel Knopik --- beacon_node/beacon_chain/src/events.rs | 75 +++++++++++++++++++++ beacon_node/http_api/src/lib.rs | 15 +++++ common/eth2/src/types.rs | 92 ++++++++++++++++++++++++++ 3 files changed, 182 insertions(+) diff --git a/beacon_node/beacon_chain/src/events.rs b/beacon_node/beacon_chain/src/events.rs index 276edc3fe6..80667cd399 100644 --- a/beacon_node/beacon_chain/src/events.rs +++ b/beacon_node/beacon_chain/src/events.rs @@ -25,6 +25,11 @@ pub struct ServerSentEventHandler { attester_slashing_tx: Sender>, bls_to_execution_change_tx: Sender>, block_gossip_tx: Sender>, + execution_payload_tx: Sender>, + execution_payload_gossip_tx: Sender>, + execution_payload_available_tx: Sender>, + execution_payload_bid_tx: Sender>, + payload_attestation_message_tx: Sender>, } impl ServerSentEventHandler { @@ -51,6 +56,11 @@ impl ServerSentEventHandler { let (attester_slashing_tx, _) = broadcast::channel(capacity); let (bls_to_execution_change_tx, _) = broadcast::channel(capacity); let (block_gossip_tx, _) = broadcast::channel(capacity); + let (execution_payload_tx, _) = broadcast::channel(capacity); + let (execution_payload_gossip_tx, _) = broadcast::channel(capacity); + let (execution_payload_available_tx, _) = broadcast::channel(capacity); + let (execution_payload_bid_tx, _) = broadcast::channel(capacity); + let (payload_attestation_message_tx, _) = broadcast::channel(capacity); Self { attestation_tx, @@ -71,6 +81,11 @@ impl ServerSentEventHandler { attester_slashing_tx, bls_to_execution_change_tx, block_gossip_tx, + execution_payload_tx, + execution_payload_gossip_tx, + execution_payload_available_tx, + execution_payload_bid_tx, + payload_attestation_message_tx, } } @@ -155,6 +170,26 @@ impl ServerSentEventHandler { .block_gossip_tx .send(kind) .map(|count| log_count("block gossip", count)), + EventKind::ExecutionPayload(_) => self + .execution_payload_tx + .send(kind) + .map(|count| log_count("execution payload", count)), + EventKind::ExecutionPayloadGossip(_) => self + .execution_payload_gossip_tx + .send(kind) + .map(|count| log_count("execution payload gossip", count)), + EventKind::ExecutionPayloadAvailable(_) => self + .execution_payload_available_tx + .send(kind) + .map(|count| log_count("execution payload available", count)), + EventKind::ExecutionPayloadBid(_) => self + .execution_payload_bid_tx + .send(kind) + .map(|count| log_count("execution payload bid", count)), + EventKind::PayloadAttestationMessage(_) => self + .payload_attestation_message_tx + .send(kind) + .map(|count| log_count("payload attestation message", count)), }; if let Err(SendError(event)) = result { trace!(?event, "No receivers registered to listen for event"); @@ -233,6 +268,26 @@ impl ServerSentEventHandler { self.block_gossip_tx.subscribe() } + pub fn subscribe_execution_payload(&self) -> Receiver> { + self.execution_payload_tx.subscribe() + } + + pub fn subscribe_execution_payload_gossip(&self) -> Receiver> { + self.execution_payload_gossip_tx.subscribe() + } + + pub fn subscribe_execution_payload_available(&self) -> Receiver> { + self.execution_payload_available_tx.subscribe() + } + + pub fn subscribe_execution_payload_bid(&self) -> Receiver> { + self.execution_payload_bid_tx.subscribe() + } + + pub fn subscribe_payload_attestation_message(&self) -> Receiver> { + self.payload_attestation_message_tx.subscribe() + } + pub fn has_attestation_subscribers(&self) -> bool { self.attestation_tx.receiver_count() > 0 } @@ -296,4 +351,24 @@ impl ServerSentEventHandler { pub fn has_block_gossip_subscribers(&self) -> bool { self.block_gossip_tx.receiver_count() > 0 } + + pub fn has_execution_payload_subscribers(&self) -> bool { + self.execution_payload_tx.receiver_count() > 0 + } + + pub fn has_execution_payload_gossip_subscribers(&self) -> bool { + self.execution_payload_gossip_tx.receiver_count() > 0 + } + + pub fn has_execution_payload_available_subscribers(&self) -> bool { + self.execution_payload_available_tx.receiver_count() > 0 + } + + pub fn has_execution_payload_bid_subscribers(&self) -> bool { + self.execution_payload_bid_tx.receiver_count() > 0 + } + + pub fn has_payload_attestation_message_subscribers(&self) -> bool { + self.payload_attestation_message_tx.receiver_count() > 0 + } } diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 29e2d39aee..6c0f1e8406 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -3158,6 +3158,21 @@ pub fn serve( api_types::EventTopic::BlockGossip => { event_handler.subscribe_block_gossip() } + api_types::EventTopic::ExecutionPayload => { + event_handler.subscribe_execution_payload() + } + api_types::EventTopic::ExecutionPayloadGossip => { + event_handler.subscribe_execution_payload_gossip() + } + api_types::EventTopic::ExecutionPayloadAvailable => { + event_handler.subscribe_execution_payload_available() + } + api_types::EventTopic::ExecutionPayloadBid => { + event_handler.subscribe_execution_payload_bid() + } + api_types::EventTopic::PayloadAttestationMessage => { + event_handler.subscribe_payload_attestation_message() + } }; receivers.push( diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 94dff95bc6..54e9c98b5b 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1070,6 +1070,33 @@ pub struct BlockGossip { pub slot: Slot, pub block: Hash256, } +#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] +pub struct SseExecutionPayload { + pub slot: Slot, + #[serde(with = "serde_utils::quoted_u64")] + pub builder_index: u64, + pub block_hash: ExecutionBlockHash, + pub block_root: Hash256, + pub state_root: Hash256, + pub execution_optimistic: bool, +} + +#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] +pub struct SseExecutionPayloadGossip { + pub slot: Slot, + #[serde(with = "serde_utils::quoted_u64")] + pub builder_index: u64, + pub block_hash: ExecutionBlockHash, + pub block_root: Hash256, + pub state_root: Hash256, +} + +#[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] +pub struct SseExecutionPayloadAvailable { + pub slot: Slot, + pub block_root: Hash256, +} + #[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] pub struct SseChainReorg { pub slot: Slot, @@ -1134,6 +1161,8 @@ pub struct SseExtendedPayloadAttributesGeneric { pub type SseExtendedPayloadAttributes = SseExtendedPayloadAttributesGeneric; pub type VersionedSsePayloadAttributes = ForkVersionedResponse; +pub type VersionedSseExecutionPayloadBid = ForkVersionedResponse>; +pub type VersionedSsePayloadAttestationMessage = ForkVersionedResponse; impl<'de> ContextDeserialize<'de, ForkName> for SsePayloadAttributes { fn context_deserialize(deserializer: D, context: ForkName) -> Result @@ -1210,6 +1239,11 @@ pub enum EventKind { AttesterSlashing(Box>), BlsToExecutionChange(Box), BlockGossip(Box), + ExecutionPayload(SseExecutionPayload), + ExecutionPayloadGossip(SseExecutionPayloadGossip), + ExecutionPayloadAvailable(SseExecutionPayloadAvailable), + ExecutionPayloadBid(Box>), + PayloadAttestationMessage(Box), } impl EventKind { @@ -1233,6 +1267,11 @@ impl EventKind { EventKind::AttesterSlashing(_) => "attester_slashing", EventKind::BlsToExecutionChange(_) => "bls_to_execution_change", EventKind::BlockGossip(_) => "block_gossip", + EventKind::ExecutionPayload(_) => "execution_payload", + EventKind::ExecutionPayloadGossip(_) => "execution_payload_gossip", + EventKind::ExecutionPayloadAvailable(_) => "execution_payload_available", + EventKind::ExecutionPayloadBid(_) => "execution_payload_bid", + EventKind::PayloadAttestationMessage(_) => "payload_attestation_message", } } @@ -1322,6 +1361,40 @@ impl EventKind { "block_gossip" => Ok(EventKind::BlockGossip(serde_json::from_str(data).map_err( |e| ServerError::InvalidServerSentEvent(format!("Block Gossip: {:?}", e)), )?)), + "execution_payload" => Ok(EventKind::ExecutionPayload( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!("Execution Payload: {:?}", e)) + })?, + )), + "execution_payload_gossip" => Ok(EventKind::ExecutionPayloadGossip( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!( + "Execution Payload Gossip: {:?}", + e + )) + })?, + )), + "execution_payload_available" => Ok(EventKind::ExecutionPayloadAvailable( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!( + "Execution Payload Available: {:?}", + e + )) + })?, + )), + "execution_payload_bid" => Ok(EventKind::ExecutionPayloadBid(Box::new( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!("Execution Payload Bid: {:?}", e)) + })?, + ))), + "payload_attestation_message" => Ok(EventKind::PayloadAttestationMessage(Box::new( + serde_json::from_str(data).map_err(|e| { + ServerError::InvalidServerSentEvent(format!( + "Payload Attestation Message: {:?}", + e + )) + })?, + ))), _ => Err(ServerError::InvalidServerSentEvent( "Could not parse event tag".to_string(), )), @@ -1357,6 +1430,11 @@ pub enum EventTopic { ProposerSlashing, BlsToExecutionChange, BlockGossip, + ExecutionPayload, + ExecutionPayloadGossip, + ExecutionPayloadAvailable, + ExecutionPayloadBid, + PayloadAttestationMessage, } impl FromStr for EventTopic { @@ -1382,6 +1460,11 @@ impl FromStr for EventTopic { "proposer_slashing" => Ok(EventTopic::ProposerSlashing), "bls_to_execution_change" => Ok(EventTopic::BlsToExecutionChange), "block_gossip" => Ok(EventTopic::BlockGossip), + "execution_payload" => Ok(EventTopic::ExecutionPayload), + "execution_payload_gossip" => Ok(EventTopic::ExecutionPayloadGossip), + "execution_payload_available" => Ok(EventTopic::ExecutionPayloadAvailable), + "execution_payload_bid" => Ok(EventTopic::ExecutionPayloadBid), + "payload_attestation_message" => Ok(EventTopic::PayloadAttestationMessage), _ => Err("event topic cannot be parsed.".to_string()), } } @@ -1408,6 +1491,15 @@ impl fmt::Display for EventTopic { EventTopic::ProposerSlashing => write!(f, "proposer_slashing"), EventTopic::BlsToExecutionChange => write!(f, "bls_to_execution_change"), EventTopic::BlockGossip => write!(f, "block_gossip"), + EventTopic::ExecutionPayload => write!(f, "execution_payload"), + EventTopic::ExecutionPayloadGossip => write!(f, "execution_payload_gossip"), + EventTopic::ExecutionPayloadAvailable => { + write!(f, "execution_payload_available") + } + EventTopic::ExecutionPayloadBid => write!(f, "execution_payload_bid"), + EventTopic::PayloadAttestationMessage => { + write!(f, "payload_attestation_message") + } } } } From f6f37652a8d3ad49839af2e138de340bcad25644 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Tue, 31 Mar 2026 19:44:12 +0900 Subject: [PATCH 097/189] Gloas get payload envelope beacon API (#9038) Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Mac L --- .../src/beacon/execution_payload_envelope.rs | 84 ++++++++++++++++++- beacon_node/http_api/src/lib.rs | 12 ++- common/eth2/src/lib.rs | 49 +++++++++++ 3 files changed, 141 insertions(+), 4 deletions(-) diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs index 81f2ea41ea..4a974c9919 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs @@ -1,16 +1,25 @@ +use crate::block_id::BlockId; use crate::task_spawner::{Priority, TaskSpawner}; use crate::utils::{ChainFilter, EthV1Filter, NetworkTxFilter, ResponseFilter, TaskSpawnerFilter}; +use crate::version::{ + ResponseIncludesVersion, add_consensus_version_header, add_ssz_content_type_header, + execution_optimistic_finalized_beacon_response, +}; use beacon_chain::{BeaconChain, BeaconChainTypes}; use bytes::Bytes; +use eth2::types as api_types; use eth2::{CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; use lighthouse_network::PubsubMessage; use network::NetworkMessage; -use ssz::Decode; +use ssz::{Decode, Encode}; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; use tracing::{info, warn}; use types::SignedExecutionPayloadEnvelope; -use warp::{Filter, Rejection, Reply, reply::Response}; +use warp::{ + Filter, Rejection, Reply, + hyper::{Body, Response}, +}; // POST beacon/execution_payload_envelope (SSZ) pub(crate) fn post_beacon_execution_payload_envelope_ssz( @@ -81,7 +90,7 @@ pub async fn publish_execution_payload_envelope( envelope: SignedExecutionPayloadEnvelope, chain: Arc>, network_tx: &UnboundedSender>, -) -> Result { +) -> Result, Rejection> { let slot = envelope.message.slot; let beacon_block_root = envelope.message.beacon_block_root; @@ -114,3 +123,72 @@ pub async fn publish_execution_payload_envelope( Ok(warp::reply().into_response()) } + +// TODO(gloas): add tests for this endpoint once we support importing payloads into the db +// GET beacon/execution_payload_envelope/{block_id} +pub(crate) fn get_beacon_execution_payload_envelope( + eth_v1: EthV1Filter, + block_id_or_err: impl Filter + + Clone + + Send + + Sync + + 'static, + task_spawner_filter: TaskSpawnerFilter, + chain_filter: ChainFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("beacon")) + .and(warp::path("execution_payload_envelope")) + .and(block_id_or_err) + .and(warp::path::end()) + .and(task_spawner_filter) + .and(chain_filter) + .and(warp::header::optional::("accept")) + .then( + |block_id: BlockId, + task_spawner: TaskSpawner, + chain: Arc>, + accept_header: Option| { + task_spawner.blocking_response_task(Priority::P1, move || { + let (root, execution_optimistic, finalized) = block_id.root(&chain)?; + + let envelope = chain + .get_payload_envelope(&root) + .map_err(warp_utils::reject::unhandled_error)? + .ok_or_else(|| { + warp_utils::reject::custom_not_found(format!( + "execution payload envelope for block root {root}" + )) + })?; + + let fork_name = chain + .spec + .fork_name_at_slot::(envelope.message.slot); + + match accept_header { + Some(api_types::Accept::Ssz) => Response::builder() + .status(200) + .body(envelope.as_ssz_bytes().into()) + .map(|res: Response| add_ssz_content_type_header(res)) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "failed to create response: {}", + e + )) + }), + _ => { + let res = execution_optimistic_finalized_beacon_response( + ResponseIncludesVersion::Yes(fork_name), + execution_optimistic, + finalized, + &envelope, + )?; + Ok(warp::reply::json(&res).into_response()) + } + } + .map(|resp| add_consensus_version_header(resp, fork_name)) + }) + }, + ) + .boxed() +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 6c0f1e8406..17d41cfbcd 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -35,7 +35,8 @@ mod validators; mod version; use crate::beacon::execution_payload_envelope::{ - post_beacon_execution_payload_envelope, post_beacon_execution_payload_envelope_ssz, + get_beacon_execution_payload_envelope, post_beacon_execution_payload_envelope, + post_beacon_execution_payload_envelope_ssz, }; use crate::beacon::pool::*; use crate::light_client::{get_light_client_bootstrap, get_light_client_updates}; @@ -1509,6 +1510,14 @@ pub fn serve( network_tx_filter.clone(), ); + // GET beacon/execution_payload_envelope/{block_id} + let get_beacon_execution_payload_envelope = get_beacon_execution_payload_envelope( + eth_v1.clone(), + block_id_or_err, + task_spawner_filter.clone(), + chain_filter.clone(), + ); + let beacon_rewards_path = eth_v1 .clone() .and(warp::path("beacon")) @@ -3298,6 +3307,7 @@ pub fn serve( .uor(get_beacon_block_root) .uor(get_blob_sidecars) .uor(get_blobs) + .uor(get_beacon_execution_payload_envelope) .uor(get_beacon_pool_attestations) .uor(get_beacon_pool_attester_slashings) .uor(get_beacon_pool_proposer_slashings) diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 40c5ef58a6..d5140a3878 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -2732,6 +2732,55 @@ impl BeaconNodeHttpClient { Ok(()) } + /// Path for `v1/beacon/execution_payload_envelope/{block_id}` + pub fn get_beacon_execution_payload_envelope_path( + &self, + block_id: BlockId, + ) -> Result { + let mut path = self.eth_path(V1)?; + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("execution_payload_envelope") + .push(&block_id.to_string()); + Ok(path) + } + + /// `GET v1/beacon/execution_payload_envelope/{block_id}` + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_execution_payload_envelope( + &self, + block_id: BlockId, + ) -> Result< + Option>>, + Error, + > { + let path = self.get_beacon_execution_payload_envelope_path(block_id)?; + self.get_opt(path) + .await + .map(|opt| opt.map(BeaconResponse::ForkVersioned)) + } + + /// `GET v1/beacon/execution_payload_envelope/{block_id}` in SSZ format + /// + /// Returns `Ok(None)` on a 404 error. + pub async fn get_beacon_execution_payload_envelope_ssz( + &self, + block_id: BlockId, + ) -> Result>, Error> { + let path = self.get_beacon_execution_payload_envelope_path(block_id)?; + let opt_response = self + .get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.get_beacon_blocks_ssz) + .await?; + match opt_response { + Some(bytes) => SignedExecutionPayloadEnvelope::from_ssz_bytes(&bytes) + .map(Some) + .map_err(Error::InvalidSsz), + None => Ok(None), + } + } + /// `GET v2/validator/blocks/{slot}` in ssz format pub async fn get_validator_blocks_ssz( &self, From 37a948cf32cba283e35425be03ec9f3e04832191 Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:54:10 +0200 Subject: [PATCH 098/189] Never use MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS for networks that started with Fulu enabled (#8731) Lighthouse uses `MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS` for blob **and column retention** instead of `MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUESTS` if Fulu activated less than `MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUESTS` epochs ago - also if Fulu activated at genesis. This causes unexpected behaviour, as there are no blob sidecars to be stored or requested in such networks. ~~Add a special case to avoid that logic in post-Fulu genesis networks (`fulu_fork_epoch == 0`)~~ If the blob retention period would start in the fulu fork epoch, use the `min_epochs_for_data_column_sidecars_requests`, as there are no blobs to retain in Fulu. Co-Authored-By: Daniel Knopik --- consensus/types/src/core/chain_spec.rs | 63 ++++++++++++++++++++------ 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index 01c4c7bbfd..cc79d3fc29 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -828,15 +828,17 @@ impl ChainSpec { /// Returns the min epoch for blob / data column sidecar requests based on the current epoch. /// Switch to use the column sidecar config once the `blob_retention_epoch` has passed Fulu fork epoch. + /// Never uses the `blob_retention_epoch` for networks that started with Fulu enabled. pub fn min_epoch_data_availability_boundary(&self, current_epoch: Epoch) -> Option { - let fork_epoch = self.deneb_fork_epoch?; + let deneb_fork_epoch = self.deneb_fork_epoch?; let blob_retention_epoch = current_epoch.saturating_sub(self.min_epochs_for_blob_sidecars_requests); - match self.fulu_fork_epoch { - Some(fulu_fork_epoch) if blob_retention_epoch > fulu_fork_epoch => Some( - current_epoch.saturating_sub(self.min_epochs_for_data_column_sidecars_requests), - ), - _ => Some(std::cmp::max(fork_epoch, blob_retention_epoch)), + if let Some(fulu_fork_epoch) = self.fulu_fork_epoch + && blob_retention_epoch >= fulu_fork_epoch + { + Some(current_epoch.saturating_sub(self.min_epochs_for_data_column_sidecars_requests)) + } else { + Some(std::cmp::max(deneb_fork_epoch, blob_retention_epoch)) } } @@ -3398,17 +3400,19 @@ mod yaml_tests { spec.min_epoch_data_availability_boundary(fulu_fork_epoch) ); - // `min_epochs_for_data_sidecar_requests` at fulu fork epoch + min_epochs_for_blob_sidecars_request - let blob_retention_epoch_after_fulu = fulu_fork_epoch + blob_retention_epochs; - let expected_blob_retention_epoch = blob_retention_epoch_after_fulu - blob_retention_epochs; + // Now, the blob retention period starts still before the fulu fork epoch, so the boundary + // should respect the blob retention period. + let half_blob_retention_epoch_after_fulu = fulu_fork_epoch + (blob_retention_epochs / 2); + let expected_blob_retention_epoch = + half_blob_retention_epoch_after_fulu - blob_retention_epochs; assert_eq!( Some(expected_blob_retention_epoch), - spec.min_epoch_data_availability_boundary(blob_retention_epoch_after_fulu) + spec.min_epoch_data_availability_boundary(half_blob_retention_epoch_after_fulu) ); - // After the final blob retention epoch, `min_epochs_for_data_sidecar_requests` should be calculated - // using `min_epochs_for_data_column_sidecars_request` - let current_epoch = blob_retention_epoch_after_fulu + 1; + // If the retention period starts with the fulu fork epoch, there are no more blobs to + // retain, and the return value will be based on the data column retention period. + let current_epoch = fulu_fork_epoch + blob_retention_epochs; let expected_data_column_retention_epoch = current_epoch - data_column_retention_epochs; assert_eq!( Some(expected_data_column_retention_epoch), @@ -3416,6 +3420,39 @@ mod yaml_tests { ); } + #[test] + fn min_epochs_for_data_sidecar_requests_fulu_genesis() { + type E = MainnetEthSpec; + let spec = { + // fulu active at genesis + let mut spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + // set a different value for testing purpose, 4096 / 2 = 2048 + spec.min_epochs_for_data_column_sidecars_requests = + spec.min_epochs_for_blob_sidecars_requests / 2; + Arc::new(spec) + }; + let blob_retention_epochs = spec.min_epochs_for_blob_sidecars_requests; + let data_column_retention_epochs = spec.min_epochs_for_data_column_sidecars_requests; + + // If Fulu is activated at genesis, the column retention period should always be used. + let assert_correct_boundary = |epoch| { + let epoch = Epoch::new(epoch); + assert_eq!( + Some(epoch.saturating_sub(data_column_retention_epochs)), + spec.min_epoch_data_availability_boundary(epoch) + ) + }; + + assert_correct_boundary(0); + assert_correct_boundary(1); + assert_correct_boundary(blob_retention_epochs - 1); + assert_correct_boundary(blob_retention_epochs); + assert_correct_boundary(blob_retention_epochs + 1); + assert_correct_boundary(data_column_retention_epochs - 1); + assert_correct_boundary(data_column_retention_epochs); + assert_correct_boundary(data_column_retention_epochs + 1); + } + #[test] fn proposer_shuffling_decision_root_around_epoch_boundary() { type E = MainnetEthSpec; From 037b263f17c9cddaed53505b70a13c9a8e30c683 Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:16:40 +0200 Subject: [PATCH 099/189] Emit SSE: `execution_payload_gossip` (#9063) Emit `execution_payload_gossip` on successful gossip verification of an execution payload. This is done as last step inside the verification function. Co-Authored-By: Daniel Knopik --- .../gossip_verified_envelope.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs index 03a3a91ac5..9a4ed2d044 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use educe::Educe; +use eth2::types::{EventKind, SseExecutionPayloadGossip}; use parking_lot::{Mutex, RwLock}; use store::DatabaseBlock; use tracing::{Span, debug}; @@ -10,7 +11,7 @@ use types::{ }; use crate::{ - BeaconChain, BeaconChainError, BeaconChainTypes, BeaconStore, + BeaconChain, BeaconChainError, BeaconChainTypes, BeaconStore, ServerSentEventHandler, beacon_proposer_cache::{self, BeaconProposerCache}, canonical_head::CanonicalHead, payload_envelope_verification::{ @@ -28,6 +29,7 @@ pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { pub beacon_proposer_cache: &'a Mutex, pub validator_pubkey_cache: &'a RwLock>, pub genesis_validators_root: Hash256, + pub event_handler: &'a Option>, } /// Verify that an execution payload envelope is consistent with its beacon block @@ -213,6 +215,20 @@ impl GossipVerifiedEnvelope { return Err(EnvelopeError::BadSignature); } + if let Some(event_handler) = ctx.event_handler.as_ref() + && event_handler.has_execution_payload_gossip_subscribers() + { + event_handler.register(EventKind::ExecutionPayloadGossip( + SseExecutionPayloadGossip { + slot: block.slot(), + builder_index, + block_hash: signed_envelope.message.payload.block_hash, + block_root: beacon_block_root, + state_root: signed_envelope.message.state_root, + }, + )); + } + Ok(Self { signed_envelope, block, @@ -235,6 +251,7 @@ impl BeaconChain { beacon_proposer_cache: &self.beacon_proposer_cache, validator_pubkey_cache: &self.validator_pubkey_cache, genesis_validators_root: self.genesis_validators_root, + event_handler: &self.event_handler, } } From 62c016660fcdb9089064fd14ae48a1a61c5169db Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:58:49 +0200 Subject: [PATCH 100/189] Emit SSE: `execution_payload` (#9065) Emit `execution_payload` on successful import of an execution payload. Co-Authored-By: Daniel Knopik --- .../payload_envelope_verification/import.rs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs index 2ee315e559..bae848c3c1 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -1,11 +1,12 @@ use std::sync::Arc; use std::time::Duration; +use eth2::types::{EventKind, SseExecutionPayload}; use fork_choice::PayloadVerificationStatus; use slot_clock::SlotClock; use store::StoreOp; use tracing::{debug, error, info, info_span, instrument, warn}; -use types::{BeaconState, BlockImportSource, Hash256, Slot}; +use types::{BeaconState, BlockImportSource, Hash256, SignedExecutionPayloadEnvelope}; use super::{ AvailableEnvelope, AvailableExecutedEnvelope, EnvelopeError, EnvelopeImportData, @@ -225,7 +226,7 @@ impl BeaconChain { signed_envelope: AvailableEnvelope, block_root: Hash256, state: BeaconState, - _payload_verification_status: PayloadVerificationStatus, + payload_verification_status: PayloadVerificationStatus, ) -> Result { // Everything in this initial section is on the hot path for processing the envelope. // Take an upgradable read lock on fork choice so we can check if this block has already @@ -317,8 +318,9 @@ impl BeaconChain { metrics::stop_timer(db_write_timer); self.import_envelope_update_metrics_and_events( + signed_envelope, block_root, - signed_envelope.slot(), + payload_verification_status, envelope_time_imported, ); @@ -327,10 +329,12 @@ impl BeaconChain { fn import_envelope_update_metrics_and_events( &self, + signed_envelope: Arc>, block_root: Hash256, - envelope_slot: Slot, + payload_verification_status: PayloadVerificationStatus, envelope_time_imported: Duration, ) { + let envelope_slot = signed_envelope.slot(); let envelope_delay_total = get_slot_delay_ms(envelope_time_imported, envelope_slot, &self.slot_clock); @@ -349,6 +353,17 @@ impl BeaconChain { ); } - // TODO(gloas) emit SSE event for envelope import (similar to SseBlock for blocks). + if let Some(event_handler) = self.event_handler.as_ref() + && event_handler.has_execution_payload_subscribers() + { + event_handler.register(EventKind::ExecutionPayload(SseExecutionPayload { + slot: envelope_slot, + builder_index: signed_envelope.message.builder_index, + block_hash: signed_envelope.block_hash(), + block_root, + state_root: signed_envelope.message.state_root, + execution_optimistic: payload_verification_status.is_optimistic(), + })); + } } } From 03385d698db96b9dc7fe037454fd78c47a19c302 Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:58:52 +0800 Subject: [PATCH 101/189] Update `blob_delay_ms` to track data columns seen (#9024) * #7477 Use the last seen data column as the time for `blob_delay_ms`, the metric name remains unchanged Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Tan Chee Keong Co-Authored-By: Tan Chee Keong --- beacon_node/beacon_chain/src/beacon_chain.rs | 4 +- .../beacon_chain/src/canonical_head.rs | 4 +- .../overflow_lru_cache.rs | 7 ++- .../src/data_column_verification.rs | 51 +++++++++++++++---- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 81735bdd9d..69db0c24fb 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -3801,7 +3801,7 @@ impl BeaconChain { consensus_context, } = import_data; - // Record the time at which this block's blobs became available. + // Record the time at which this block's blobs/data columns became available. if let Some(blobs_available) = block.blobs_available_timestamp() { self.block_times_cache.write().set_time_blob_observed( block_root, @@ -3810,8 +3810,6 @@ impl BeaconChain { ); } - // TODO(das) record custody column available timestamp - let block_root = { // Capture the current span before moving into the blocking task let current_span = tracing::Span::current(); diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 3a429bdb8a..9dd7d62a27 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -1379,8 +1379,8 @@ fn observe_head_block_delays( .as_millis() as i64, ); - // The time from the start of the slot when all blobs have been observed. Technically this - // is the time we last saw a blob related to this block/slot. + // The time from the start of the slot when all blobs/data columns have been observed. Technically this + // is the time we last saw a blob/data column related to this block/slot. metrics::set_gauge( &metrics::BEACON_BLOB_DELAY_ALL_OBSERVED_SLOT_START, block_delays diff --git a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs index c0403595ee..8f1d4464e1 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs @@ -282,8 +282,11 @@ impl PendingComponents { .flatten() .map(|blob| blob.seen_timestamp()) .max(), - // TODO(das): To be fixed with https://github.com/sigp/lighthouse/pull/6850 - AvailableBlockData::DataColumns(_) => None, + AvailableBlockData::DataColumns(_) => self + .verified_data_columns + .iter() + .map(|data_column| data_column.seen_timestamp()) + .max(), }; let AvailabilityPendingExecutedBlock { diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index dde9fad342..f47de01ddc 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -5,6 +5,7 @@ use crate::kzg_utils::{reconstruct_data_columns, validate_data_columns}; use crate::observed_data_sidecars::{ Error as ObservedDataSidecarsError, ObservationKey, ObservationStrategy, Observe, }; +use crate::validator_monitor::timestamp_now; use crate::{BeaconChain, BeaconChainError, BeaconChainTypes, metrics}; use educe::Educe; use fork_choice::ProtoBlock; @@ -16,6 +17,7 @@ use ssz_types::VariableList; use std::iter; use std::marker::PhantomData; use std::sync::Arc; +use std::time::Duration; use tracing::{debug, instrument}; use types::data::ColumnIndex; use types::{ @@ -320,25 +322,34 @@ impl GossipVerifiedDataColumn #[ssz(struct_behaviour = "transparent")] pub struct KzgVerifiedDataColumn { data: Arc>, + #[ssz(skip_serializing, skip_deserializing)] + seen_timestamp: Duration, } impl KzgVerifiedDataColumn { pub fn new( data_column: Arc>, kzg: &Kzg, + seen_timestamp: Duration, ) -> Result, KzgError)> { - verify_kzg_for_data_column(data_column, kzg) + verify_kzg_for_data_column(data_column, kzg, seen_timestamp) } /// Mark a data column as KZG verified. Caller must ONLY use this on columns constructed /// from EL blobs. pub fn from_execution_verified(data_column: Arc>) -> Self { - Self { data: data_column } + Self { + data: data_column, + seen_timestamp: timestamp_now(), + } } /// Create a `KzgVerifiedDataColumn` from `DataColumnSidecar` for testing ONLY. pub(crate) fn __new_for_testing(data_column: Arc>) -> Self { - Self { data: data_column } + Self { + data: data_column, + seen_timestamp: timestamp_now(), + } } pub fn from_batch_with_scoring( @@ -348,7 +359,10 @@ impl KzgVerifiedDataColumn { verify_kzg_for_data_column_list(data_columns.iter(), kzg)?; Ok(data_columns .into_iter() - .map(|column| Self { data: column }) + .map(|column| Self { + data: column, + seen_timestamp: timestamp_now(), + }) .collect()) } @@ -407,6 +421,8 @@ impl CustodyDataColumn { #[ssz(struct_behaviour = "transparent")] pub struct KzgVerifiedCustodyDataColumn { data: Arc>, + #[ssz(skip_serializing, skip_deserializing)] + seen_timestamp: Duration, } impl KzgVerifiedCustodyDataColumn { @@ -414,6 +430,7 @@ impl KzgVerifiedCustodyDataColumn { /// include this column pub fn from_asserted_custody(kzg_verified: KzgVerifiedDataColumn) -> Self { Self { + seen_timestamp: kzg_verified.seen_timestamp, data: kzg_verified.to_data_column(), } } @@ -422,10 +439,12 @@ impl KzgVerifiedCustodyDataColumn { pub fn new( data_column: CustodyDataColumn, kzg: &Kzg, + seen_timestamp: Duration, ) -> Result, KzgError)> { - verify_kzg_for_data_column(data_column.clone_arc(), kzg)?; + verify_kzg_for_data_column(data_column.clone_arc(), kzg, seen_timestamp)?; Ok(Self { data: data_column.data, + seen_timestamp, }) } @@ -443,10 +462,15 @@ impl KzgVerifiedCustodyDataColumn { spec, )?; + let seen_timestamp = timestamp_now(); + Ok(all_data_columns .into_iter() .map(|data| { - KzgVerifiedCustodyDataColumn::from_asserted_custody(KzgVerifiedDataColumn { data }) + KzgVerifiedCustodyDataColumn::from_asserted_custody(KzgVerifiedDataColumn { + data, + seen_timestamp, + }) }) .collect::>()) } @@ -464,6 +488,10 @@ impl KzgVerifiedCustodyDataColumn { pub fn index(&self) -> ColumnIndex { *self.data.index() } + + pub fn seen_timestamp(&self) -> Duration { + self.seen_timestamp + } } /// Complete kzg verification for a `DataColumnSidecar`. @@ -473,10 +501,14 @@ impl KzgVerifiedCustodyDataColumn { pub fn verify_kzg_for_data_column( data_column: Arc>, kzg: &Kzg, + seen_timestamp: Duration, ) -> Result, (Option, KzgError)> { let _timer = metrics::start_timer(&metrics::KZG_VERIFICATION_DATA_COLUMN_SINGLE_TIMES); validate_data_columns(kzg, iter::once(&data_column))?; - Ok(KzgVerifiedDataColumn { data: data_column }) + Ok(KzgVerifiedDataColumn { + data: data_column, + seen_timestamp, + }) } /// Complete kzg verification for a list of `DataColumnSidecar`s. @@ -538,8 +570,9 @@ pub fn validate_data_column_sidecar_for_gossip_fulu Date: Wed, 1 Apr 2026 11:13:20 +0900 Subject: [PATCH 102/189] Automatically pass spans into blocking handles (#8158) Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Jimmy Chen --- beacon_node/beacon_chain/src/beacon_chain.rs | 46 +++---------------- .../src/block_production/gloas.rs | 17 ++----- .../beacon_chain/src/block_production/mod.rs | 1 + .../beacon_chain/src/canonical_head.rs | 12 +---- .../beacon_chain/src/fetch_blobs/mod.rs | 4 +- .../gossip_verified_envelope.rs | 4 +- .../payload_envelope_verification/import.rs | 4 -- beacon_node/http_api/src/publish_blocks.rs | 11 ++--- common/task_executor/src/lib.rs | 12 ++++- 9 files changed, 29 insertions(+), 82 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 69db0c24fb..310163b4a9 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -130,7 +130,7 @@ use store::{ }; use task_executor::{RayonPoolType, ShutdownReason, TaskExecutor}; use tokio_stream::Stream; -use tracing::{Span, debug, debug_span, error, info, info_span, instrument, trace, warn}; +use tracing::{debug, debug_span, error, info, info_span, instrument, trace, warn}; use tree_hash::TreeHash; use types::data::{ColumnIndex, FixedBlobSidecarList}; use types::execution::BlockProductionVersion; @@ -2761,6 +2761,7 @@ impl BeaconChain { /// or already-known). /// /// This method is potentially long-running and should not run on the core executor. + #[instrument(skip_all, level = "debug")] pub fn filter_chain_segment( self: &Arc, chain_segment: Vec>, @@ -2888,12 +2889,8 @@ impl BeaconChain { // Filter uninteresting blocks from the chain segment in a blocking task. let chain = self.clone(); - let filter_chain_segment = debug_span!("filter_chain_segment"); let filtered_chain_segment_future = self.spawn_blocking_handle( - move || { - let _guard = filter_chain_segment.enter(); - chain.filter_chain_segment(chain_segment) - }, + move || chain.filter_chain_segment(chain_segment), "filter_chain_segment", ); let mut filtered_chain_segment = match filtered_chain_segment_future.await { @@ -2924,12 +2921,8 @@ impl BeaconChain { std::mem::swap(&mut blocks, &mut filtered_chain_segment); let chain = self.clone(); - let current_span = Span::current(); let signature_verification_future = self.spawn_blocking_handle( - move || { - let _guard = current_span.enter(); - signature_verify_chain_segment(blocks, &chain) - }, + move || signature_verify_chain_segment(blocks, &chain), "signature_verify_chain_segment", ); @@ -3019,12 +3012,10 @@ impl BeaconChain { block: Arc>, ) -> Result, BlockError> { let chain = self.clone(); - let span = Span::current(); self.task_executor .clone() .spawn_blocking_handle( move || { - let _guard = span.enter(); let slot = block.slot(); let graffiti_string = block.message().body().graffiti().as_utf8_lossy(); @@ -3332,11 +3323,9 @@ impl BeaconChain { let data_availability_checker = self.data_availability_checker.clone(); - let current_span = Span::current(); let result = self .task_executor .spawn_blocking_with_rayon_async(RayonPoolType::HighPriority, move || { - let _guard = current_span.enter(); data_availability_checker.reconstruct_data_columns(&block_root) }) .await @@ -3811,13 +3800,9 @@ impl BeaconChain { } let block_root = { - // Capture the current span before moving into the blocking task - let current_span = tracing::Span::current(); let chain = self.clone(); self.spawn_blocking_handle( move || { - // Enter the captured span in the blocking thread - let _guard = current_span.enter(); chain.import_block( block, block_root, @@ -4528,15 +4513,10 @@ impl BeaconChain { // // Load the parent state from disk. let chain = self.clone(); - let span = Span::current(); let (state, state_root_opt) = self .task_executor .spawn_blocking_handle( - move || { - let _guard = - debug_span!(parent: span, "load_state_for_block_production").entered(); - chain.load_state_for_block_production(slot) - }, + move || chain.load_state_for_block_production(slot), "load_state_for_block_production", ) .ok_or(BlockProductionError::ShuttingDown)? @@ -4960,13 +4940,10 @@ impl BeaconChain { .graffiti_calculator .get_graffiti(graffiti_settings) .await; - let span = Span::current(); let mut partial_beacon_block = self .task_executor .spawn_blocking_handle( move || { - let _guard = - debug_span!(parent: span, "produce_partial_beacon_block").entered(); chain.produce_partial_beacon_block( state, state_root_opt, @@ -5002,14 +4979,10 @@ impl BeaconChain { match block_contents_type { BlockProposalContentsType::Full(block_contents) => { let chain = self.clone(); - let span = Span::current(); let beacon_block_response = self .task_executor .spawn_blocking_handle( move || { - let _guard = - debug_span!(parent: span, "complete_partial_beacon_block") - .entered(); chain.complete_partial_beacon_block( partial_beacon_block, Some(block_contents), @@ -5026,14 +4999,10 @@ impl BeaconChain { } BlockProposalContentsType::Blinded(block_contents) => { let chain = self.clone(); - let span = Span::current(); let beacon_block_response = self .task_executor .spawn_blocking_handle( move || { - let _guard = - debug_span!(parent: span, "complete_partial_beacon_block") - .entered(); chain.complete_partial_beacon_block( partial_beacon_block, Some(block_contents), @@ -5051,13 +5020,10 @@ impl BeaconChain { } } else { let chain = self.clone(); - let span = Span::current(); let beacon_block_response = self .task_executor .spawn_blocking_handle( move || { - let _guard = - debug_span!(parent: span, "complete_partial_beacon_block").entered(); chain.complete_partial_beacon_block( partial_beacon_block, None, @@ -5075,6 +5041,7 @@ impl BeaconChain { } #[allow(clippy::too_many_arguments)] + #[instrument(skip_all, level = "debug")] fn produce_partial_beacon_block( self: &Arc, mut state: BeaconState, @@ -5319,6 +5286,7 @@ impl BeaconChain { }) } + #[instrument(skip_all, level = "debug")] fn complete_partial_beacon_block>( &self, partial_beacon_block: PartialBeaconBlock, diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 2fc4fb51f7..51caf63b7a 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -19,7 +19,7 @@ use state_processing::{ }; use state_processing::{VerifyOperation, state_advance::complete_state_advance}; use task_executor::JoinHandle; -use tracing::{Instrument, Span, debug, debug_span, error, instrument, trace, warn}; +use tracing::{Instrument, debug, debug_span, error, instrument, trace, warn}; use tree_hash::TreeHash; use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{ @@ -87,15 +87,10 @@ impl BeaconChain { // // Load the parent state from disk. let chain = self.clone(); - let span = Span::current(); let (state, state_root_opt) = self .task_executor .spawn_blocking_handle( - move || { - let _guard = - debug_span!(parent: span, "load_state_for_block_production").entered(); - chain.load_state_for_block_production(slot) - }, + move || chain.load_state_for_block_production(slot), "load_state_for_block_production", ) .ok_or(BlockProductionError::ShuttingDown)? @@ -135,13 +130,10 @@ impl BeaconChain { .graffiti_calculator .get_graffiti(graffiti_settings) .await; - let span = Span::current(); let (partial_beacon_block, state) = self .task_executor .spawn_blocking_handle( move || { - let _guard = - debug_span!(parent: span, "produce_partial_beacon_block_gloas").entered(); chain.produce_partial_beacon_block_gloas( state, state_root_opt, @@ -175,12 +167,9 @@ impl BeaconChain { // // Complete the block with the execution payload bid. let chain = self.clone(); - let span = Span::current(); self.task_executor .spawn_blocking_handle( move || { - let _guard = - debug_span!(parent: span, "complete_partial_beacon_block_gloas").entered(); chain.complete_partial_beacon_block_gloas( partial_beacon_block, execution_payload_bid, @@ -198,6 +187,7 @@ impl BeaconChain { #[allow(clippy::too_many_arguments)] #[allow(clippy::type_complexity)] + #[instrument(skip_all, level = "debug")] fn produce_partial_beacon_block_gloas( self: &Arc, mut state: BeaconState, @@ -432,6 +422,7 @@ impl BeaconChain { /// - `pending_state` is the state post block application (prior to payload application) /// - `block_value` is the consensus-layer rewards for `block` #[allow(clippy::type_complexity)] + #[instrument(skip_all, level = "debug")] fn complete_partial_beacon_block_gloas( &self, partial_beacon_block: PartialBeaconBlock, diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index b33323f527..256b67086a 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -15,6 +15,7 @@ mod gloas; impl BeaconChain { /// Load a beacon state from the database for block production. This is a long-running process /// that should not be performed in an `async` context. + #[instrument(skip_all, level = "debug")] pub(crate) fn load_state_for_block_production( self: &Arc, slot: Slot, diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 9dd7d62a27..f6377e6ea5 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -58,7 +58,6 @@ use store::{ Error as StoreError, KeyValueStore, KeyValueStoreOp, StoreConfig, iter::StateRootsIterator, }; use task_executor::{JoinHandle, ShutdownReason}; -use tracing::info_span; use tracing::{debug, error, info, instrument, warn}; use types::*; @@ -528,22 +527,15 @@ impl BeaconChain { /// such a case it's critical that the `BeaconChain` keeps importing blocks so that the /// situation can be rectified. We avoid returning an error here so that calling functions /// can't abort block import because an error is returned here. + #[instrument(name = "lh_recompute_head_at_slot", skip(self), level = "info", fields(slot = %current_slot))] pub async fn recompute_head_at_slot(self: &Arc, current_slot: Slot) { - let span = info_span!( - "lh_recompute_head_at_slot", - slot = %current_slot - ); - metrics::inc_counter(&metrics::FORK_CHOICE_REQUESTS); let _timer = metrics::start_timer(&metrics::FORK_CHOICE_TIMES); let chain = self.clone(); match self .spawn_blocking_handle( - move || { - let _guard = span.enter(); - chain.recompute_head_at_slot_internal(current_slot) - }, + move || chain.recompute_head_at_slot_internal(current_slot), "recompute_head_internal", ) .await diff --git a/beacon_node/beacon_chain/src/fetch_blobs/mod.rs b/beacon_node/beacon_chain/src/fetch_blobs/mod.rs index bae61767cc..db76ff887d 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/mod.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/mod.rs @@ -32,7 +32,7 @@ use mockall_double::double; use ssz_types::FixedVector; use state_processing::per_block_processing::deneb::kzg_commitment_to_versioned_hash; use std::sync::Arc; -use tracing::{Span, debug, instrument, warn}; +use tracing::{debug, instrument, warn}; use types::data::{BlobSidecarError, DataColumnSidecarError}; use types::{ BeaconStateError, Blob, BlobSidecar, ColumnIndex, EthSpec, FullPayload, Hash256, KzgProofs, @@ -356,12 +356,10 @@ async fn compute_custody_columns_to_import( let spec = chain_adapter.spec().clone(); let chain_adapter_cloned = chain_adapter.clone(); let custody_columns_indices = custody_columns_indices.to_vec(); - let current_span = Span::current(); chain_adapter .executor() .spawn_blocking_handle( move || { - let _guard = current_span.enter(); let mut timer = metrics::start_timer_vec( &metrics::DATA_COLUMN_SIDECAR_COMPUTATION, &[&blobs.len().to_string()], diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs index 9a4ed2d044..4d40a29332 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs @@ -4,7 +4,7 @@ use educe::Educe; use eth2::types::{EventKind, SseExecutionPayloadGossip}; use parking_lot::{Mutex, RwLock}; use store::DatabaseBlock; -use tracing::{Span, debug}; +use tracing::debug; use types::{ ChainSpec, EthSpec, ExecutionPayloadBid, ExecutionPayloadEnvelope, Hash256, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, consts::gloas::BUILDER_INDEX_SELF_BUILD, @@ -270,12 +270,10 @@ impl BeaconChain { envelope: Arc>, ) -> Result, EnvelopeError> { let chain = self.clone(); - let span = Span::current(); self.task_executor .clone() .spawn_blocking_handle( move || { - let _guard = span.enter(); let slot = envelope.slot(); let beacon_block_root = envelope.message.beacon_block_root; diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs index bae848c3c1..39925d65d2 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -192,13 +192,9 @@ impl BeaconChain { } = import_data; let block_root = { - // Capture the current span before moving into the blocking task - let current_span = tracing::Span::current(); let chain = self.clone(); self.spawn_blocking_handle( move || { - // Enter the captured span in the blocking thread - let _guard = current_span.enter(); chain.import_execution_payload_envelope( envelope, block_root, diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index 43dfbeb836..eb7e56e9cc 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -146,12 +146,8 @@ pub async fn publish_block>( let slot = block.message().slot(); let sender_clone = network_tx.clone(); - let build_sidecar_task_handle = spawn_build_data_sidecar_task( - chain.clone(), - block.clone(), - unverified_blobs, - current_span.clone(), - )?; + let build_sidecar_task_handle = + spawn_build_data_sidecar_task(chain.clone(), block.clone(), unverified_blobs)?; // Gossip verify the block and blobs/data columns separately. let gossip_verified_block_result = unverified_block.into_gossip_verified_block(&chain); @@ -358,7 +354,6 @@ fn spawn_build_data_sidecar_task( chain: Arc>, block: Arc>>, proofs_and_blobs: UnverifiedBlobs, - current_span: Span, ) -> Result>, Rejection> { chain .clone() @@ -368,7 +363,7 @@ fn spawn_build_data_sidecar_task( let Some((kzg_proofs, blobs)) = proofs_and_blobs else { return Ok((vec![], vec![])); }; - let _guard = debug_span!(parent: current_span, "build_data_sidecars").entered(); + let _span = debug_span!("build_data_sidecars").entered(); let peer_das_enabled = chain.spec.is_peer_das_enabled_for_epoch(block.epoch()); if !peer_das_enabled { diff --git a/common/task_executor/src/lib.rs b/common/task_executor/src/lib.rs index d3d862f96c..07716fa2e7 100644 --- a/common/task_executor/src/lib.rs +++ b/common/task_executor/src/lib.rs @@ -6,7 +6,7 @@ use futures::channel::mpsc::Sender; use futures::prelude::*; use std::sync::{Arc, Weak}; use tokio::runtime::{Handle, Runtime}; -use tracing::debug; +use tracing::{Span, debug}; use crate::rayon_pool_provider::RayonPoolProvider; pub use crate::rayon_pool_provider::RayonPoolType; @@ -225,9 +225,11 @@ impl TaskExecutor { F: FnOnce() + Send + 'static, { let thread_pool = self.rayon_pool_provider.get_thread_pool(rayon_pool_type); + let span = Span::current(); self.spawn_blocking( move || { thread_pool.install(|| { + let _guard = span.enter(); task(); }); }, @@ -247,8 +249,10 @@ impl TaskExecutor { { let thread_pool = self.rayon_pool_provider.get_thread_pool(rayon_pool_type); let (tx, rx) = tokio::sync::oneshot::channel(); + let span = Span::current(); thread_pool.spawn(move || { + let _guard = span.enter(); let result = task(); let _ = tx.send(result); }); @@ -320,8 +324,12 @@ impl TaskExecutor { let timer = metrics::start_timer_vec(&metrics::BLOCKING_TASKS_HISTOGRAM, &[name]); metrics::inc_gauge_vec(&metrics::BLOCKING_TASKS_COUNT, &[name]); + let span = Span::current(); let join_handle = if let Some(handle) = self.handle() { - handle.spawn_blocking(task) + handle.spawn_blocking(move || { + let _guard = span.enter(); + task() + }) } else { debug!("Couldn't spawn task. Runtime shutting down"); return None; From 65c2e0161247409580a50e8a01e3e3aa8dbebf32 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Fri, 3 Apr 2026 19:35:02 +1100 Subject: [PATCH 103/189] Gloas fork choice redux (#9025) Co-Authored-By: hopinheimer Co-Authored-By: Michael Sproul Co-Authored-By: hopinheimer <48147533+hopinheimer@users.noreply.github.com> Co-Authored-By: Eitan Seri- Levi Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Michael Sproul Co-Authored-By: Jimmy Chen Co-Authored-By: Daniel Knopik <107140945+dknopik@users.noreply.github.com> --- Cargo.lock | 3 + beacon_node/beacon_chain/src/beacon_chain.rs | 41 +- .../beacon_chain/src/block_production/mod.rs | 4 +- .../beacon_chain/src/block_verification.rs | 68 +- beacon_node/beacon_chain/src/builder.rs | 12 +- .../beacon_chain/src/canonical_head.rs | 23 +- beacon_node/beacon_chain/src/invariants.rs | 4 +- .../payload_envelope_verification/import.rs | 26 +- .../src/payload_envelope_verification/mod.rs | 2 + .../beacon_chain/src/persisted_fork_choice.rs | 64 +- beacon_node/beacon_chain/src/schema_change.rs | 15 +- .../src/schema_change/migration_schema_v29.rs | 151 ++ .../tests/payload_invalidation.rs | 8 +- beacon_node/beacon_chain/tests/store_tests.rs | 6 +- beacon_node/beacon_chain/tests/tests.rs | 14 +- beacon_node/http_api/src/lib.rs | 60 +- beacon_node/http_api/src/validator/mod.rs | 2 +- beacon_node/http_api/tests/tests.rs | 63 +- beacon_node/store/src/metadata.rs | 2 +- consensus/fork_choice/Cargo.toml | 1 + consensus/fork_choice/src/fork_choice.rs | 337 +++- consensus/fork_choice/src/lib.rs | 7 +- consensus/fork_choice/tests/tests.rs | 61 +- consensus/proto_array/Cargo.toml | 2 + consensus/proto_array/src/error.rs | 8 + .../src/fork_choice_test_definition.rs | 270 ++- .../execution_status.rs | 100 +- .../ffg_updates.rs | 76 +- .../gloas_payload.rs | 893 ++++++++++ .../fork_choice_test_definition/no_votes.rs | 33 + .../src/fork_choice_test_definition/votes.rs | 96 +- consensus/proto_array/src/lib.rs | 6 +- consensus/proto_array/src/proto_array.rs | 1524 ++++++++++++----- .../src/proto_array_fork_choice.rs | 620 +++++-- consensus/proto_array/src/ssz_container.rs | 80 +- .../indexed_payload_attestation.rs | 7 - consensus/types/src/core/chain_spec.rs | 56 +- testing/ef_tests/src/cases/fork_choice.rs | 121 +- testing/ef_tests/src/handler.rs | 23 +- testing/ef_tests/tests/tests.rs | 6 + 40 files changed, 4061 insertions(+), 834 deletions(-) create mode 100644 beacon_node/beacon_chain/src/schema_change/migration_schema_v29.rs create mode 100644 consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs diff --git a/Cargo.lock b/Cargo.lock index 3ba431d62e..726929e9ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3583,6 +3583,7 @@ name = "fork_choice" version = "0.1.0" dependencies = [ "beacon_chain", + "bls", "ethereum_ssz", "ethereum_ssz_derive", "fixed_bytes", @@ -7023,7 +7024,9 @@ dependencies = [ "fixed_bytes", "safe_arith", "serde", + "smallvec", "superstruct", + "typenum", "types", "yaml_serde", ] diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 310163b4a9..e226c707a4 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -1468,7 +1468,7 @@ impl BeaconChain { .proto_array() .heads_descended_from_finalization::(fork_choice.finalized_checkpoint()) .iter() - .map(|node| (node.root, node.slot)) + .map(|node| (node.root(), node.slot())) .collect() } @@ -2298,6 +2298,7 @@ impl BeaconChain { self.slot()?, verified.indexed_attestation().to_ref(), AttestationFromBlock::False, + &self.spec, ) .map_err(Into::into) } @@ -3934,7 +3935,7 @@ impl BeaconChain { let fork_choice_timer = metrics::start_timer(&metrics::BLOCK_PROCESSING_FORK_CHOICE); match fork_choice.get_head(current_slot, &self.spec) { // This block became the head, add it to the early attester cache. - Ok(new_head_root) if new_head_root == block_root => { + Ok((new_head_root, _)) if new_head_root == block_root => { if let Some(proto_block) = fork_choice.get_block(&block_root) { let new_head_is_optimistic = proto_block.execution_status.is_optimistic_or_invalid(); @@ -4734,6 +4735,7 @@ impl BeaconChain { }) } + // TODO(gloas): wrong for Gloas, needs an update pub fn overridden_forkchoice_update_params_or_failure_reason( &self, canonical_forkchoice_params: &ForkchoiceUpdateParameters, @@ -4768,7 +4770,7 @@ impl BeaconChain { // The slot of our potential re-org block is always 1 greater than the head block because we // only attempt single-slot re-orgs. - let head_slot = info.head_node.slot; + let head_slot = info.head_node.slot(); let re_org_block_slot = head_slot + 1; let fork_choice_slot = info.current_slot; @@ -4803,9 +4805,9 @@ impl BeaconChain { .fork_name_at_slot::(re_org_block_slot) .fulu_enabled() { - info.head_node.current_epoch_shuffling_id + info.head_node.current_epoch_shuffling_id() } else { - info.head_node.next_epoch_shuffling_id + info.head_node.next_epoch_shuffling_id() } .shuffling_decision_block; let proposer_index = self @@ -4831,13 +4833,15 @@ impl BeaconChain { return Err(Box::new(DoNotReOrg::NotProposing.into())); } - // If the current slot is already equal to the proposal slot (or we are in the tail end of - // the prior slot), then check the actual weight of the head against the head re-org threshold - // and the actual weight of the parent against the parent re-org threshold. + // TODO(gloas): reorg weight logic needs updating for Gloas. For now use + // total weight which is correct for pre-Gloas and conservative for post-Gloas. + let head_weight = info.head_node.weight(); + let parent_weight = info.parent_node.weight(); + let (head_weak, parent_strong) = if fork_choice_slot == re_org_block_slot { ( - info.head_node.weight < info.re_org_head_weight_threshold, - info.parent_node.weight > info.re_org_parent_weight_threshold, + head_weight < info.re_org_head_weight_threshold, + parent_weight > info.re_org_parent_weight_threshold, ) } else { (true, true) @@ -4845,7 +4849,7 @@ impl BeaconChain { if !head_weak { return Err(Box::new( DoNotReOrg::HeadNotWeak { - head_weight: info.head_node.weight, + head_weight, re_org_head_weight_threshold: info.re_org_head_weight_threshold, } .into(), @@ -4854,7 +4858,7 @@ impl BeaconChain { if !parent_strong { return Err(Box::new( DoNotReOrg::ParentNotStrong { - parent_weight: info.parent_node.weight, + parent_weight, re_org_parent_weight_threshold: info.re_org_parent_weight_threshold, } .into(), @@ -4872,9 +4876,16 @@ impl BeaconChain { return Err(Box::new(DoNotReOrg::HeadNotLate.into())); } - let parent_head_hash = info.parent_node.execution_status.block_hash(); + // TODO(gloas): V29 nodes don't carry execution_status, so this returns + // None for post-Gloas re-orgs. Need to source the EL block hash from + // the bid's block_hash instead. Re-org is disabled for Gloas for now. + let parent_head_hash = info + .parent_node + .execution_status() + .ok() + .and_then(|execution_status| execution_status.block_hash()); let forkchoice_update_params = ForkchoiceUpdateParameters { - head_root: info.parent_node.root, + head_root: info.parent_node.root(), head_hash: parent_head_hash, justified_hash: canonical_forkchoice_params.justified_hash, finalized_hash: canonical_forkchoice_params.finalized_hash, @@ -4882,7 +4893,7 @@ impl BeaconChain { debug!( canonical_head = ?head_block_root, - ?info.parent_node.root, + parent_root = ?info.parent_node.root(), slot = %fork_choice_slot, "Fork choice update overridden" ); diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index 256b67086a..bf42923cbe 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -228,7 +228,7 @@ impl BeaconChain { }) .ok()?; drop(proposer_head_timer); - let re_org_parent_block = proposer_head.parent_node.root; + let re_org_parent_block = proposer_head.parent_node.root(); let (state_root, state) = self .store @@ -245,7 +245,7 @@ impl BeaconChain { info!( weak_head = ?canonical_head, parent = ?re_org_parent_block, - head_weight = proposer_head.head_node.weight, + head_weight = proposer_head.head_node.weight(), threshold_weight = proposer_head.re_org_head_weight_threshold, "Attempting re-org due to weak head" ); diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 802b090f6a..1ce1137f1e 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -1670,6 +1670,7 @@ impl ExecutionPendingBlock { current_slot, indexed_attestation, AttestationFromBlock::True, + &chain.spec, ) { Ok(()) => Ok(()), // Ignore invalid attestations whilst importing attestations from a block. The @@ -1678,6 +1679,31 @@ impl ExecutionPendingBlock { Err(e) => Err(BlockError::BeaconChainError(Box::new(e.into()))), }?; } + + // Register each payload attestation in the block with fork choice. + if let Ok(payload_attestations) = block.message().body().payload_attestations() { + for (i, payload_attestation) in payload_attestations.iter().enumerate() { + let indexed_payload_attestation = consensus_context + .get_indexed_payload_attestation(&state, payload_attestation, &chain.spec) + .map_err(|e| BlockError::PerBlockProcessingError(e.into_with_index(i)))?; + + let ptc = state + .get_ptc(indexed_payload_attestation.data.slot, &chain.spec) + .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))?; + + // Ignore invalid payload attestations from blocks (same as + // regular attestations — the block may be old). + if let Err(e) = fork_choice.on_payload_attestation( + current_slot, + indexed_payload_attestation, + AttestationFromBlock::True, + &ptc.0, + ) && !matches!(e, ForkChoiceError::InvalidPayloadAttestation(_)) + { + return Err(BlockError::BeaconChainError(Box::new(e.into()))); + } + } + } drop(fork_choice); Ok(Self { @@ -1934,25 +1960,31 @@ fn load_parent>( // Post-Gloas we must also fetch a state with the correct payload status. If the current // block builds upon the payload of its parent block, then we know the parent block is FULL // and we need to load the full state. - let (payload_status, parent_state_root) = - if block.as_block().fork_name_unchecked().gloas_enabled() - && let Ok(parent_bid_block_hash) = parent_block.payload_bid_block_hash() - { - if block.as_block().is_parent_block_full(parent_bid_block_hash) { - // TODO(gloas): loading the envelope here is not very efficient - // TODO(gloas): check parent payload existence prior to this point? - let envelope = chain.store.get_payload_envelope(&root)?.ok_or_else(|| { - BeaconChainError::DBInconsistent(format!( - "Missing envelope for parent block {root:?}", - )) - })?; - (StatePayloadStatus::Full, envelope.message.state_root) - } else { - (StatePayloadStatus::Pending, parent_block.state_root()) - } - } else { - (StatePayloadStatus::Pending, parent_block.state_root()) + let (payload_status, parent_state_root) = if parent_block.slot() == chain.spec.genesis_slot + { + // Genesis state is always pending, there is no such thing as a "genesis envelope". + // See: https://github.com/ethereum/consensus-specs/issues/5043 + (StatePayloadStatus::Pending, parent_block.state_root()) + } else if !block.as_block().fork_name_unchecked().gloas_enabled() { + // All pre-Gloas parent states are pending. + (StatePayloadStatus::Pending, parent_block.state_root()) + } else if let Ok(parent_bid_block_hash) = parent_block.payload_bid_block_hash() + && block.as_block().is_parent_block_full(parent_bid_block_hash) + { + // Post-Gloas Full block case. + // TODO(gloas): loading the envelope here is not very efficient + let Some(envelope) = chain.store.get_payload_envelope(&root)? else { + return Err(BeaconChainError::DBInconsistent(format!( + "Missing envelope for parent block {root:?}", + )) + .into()); }; + let state_root = envelope.message.state_root; + (StatePayloadStatus::Full, state_root) + } else { + // Post-Gloas empty block case (also covers the Gloas fork transition). + (StatePayloadStatus::Pending, parent_block.state_root()) + }; let (parent_state_root, state) = chain .store .get_advanced_hot_state(root, payload_status, block.slot(), parent_state_root)? diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 7eb92060a2..11b87351b1 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -45,7 +45,7 @@ use tree_hash::TreeHash; use types::data::CustodyIndex; use types::{ BeaconBlock, BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, - Epoch, EthSpec, Hash256, SignedBeaconBlock, Slot, StatePayloadStatus, + Epoch, EthSpec, Hash256, SignedBeaconBlock, Slot, }; /// An empty struct used to "witness" all the `BeaconChainTypes` traits. It has no user-facing @@ -776,7 +776,7 @@ where slot_clock.now().ok_or("Unable to read slot")? }; - let initial_head_block_root = fork_choice + let (initial_head_block_root, head_payload_status) = fork_choice .get_head(current_slot, &self.spec) .map_err(|e| format!("Unable to get fork choice head: {:?}", e))?; @@ -786,13 +786,12 @@ where .map_err(|e| descriptive_db_error("head block", &e))? .ok_or("Head block not found in store")?; - // TODO(gloas): update head loading to load Full block once fork choice works - let payload_status = StatePayloadStatus::Pending; + let state_payload_status = head_payload_status.as_state_payload_status(); let (_head_state_root, head_state) = store .get_advanced_hot_state( head_block_root, - payload_status, + state_payload_status, current_slot, head_block.state_root(), ) @@ -923,7 +922,8 @@ where let genesis_validators_root = head_snapshot.beacon_state.genesis_validators_root(); let genesis_time = head_snapshot.beacon_state.genesis_time(); - let canonical_head = CanonicalHead::new(fork_choice, Arc::new(head_snapshot)); + let canonical_head = + CanonicalHead::new(fork_choice, Arc::new(head_snapshot), head_payload_status); let shuffling_cache_size = self.chain_config.shuffling_cache_size; let complete_blob_backfill = self.chain_config.complete_blob_backfill; diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index f6377e6ea5..cd53d0ef7c 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -107,6 +107,8 @@ pub struct CachedHead { /// This value may be distinct to the `self.snapshot.beacon_state.finalized_checkpoint`. /// This value should be used over the beacon state value in practically all circumstances. finalized_checkpoint: Checkpoint, + /// The payload status of the head block, as determined by fork choice. + head_payload_status: proto_array::PayloadStatus, /// The `execution_payload.block_hash` of the block at the head of the chain. Set to `None` /// before Bellatrix. head_hash: Option, @@ -231,6 +233,10 @@ impl CachedHead { finalized_hash: self.finalized_hash, } } + + pub fn head_payload_status(&self) -> proto_array::PayloadStatus { + self.head_payload_status + } } /// Represents the "canonical head" of the beacon chain. @@ -261,6 +267,7 @@ impl CanonicalHead { pub fn new( fork_choice: BeaconForkChoice, snapshot: Arc>, + head_payload_status: proto_array::PayloadStatus, ) -> Self { let fork_choice_view = fork_choice.cached_fork_choice_view(); let forkchoice_update_params = fork_choice.get_forkchoice_update_parameters(); @@ -268,6 +275,7 @@ impl CanonicalHead { snapshot, justified_checkpoint: fork_choice_view.justified_checkpoint, finalized_checkpoint: fork_choice_view.finalized_checkpoint, + head_payload_status, head_hash: forkchoice_update_params.head_hash, justified_hash: forkchoice_update_params.justified_hash, finalized_hash: forkchoice_update_params.finalized_hash, @@ -295,9 +303,11 @@ impl CanonicalHead { store: &BeaconStore, spec: &ChainSpec, ) -> Result<(), Error> { - let fork_choice = + let mut fork_choice = >::load_fork_choice(store.clone(), reset_payload_statuses, spec)? .ok_or(Error::MissingPersistedForkChoice)?; + let current_slot_for_head = fork_choice.fc_store().get_current_slot(); + let (_, head_payload_status) = fork_choice.get_head(current_slot_for_head, spec)?; let fork_choice_view = fork_choice.cached_fork_choice_view(); let beacon_block_root = fork_choice_view.head_block_root; let beacon_block = store @@ -328,6 +338,7 @@ impl CanonicalHead { snapshot: Arc::new(snapshot), justified_checkpoint: fork_choice_view.justified_checkpoint, finalized_checkpoint: fork_choice_view.finalized_checkpoint, + head_payload_status, head_hash: forkchoice_update_params.head_hash, justified_hash: forkchoice_update_params.justified_hash, finalized_hash: forkchoice_update_params.finalized_hash, @@ -601,11 +612,12 @@ impl BeaconChain { justified_checkpoint: old_cached_head.justified_checkpoint(), finalized_checkpoint: old_cached_head.finalized_checkpoint(), }; + let old_payload_status = old_cached_head.head_payload_status(); let mut fork_choice_write_lock = self.canonical_head.fork_choice_write_lock(); // Recompute the current head via the fork choice algorithm. - fork_choice_write_lock.get_head(current_slot, &self.spec)?; + let (_, new_payload_status) = fork_choice_write_lock.get_head(current_slot, &self.spec)?; // Downgrade the fork choice write-lock to a read lock, without allowing access to any // other writers. @@ -650,9 +662,8 @@ impl BeaconChain { }); } - // Exit early if the head or justified/finalized checkpoints have not changed, there's - // nothing to do. - if new_view == old_view { + // Exit early if the head, checkpoints, and payload status have not changed. + if new_view == old_view && new_payload_status == old_payload_status { debug!( head = ?new_view.head_block_root, "No change in canonical head" @@ -709,6 +720,7 @@ impl BeaconChain { snapshot: Arc::new(new_snapshot), justified_checkpoint: new_view.justified_checkpoint, finalized_checkpoint: new_view.finalized_checkpoint, + head_payload_status: new_payload_status, head_hash: new_forkchoice_update_parameters.head_hash, justified_hash: new_forkchoice_update_parameters.justified_hash, finalized_hash: new_forkchoice_update_parameters.finalized_hash, @@ -736,6 +748,7 @@ impl BeaconChain { snapshot: old_cached_head.snapshot.clone(), justified_checkpoint: new_view.justified_checkpoint, finalized_checkpoint: new_view.finalized_checkpoint, + head_payload_status: new_payload_status, head_hash: new_forkchoice_update_parameters.head_hash, justified_hash: new_forkchoice_update_parameters.justified_hash, finalized_hash: new_forkchoice_update_parameters.finalized_hash, diff --git a/beacon_node/beacon_chain/src/invariants.rs b/beacon_node/beacon_chain/src/invariants.rs index 7bcec7b0b4..b365f37a0a 100644 --- a/beacon_node/beacon_chain/src/invariants.rs +++ b/beacon_node/beacon_chain/src/invariants.rs @@ -23,9 +23,9 @@ impl BeaconChain { // Only check blocks that are descendants of the finalized checkpoint. // Pruned non-canonical fork blocks may linger in the proto-array but // are legitimately absent from the database. - fc.is_finalized_checkpoint_or_descendant(node.root) + fc.is_finalized_checkpoint_or_descendant(node.root()) }) - .map(|node| (node.root, node.slot)) + .map(|node| (node.root(), node.slot())) .collect() }; diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs index 39925d65d2..7e79799310 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -168,6 +168,16 @@ impl BeaconChain { .map_err(BeaconChainError::TokioJoin)? .ok_or(BeaconChainError::RuntimeShutdown)??; + // TODO(gloas): optimistic sync is not supported for Gloas, maybe we could re-add it + if payload_verification_outcome + .payload_verification_status + .is_optimistic() + { + return Err(EnvelopeError::OptimisticSyncNotSupported { + block_root: import_data.block_root, + }); + } + Ok(ExecutedEnvelope::new( signed_envelope, import_data, @@ -236,16 +246,15 @@ impl BeaconChain { // Note that a duplicate cache/payload status table should prevent this from happening // but it doesnt hurt to be defensive. - // TODO(gloas) when the code below is implemented we can delete this drop - drop(fork_choice_reader); - - // TODO(gloas) no fork choice logic yet // Take an exclusive write-lock on fork choice. It's very important to prevent deadlocks by // avoiding taking other locks whilst holding this lock. - // let fork_choice = parking_lot::RwLockUpgradableReadGuard::upgrade(fork_choice_reader); + let mut fork_choice = parking_lot::RwLockUpgradableReadGuard::upgrade(fork_choice_reader); - // TODO(gloas) Do we need this check? Do not import a block that doesn't descend from the finalized root. - // let signed_block = check_block_is_finalized_checkpoint_or_descendant(self, &fork_choice, signed_block)?; + // Update the block's payload to received in fork choice, which creates the `Full` virtual + // node which can be eligible for head. + fork_choice + .on_valid_payload_envelope_received(block_root) + .map_err(|e| EnvelopeError::InternalError(format!("{e:?}")))?; // TODO(gloas) emit SSE event if the payload became the new head payload @@ -299,10 +308,9 @@ impl BeaconChain { drop(db_span); - // TODO(gloas) drop fork choice lock // The fork choice write-lock is dropped *after* the on-disk database has been updated. // This prevents inconsistency between the two at the expense of concurrency. - // drop(fork_choice); + drop(fork_choice); // We're declaring the envelope "imported" at this point, since fork choice and the DB know // about it. diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs index c707d62dc7..225d5a9892 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs @@ -182,6 +182,8 @@ pub enum EnvelopeError { payload_slot: Slot, latest_finalized_slot: Slot, }, + /// Optimistic sync is not supported for Gloas payload envelopes. + OptimisticSyncNotSupported { block_root: Hash256 }, /// Some Beacon Chain Error BeaconChainError(Arc), /// Some Beacon State error diff --git a/beacon_node/beacon_chain/src/persisted_fork_choice.rs b/beacon_node/beacon_chain/src/persisted_fork_choice.rs index 6229544e81..8edccbbe98 100644 --- a/beacon_node/beacon_chain/src/persisted_fork_choice.rs +++ b/beacon_node/beacon_chain/src/persisted_fork_choice.rs @@ -6,11 +6,19 @@ use superstruct::superstruct; use types::Hash256; // If adding a new version you should update this type alias and fix the breakages. -pub type PersistedForkChoice = PersistedForkChoiceV28; +pub type PersistedForkChoice = PersistedForkChoiceV29; -#[superstruct(variants(V28), variant_attributes(derive(Encode, Decode)), no_enum)] +#[superstruct( + variants(V28, V29), + variant_attributes(derive(Encode, Decode)), + no_enum +)] pub struct PersistedForkChoice { - pub fork_choice: fork_choice::PersistedForkChoiceV28, + #[superstruct(only(V28))] + pub fork_choice_v28: fork_choice::PersistedForkChoiceV28, + #[superstruct(only(V29))] + pub fork_choice: fork_choice::PersistedForkChoiceV29, + #[superstruct(only(V28, V29))] pub fork_choice_store: PersistedForkChoiceStoreV28, } @@ -45,3 +53,53 @@ impl PersistedForkChoiceV28 { )) } } + +impl PersistedForkChoiceV29 { + pub fn from_bytes(bytes: &[u8], store_config: &StoreConfig) -> Result { + let decompressed_bytes = store_config + .decompress_bytes(bytes) + .map_err(Error::Compression)?; + Self::from_ssz_bytes(&decompressed_bytes).map_err(Into::into) + } + + pub fn as_bytes(&self, store_config: &StoreConfig) -> Result, Error> { + let encode_timer = metrics::start_timer(&metrics::FORK_CHOICE_ENCODE_TIMES); + let ssz_bytes = self.as_ssz_bytes(); + drop(encode_timer); + + let _compress_timer = metrics::start_timer(&metrics::FORK_CHOICE_COMPRESS_TIMES); + store_config + .compress_bytes(&ssz_bytes) + .map_err(Error::Compression) + } + + pub fn as_kv_store_op( + &self, + key: Hash256, + store_config: &StoreConfig, + ) -> Result { + Ok(KeyValueStoreOp::PutKeyValue( + DBColumn::ForkChoice, + key.as_slice().to_vec(), + self.as_bytes(store_config)?, + )) + } +} + +impl From for PersistedForkChoiceV29 { + fn from(v28: PersistedForkChoiceV28) -> Self { + Self { + fork_choice: v28.fork_choice_v28.into(), + fork_choice_store: v28.fork_choice_store, + } + } +} + +impl From for PersistedForkChoiceV28 { + fn from(v29: PersistedForkChoiceV29) -> Self { + Self { + fork_choice_v28: v29.fork_choice.into(), + fork_choice_store: v29.fork_choice_store, + } + } +} diff --git a/beacon_node/beacon_chain/src/schema_change.rs b/beacon_node/beacon_chain/src/schema_change.rs index ed82143c38..841f28e37d 100644 --- a/beacon_node/beacon_chain/src/schema_change.rs +++ b/beacon_node/beacon_chain/src/schema_change.rs @@ -1,5 +1,8 @@ //! Utilities for managing database schema changes. +mod migration_schema_v29; + use crate::beacon_chain::BeaconChainTypes; +use migration_schema_v29::{downgrade_from_v29, upgrade_to_v29}; use std::sync::Arc; use store::Error as StoreError; use store::hot_cold_store::{HotColdDB, HotColdDBError}; @@ -10,13 +13,23 @@ use store::metadata::{CURRENT_SCHEMA_VERSION, SchemaVersion}; /// All migrations for schema versions up to and including v28 have been removed. Nodes on live /// networks are already running v28, so only the current version check remains. pub fn migrate_schema( - _db: Arc>, + db: Arc>, from: SchemaVersion, to: SchemaVersion, ) -> Result<(), StoreError> { match (from, to) { // Migrating from the current schema version to itself is always OK, a no-op. (_, _) if from == to && to == CURRENT_SCHEMA_VERSION => Ok(()), + // Upgrade from v28 to v29. + (SchemaVersion(28), SchemaVersion(29)) => { + let ops = upgrade_to_v29::(&db)?; + db.store_schema_version_atomically(to, ops) + } + // Downgrade from v29 to v28. + (SchemaVersion(29), SchemaVersion(28)) => { + let ops = downgrade_from_v29::(&db)?; + db.store_schema_version_atomically(to, ops) + } // Anything else is an error. (_, _) => Err(HotColdDBError::UnsupportedSchemaVersion { target_version: to, diff --git a/beacon_node/beacon_chain/src/schema_change/migration_schema_v29.rs b/beacon_node/beacon_chain/src/schema_change/migration_schema_v29.rs new file mode 100644 index 0000000000..77d4be3443 --- /dev/null +++ b/beacon_node/beacon_chain/src/schema_change/migration_schema_v29.rs @@ -0,0 +1,151 @@ +use crate::beacon_chain::{BeaconChainTypes, FORK_CHOICE_DB_KEY}; +use crate::persisted_fork_choice::{PersistedForkChoiceV28, PersistedForkChoiceV29}; +use std::collections::HashMap; +use store::hot_cold_store::HotColdDB; +use store::{DBColumn, Error as StoreError, KeyValueStore, KeyValueStoreOp}; +use tracing::warn; +use types::EthSpec; + +/// Upgrade from schema v28 to v29. +/// +/// - Clears `best_child` and `best_descendant` on all nodes (replaced by +/// virtual tree walk). +/// - Fails if the persisted fork choice contains any V17 (pre-Gloas) proto +/// nodes at or after the Gloas fork slot. +/// +/// Returns a list of store ops to be applied atomically with the schema version write. +pub fn upgrade_to_v29( + db: &HotColdDB, +) -> Result, StoreError> { + let gloas_fork_slot = db + .spec + .gloas_fork_epoch + .map(|epoch| epoch.start_slot(T::EthSpec::slots_per_epoch())); + + // Load the persisted fork choice (v28 format). + let Some(fc_bytes) = db + .hot_db + .get_bytes(DBColumn::ForkChoice, FORK_CHOICE_DB_KEY.as_slice())? + else { + return Ok(vec![]); + }; + + let persisted_v28 = PersistedForkChoiceV28::from_bytes(&fc_bytes, db.get_config())?; + + // Check for V17 nodes at/after the Gloas fork slot. + if let Some(gloas_fork_slot) = gloas_fork_slot { + let bad_node = persisted_v28 + .fork_choice_v28 + .proto_array_v28 + .nodes + .iter() + .find(|node| node.slot >= gloas_fork_slot); + + if let Some(node) = bad_node { + return Err(StoreError::MigrationError(format!( + "cannot upgrade from v28 to v29: found V17 proto node at slot {} (root: {:?}) \ + which is at or after the Gloas fork slot {}. This node has synced a chain with \ + Gloas disabled and cannot be upgraded. Please resync from scratch.", + node.slot, node.root, gloas_fork_slot, + ))); + } + } + + // Read the previous proposer boost before converting to V29 (V29 no longer stores it). + let previous_proposer_boost = persisted_v28 + .fork_choice_v28 + .proto_array_v28 + .previous_proposer_boost; + + // Convert to v29. + let mut persisted_v29 = PersistedForkChoiceV29::from(persisted_v28); + + // Subtract the proposer boost from the boosted node and all its ancestors. + // + // In the V28 schema, `apply_score_changes` baked the proposer boost directly into node + // weights and back-propagated it up the parent chain. In V29, the boost is computed + // on-the-fly during the virtual tree walk. If we don't subtract the baked-in boost here, + // it will be double-counted after the upgrade. + if !previous_proposer_boost.root.is_zero() && previous_proposer_boost.score > 0 { + let score = previous_proposer_boost.score; + let indices: HashMap<_, _> = persisted_v29 + .fork_choice + .proto_array + .indices + .iter() + .cloned() + .collect(); + + if let Some(node_index) = indices.get(&previous_proposer_boost.root).copied() { + let nodes = &mut persisted_v29.fork_choice.proto_array.nodes; + let mut current = Some(node_index); + while let Some(idx) = current { + if let Some(node) = nodes.get_mut(idx) { + *node.weight_mut() = node.weight().saturating_sub(score); + current = node.parent(); + } else { + break; + } + } + } else { + warn!( + root = ?previous_proposer_boost.root, + "Proposer boost node missing from fork choice" + ); + } + } + + Ok(vec![ + persisted_v29.as_kv_store_op(FORK_CHOICE_DB_KEY, db.get_config())?, + ]) +} + +/// Downgrade from schema v29 to v28. +/// +/// Converts the persisted fork choice from V29 format back to V28. +/// Fails if the persisted fork choice contains any V29 proto nodes, as these contain +/// payload-specific fields that cannot be losslessly converted back to V17 format. +/// +/// Returns a list of store ops to be applied atomically with the schema version write. +pub fn downgrade_from_v29( + db: &HotColdDB, +) -> Result, StoreError> { + // Load the persisted fork choice (v29 format, compressed). + let Some(fc_bytes) = db + .hot_db + .get_bytes(DBColumn::ForkChoice, FORK_CHOICE_DB_KEY.as_slice())? + else { + return Ok(vec![]); + }; + + let persisted_v29 = + PersistedForkChoiceV29::from_bytes(&fc_bytes, db.get_config()).map_err(|e| { + StoreError::MigrationError(format!( + "cannot downgrade from v29 to v28: failed to decode fork choice: {:?}", + e + )) + })?; + + let has_v29_node = persisted_v29 + .fork_choice + .proto_array + .nodes + .iter() + .any(|node| matches!(node, proto_array::core::ProtoNode::V29(_))); + + if has_v29_node { + return Err(StoreError::MigrationError( + "cannot downgrade from v29 to v28: the persisted fork choice contains V29 proto \ + nodes which cannot be losslessly converted to V17 format. The Gloas-specific \ + payload data would be lost." + .to_string(), + )); + } + + // Convert to v28 and encode. + let persisted_v28 = PersistedForkChoiceV28::from(persisted_v29); + + Ok(vec![ + persisted_v28.as_kv_store_op(FORK_CHOICE_DB_KEY, db.get_config())?, + ]) +} diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index 3ed8f59838..947024e8c2 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1350,7 +1350,7 @@ async fn recover_from_invalid_head_by_importing_blocks() { "the fork block should become the head" ); - let manual_get_head = rig + let (manual_get_head, _) = rig .harness .chain .canonical_head @@ -1428,7 +1428,7 @@ async fn weights_after_resetting_optimistic_status() { .fork_choice_read_lock() .proto_array() .iter_nodes(&head.head_block_root()) - .map(|node| (node.root, node.weight)) + .map(|node| (node.root(), node.weight())) .collect::>(); rig.invalidate_manually(roots[1]).await; @@ -1438,7 +1438,7 @@ async fn weights_after_resetting_optimistic_status() { .canonical_head .fork_choice_write_lock() .proto_array_mut() - .set_all_blocks_to_optimistic::(&rig.harness.chain.spec) + .set_all_blocks_to_optimistic::() .unwrap(); let new_weights = rig @@ -1448,7 +1448,7 @@ async fn weights_after_resetting_optimistic_status() { .fork_choice_read_lock() .proto_array() .iter_nodes(&head.head_block_root()) - .map(|node| (node.root, node.weight)) + .map(|node| (node.root(), node.weight())) .collect::>(); assert_eq!(original_weights, new_weights); diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index fb5262b893..c6e13bd160 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -3995,7 +3995,7 @@ async fn schema_downgrade_to_min_version(store_config: StoreConfig, archive: boo ) .await; - let min_version = CURRENT_SCHEMA_VERSION; + let min_version = SchemaVersion(28); // Save the slot clock so that the new harness doesn't revert in time. let slot_clock = harness.chain.slot_clock.clone(); @@ -5426,10 +5426,12 @@ fn assert_chains_pretty_much_the_same(a: &BeaconChain, b .fork_choice_write_lock() .get_head(slot, &spec) .unwrap() + .0 == b.canonical_head .fork_choice_write_lock() .get_head(slot, &spec) - .unwrap(), + .unwrap() + .0, "fork_choice heads should be equal" ); } diff --git a/beacon_node/beacon_chain/tests/tests.rs b/beacon_node/beacon_chain/tests/tests.rs index b052ba66f1..10c0b429a9 100644 --- a/beacon_node/beacon_chain/tests/tests.rs +++ b/beacon_node/beacon_chain/tests/tests.rs @@ -590,7 +590,10 @@ async fn unaggregated_attestations_added_to_fork_choice_some_none() { if slot <= num_blocks_produced && slot != 0 { assert_eq!( - latest_message.unwrap().1, + latest_message + .expect("latest message should be present") + .slot + .epoch(MinimalEthSpec::slots_per_epoch()), slot.epoch(MinimalEthSpec::slots_per_epoch()), "Latest message epoch for {} should be equal to epoch {}.", validator, @@ -700,10 +703,12 @@ async fn unaggregated_attestations_added_to_fork_choice_all_updated() { let validator_slots: Vec<(&usize, Slot)> = validators.iter().zip(slots).collect(); for (validator, slot) in validator_slots { - let latest_message = fork_choice.latest_message(*validator); + let latest_message = fork_choice + .latest_message(*validator) + .expect("latest message should be present"); assert_eq!( - latest_message.unwrap().1, + latest_message.slot.epoch(MinimalEthSpec::slots_per_epoch()), slot.epoch(MinimalEthSpec::slots_per_epoch()), "Latest message slot should be equal to attester duty." ); @@ -714,8 +719,7 @@ async fn unaggregated_attestations_added_to_fork_choice_all_updated() { .expect("Should get block root at slot"); assert_eq!( - latest_message.unwrap().0, - *block_root, + latest_message.root, *block_root, "Latest message block root should be equal to block at slot." ); } diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 17d41cfbcd..0bb04888b7 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -2098,52 +2098,66 @@ pub fn serve( .nodes .iter() .map(|node| { - let execution_status = if node.execution_status.is_execution_enabled() { - Some(node.execution_status.to_string()) + let execution_status = if node + .execution_status() + .is_ok_and(|status| status.is_execution_enabled()) + { + node.execution_status() + .ok() + .map(|status| status.to_string()) } else { None }; + let execution_status_string = node + .execution_status() + .map_or_else(|_| "irrelevant".to_string(), |s| s.to_string()); + ForkChoiceNode { - slot: node.slot, - block_root: node.root, + slot: node.slot(), + block_root: node.root(), parent_root: node - .parent + .parent() .and_then(|index| proto_array.nodes.get(index)) - .map(|parent| parent.root), - justified_epoch: node.justified_checkpoint.epoch, - finalized_epoch: node.finalized_checkpoint.epoch, - weight: node.weight, + .map(|parent| parent.root()), + justified_epoch: node.justified_checkpoint().epoch, + finalized_epoch: node.finalized_checkpoint().epoch, + weight: node.weight(), validity: execution_status, execution_block_hash: node - .execution_status - .block_hash() + .execution_status() + .ok() + .and_then(|status| status.block_hash()) .map(|block_hash| block_hash.into_root()), extra_data: ForkChoiceExtraData { - target_root: node.target_root, - justified_root: node.justified_checkpoint.root, - finalized_root: node.finalized_checkpoint.root, + target_root: node.target_root(), + justified_root: node.justified_checkpoint().root, + finalized_root: node.finalized_checkpoint().root, unrealized_justified_root: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_finalized_root: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_justified_epoch: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.epoch), unrealized_finalized_epoch: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.epoch), - execution_status: node.execution_status.to_string(), + execution_status: execution_status_string, best_child: node - .best_child + .best_child() + .ok() + .flatten() .and_then(|index| proto_array.nodes.get(index)) - .map(|child| child.root), + .map(|child| child.root()), best_descendant: node - .best_descendant + .best_descendant() + .ok() + .flatten() .and_then(|index| proto_array.nodes.get(index)) - .map(|descendant| descendant.root), + .map(|descendant| descendant.root()), }, } }) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 3d96b85870..412851233e 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -671,7 +671,7 @@ pub fn post_validator_prepare_beacon_proposer( .await; // TODO(gloas): verify this is correct. We skip proposer preparation for - // GLOAS because the execution payload is no longer embedded in the beacon + // Gloas because the execution payload is no longer embedded in the beacon // block (it's in the payload envelope), so the head block's // execution_payload() is unavailable. let next_slot = current_slot + 1; diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index c9086dd876..b28816302c 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -33,7 +33,7 @@ use lighthouse_network::{Enr, PeerId, types::SyncState}; use network::NetworkReceivers; use network_utils::enr_ext::EnrExt; use operation_pool::attestation_storage::CheckpointKey; -use proto_array::ExecutionStatus; +use proto_array::{ExecutionStatus, core::ProtoNode}; use reqwest::{RequestBuilder, Response, StatusCode}; use sensitive_url::SensitiveUrl; use slot_clock::SlotClock; @@ -3130,51 +3130,65 @@ impl ApiTester { .nodes .iter() .map(|node| { - let execution_status = if node.execution_status.is_execution_enabled() { - Some(node.execution_status.to_string()) + let execution_status = if node + .execution_status() + .is_ok_and(|status| status.is_execution_enabled()) + { + node.execution_status() + .ok() + .map(|status| status.to_string()) } else { None }; ForkChoiceNode { - slot: node.slot, - block_root: node.root, + slot: node.slot(), + block_root: node.root(), parent_root: node - .parent + .parent() .and_then(|index| expected_proto_array.nodes.get(index)) - .map(|parent| parent.root), - justified_epoch: node.justified_checkpoint.epoch, - finalized_epoch: node.finalized_checkpoint.epoch, - weight: node.weight, + .map(|parent| parent.root()), + justified_epoch: node.justified_checkpoint().epoch, + finalized_epoch: node.finalized_checkpoint().epoch, + weight: node.weight(), validity: execution_status, execution_block_hash: node - .execution_status - .block_hash() + .execution_status() + .ok() + .and_then(|status| status.block_hash()) .map(|block_hash| block_hash.into_root()), extra_data: ForkChoiceExtraData { - target_root: node.target_root, - justified_root: node.justified_checkpoint.root, - finalized_root: node.finalized_checkpoint.root, + target_root: node.target_root(), + justified_root: node.justified_checkpoint().root, + finalized_root: node.finalized_checkpoint().root, unrealized_justified_root: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_finalized_root: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.root), unrealized_justified_epoch: node - .unrealized_justified_checkpoint + .unrealized_justified_checkpoint() .map(|checkpoint| checkpoint.epoch), unrealized_finalized_epoch: node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .map(|checkpoint| checkpoint.epoch), - execution_status: node.execution_status.to_string(), + execution_status: node + .execution_status() + .ok() + .map(|status| status.to_string()) + .unwrap_or_else(|| "irrelevant".to_string()), best_child: node - .best_child + .best_child() + .ok() + .flatten() .and_then(|index| expected_proto_array.nodes.get(index)) - .map(|child| child.root), + .map(|child| child.root()), best_descendant: node - .best_descendant + .best_descendant() + .ok() + .flatten() .and_then(|index| expected_proto_array.nodes.get(index)) - .map(|descendant| descendant.root), + .map(|descendant| descendant.root()), }, } }) @@ -7180,6 +7194,7 @@ impl ApiTester { .core_proto_array_mut() .nodes .last_mut() + && let ProtoNode::V17(head_node) = head_node { head_node.execution_status = ExecutionStatus::Optimistic(ExecutionBlockHash::zero()) } diff --git a/beacon_node/store/src/metadata.rs b/beacon_node/store/src/metadata.rs index cf49468451..215cdb2b64 100644 --- a/beacon_node/store/src/metadata.rs +++ b/beacon_node/store/src/metadata.rs @@ -4,7 +4,7 @@ use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use types::{Hash256, Slot}; -pub const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(28); +pub const CURRENT_SCHEMA_VERSION: SchemaVersion = SchemaVersion(29); // All the keys that get stored under the `BeaconMeta` column. // diff --git a/consensus/fork_choice/Cargo.toml b/consensus/fork_choice/Cargo.toml index a07aa38aa5..df47a5c9d1 100644 --- a/consensus/fork_choice/Cargo.toml +++ b/consensus/fork_choice/Cargo.toml @@ -19,5 +19,6 @@ types = { workspace = true } [dev-dependencies] beacon_chain = { workspace = true } +bls = { workspace = true } store = { workspace = true } tokio = { workspace = true } diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 74b287975e..92fd4c1faf 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -3,8 +3,8 @@ use crate::{ForkChoiceStore, InvalidationOperation}; use fixed_bytes::FixedBytesExtended; use logging::crit; use proto_array::{ - Block as ProtoBlock, DisallowedReOrgOffsets, ExecutionStatus, JustifiedBalances, - ProposerHeadError, ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, + Block as ProtoBlock, DisallowedReOrgOffsets, ExecutionStatus, JustifiedBalances, LatestMessage, + PayloadStatus, ProposerHeadError, ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, }; use ssz_derive::{Decode, Encode}; use state_processing::{ @@ -19,12 +19,14 @@ use tracing::{debug, instrument, warn}; use types::{ AbstractExecPayload, AttestationShufflingId, AttesterSlashingRef, BeaconBlockRef, BeaconState, BeaconStateError, ChainSpec, Checkpoint, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, - Hash256, IndexedAttestationRef, RelativeEpoch, SignedBeaconBlock, Slot, + Hash256, IndexedAttestationRef, IndexedPayloadAttestation, RelativeEpoch, SignedBeaconBlock, + Slot, }; #[derive(Debug)] pub enum Error { InvalidAttestation(InvalidAttestation), + InvalidPayloadAttestation(InvalidPayloadAttestation), InvalidAttesterSlashing(AttesterSlashingValidationError), InvalidBlock(InvalidBlock), ProtoArrayStringError(String), @@ -84,6 +86,12 @@ impl From for Error { } } +impl From for Error { + fn from(e: InvalidPayloadAttestation) -> Self { + Error::InvalidPayloadAttestation(e) + } +} + impl From for Error { fn from(e: AttesterSlashingValidationError) -> Self { Error::InvalidAttesterSlashing(e) @@ -169,6 +177,33 @@ pub enum InvalidAttestation { /// The attestation is attesting to a state that is later than itself. (Viz., attesting to the /// future). AttestsToFutureBlock { block: Slot, attestation: Slot }, + /// Post-Gloas: attestation index must be 0 or 1. + InvalidAttestationIndex { index: u64 }, + /// A same-slot attestation has a non-zero index, which is invalid post-Gloas. + InvalidSameSlotAttestationIndex { slot: Slot }, + /// Post-Gloas: attestation with index == 1 (payload_present) requires the block's + /// payload to have been received (`root in store.payload_states`). + PayloadNotReceived { beacon_block_root: Hash256 }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum InvalidPayloadAttestation { + /// The payload attestation's attesting indices were empty. + EmptyAggregationBitfield, + /// The `payload_attestation.data.beacon_block_root` block is unknown. + UnknownHeadBlock { beacon_block_root: Hash256 }, + /// The payload attestation is attesting to a block that is later than itself. + AttestsToFutureBlock { block: Slot, attestation: Slot }, + /// A gossip payload attestation must be for the current slot. + PayloadAttestationNotCurrentSlot { + attestation_slot: Slot, + current_slot: Slot, + }, + /// One or more payload attesters are not part of the PTC. + PayloadAttestationAttestersNotInPtc { + attesting_indices_len: usize, + attesting_indices_in_ptc: usize, + }, } impl From for Error { @@ -240,6 +275,17 @@ pub struct QueuedAttestation { attesting_indices: Vec, block_root: Hash256, target_epoch: Epoch, + /// Per Gloas spec: `payload_present = attestation.data.index == 1`. + payload_present: bool, +} + +/// Legacy queued attestation without payload_present (pre-Gloas, schema V28). +#[derive(Clone, PartialEq, Encode, Decode)] +pub struct QueuedAttestationV28 { + slot: Slot, + attesting_indices: Vec, + block_root: Hash256, + target_epoch: Epoch, } impl<'a, E: EthSpec> From> for QueuedAttestation { @@ -249,6 +295,7 @@ impl<'a, E: EthSpec> From> for QueuedAttestation { attesting_indices: a.attesting_indices_to_vec(), block_root: a.data().beacon_block_root, target_epoch: a.data().target.epoch, + payload_present: a.data().index == 1, } } } @@ -366,21 +413,32 @@ where AttestationShufflingId::new(anchor_block_root, anchor_state, RelativeEpoch::Next) .map_err(Error::BeaconStateError)?; - let execution_status = anchor_block.message().execution_payload().map_or_else( - // If the block doesn't have an execution payload then it can't have - // execution enabled. - |_| ExecutionStatus::irrelevant(), - |execution_payload| { + let (execution_status, execution_payload_parent_hash, execution_payload_block_hash) = + if let Ok(signed_bid) = anchor_block.message().body().signed_execution_payload_bid() { + // Gloas: execution status is irrelevant post-Gloas; payload validation + // is decoupled from beacon blocks. + ( + ExecutionStatus::irrelevant(), + Some(signed_bid.message.parent_block_hash), + Some(signed_bid.message.block_hash), + ) + } else if let Ok(execution_payload) = anchor_block.message().execution_payload() { + // Pre-Gloas forks: do not set payload hashes, they are only used post-Gloas. if execution_payload.is_default_with_empty_roots() { - // A default payload does not have execution enabled. - ExecutionStatus::irrelevant() + (ExecutionStatus::irrelevant(), None, None) } else { - // Assume that this payload is valid, since the anchor should be a trusted block and - // state. - ExecutionStatus::Valid(execution_payload.block_hash()) + // Assume that this payload is valid, since the anchor should be a + // trusted block and state. + ( + ExecutionStatus::Valid(execution_payload.block_hash()), + None, + None, + ) } - }, - ); + } else { + // Pre-merge: no execution payload at all. + (ExecutionStatus::irrelevant(), None, None) + }; // If the current slot is not provided, use the value that was last provided to the store. let current_slot = current_slot.unwrap_or_else(|| fc_store.get_current_slot()); @@ -394,6 +452,10 @@ where current_epoch_shuffling_id, next_epoch_shuffling_id, execution_status, + execution_payload_parent_hash, + execution_payload_block_hash, + anchor_block.message().proposer_index(), + spec, )?; let mut fork_choice = Self { @@ -479,7 +541,7 @@ where &mut self, system_time_current_slot: Slot, spec: &ChainSpec, - ) -> Result> { + ) -> Result<(Hash256, PayloadStatus), Error> { // Provide the slot (as per the system clock) to the `fc_store` and then return its view of // the current slot. The `fc_store` will ensure that the `current_slot` is never // decreasing, a property which we must maintain. @@ -487,7 +549,7 @@ where let store = &mut self.fc_store; - let head_root = self.proto_array.find_head::( + let (head_root, head_payload_status) = self.proto_array.find_head::( *store.justified_checkpoint(), *store.finalized_checkpoint(), store.justified_balances(), @@ -516,7 +578,7 @@ where finalized_hash, }; - Ok(head_root) + Ok((head_root, head_payload_status)) } /// Get the block to build on as proposer, taking into account proposer re-orgs. @@ -611,6 +673,20 @@ where } } + /// Mark a Gloas payload envelope as valid and received. + /// + /// This must only be called for valid Gloas payloads. + pub fn on_valid_payload_envelope_received( + &mut self, + block_root: Hash256, + ) -> Result<(), Error> { + self.proto_array + .on_valid_payload_envelope_received(block_root) + .map_err(Error::FailedToProcessValidExecutionPayload) + } + + /// Pre-Gloas only. + /// /// See `ProtoArrayForkChoice::process_execution_payload_validation` for documentation. pub fn on_valid_execution_payload( &mut self, @@ -621,6 +697,8 @@ where .map_err(Error::FailedToProcessValidExecutionPayload) } + /// Pre-Gloas only. + /// /// See `ProtoArrayForkChoice::process_execution_payload_invalidation` for documentation. pub fn on_invalid_execution_payload( &mut self, @@ -729,6 +807,11 @@ where let attestation_threshold = spec.get_unaggregated_attestation_due(); // Add proposer score boost if the block is timely. + // TODO(gloas): the spec's `update_proposer_boost_root` additionally checks that + // `block.proposer_index == get_beacon_proposer_index(head_state)` — i.e. that + // the block's proposer matches the expected proposer on the canonical chain. + // This requires calling `get_head` and advancing the head state to the current + // slot, which is expensive. Implement once we have a cached proposer index. let is_before_attesting_interval = block_delay < attestation_threshold; let is_first_block = self.fc_store.proposer_boost_root().is_zero(); @@ -881,6 +964,16 @@ where ExecutionStatus::irrelevant() }; + let (execution_payload_parent_hash, execution_payload_block_hash) = + if let Ok(signed_bid) = block.body().signed_execution_payload_bid() { + ( + Some(signed_bid.message.parent_block_hash), + Some(signed_bid.message.block_hash), + ) + } else { + (None, None) + }; + // This does not apply a vote to the block, it just makes fork choice aware of the block so // it can still be identified as the head even if it doesn't have any votes. self.proto_array.process_block::( @@ -907,10 +1000,13 @@ where execution_status, unrealized_justified_checkpoint: Some(unrealized_justified_checkpoint), unrealized_finalized_checkpoint: Some(unrealized_finalized_checkpoint), + execution_payload_parent_hash, + execution_payload_block_hash, + proposer_index: Some(block.proposer_index()), }, current_slot, - self.justified_checkpoint(), - self.finalized_checkpoint(), + spec, + block_delay, )?; Ok(()) @@ -979,6 +1075,7 @@ where &self, indexed_attestation: IndexedAttestationRef, is_from_block: AttestationFromBlock, + spec: &ChainSpec, ) -> Result<(), InvalidAttestation> { // There is no point in processing an attestation with an empty bitfield. Reject // it immediately. @@ -1051,6 +1148,89 @@ where }); } + if spec + .fork_name_at_slot::(indexed_attestation.data().slot) + .gloas_enabled() + { + let index = indexed_attestation.data().index; + + // Post-Gloas: attestation index must be 0 or 1. + if index > 1 { + return Err(InvalidAttestation::InvalidAttestationIndex { index }); + } + + // Same-slot attestations must have index == 0. + if indexed_attestation.data().slot == block.slot && index != 0 { + return Err(InvalidAttestation::InvalidSameSlotAttestationIndex { + slot: block.slot, + }); + } + + // index == 1 (payload_present) requires the block's payload to have been received. + // TODO(gloas): could optimise by adding `payload_received` to `Block` + if index == 1 + && !self + .proto_array + .is_payload_received(&indexed_attestation.data().beacon_block_root) + { + return Err(InvalidAttestation::PayloadNotReceived { + beacon_block_root: indexed_attestation.data().beacon_block_root, + }); + } + } + + Ok(()) + } + + /// Validates a payload attestation for application to fork choice. + fn validate_on_payload_attestation( + &self, + indexed_payload_attestation: &IndexedPayloadAttestation, + is_from_block: AttestationFromBlock, + ) -> Result<(), InvalidPayloadAttestation> { + // This check is from `is_valid_indexed_payload_attestation`, but we do it immediately to + // avoid wasting time on junk attestations. + if indexed_payload_attestation.attesting_indices.is_empty() { + return Err(InvalidPayloadAttestation::EmptyAggregationBitfield); + } + + // PTC attestation must be for a known block. If block is unknown, delay consideration until + // the block is found (responsibility of caller). + let block = self + .proto_array + .get_block(&indexed_payload_attestation.data.beacon_block_root) + .ok_or(InvalidPayloadAttestation::UnknownHeadBlock { + beacon_block_root: indexed_payload_attestation.data.beacon_block_root, + })?; + + // Not strictly part of the spec, but payload attestations to future slots are MORE INVALID + // than payload attestations to blocks at previous slots. + if block.slot > indexed_payload_attestation.data.slot { + return Err(InvalidPayloadAttestation::AttestsToFutureBlock { + block: block.slot, + attestation: indexed_payload_attestation.data.slot, + }); + } + + // PTC votes can only change the vote for their assigned beacon block, return early otherwise + if block.slot != indexed_payload_attestation.data.slot { + return Ok(()); + } + + // Gossip payload attestations must be for the current slot. + // NOTE: signature is assumed to have been verified by caller. + // https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/fork-choice.md + if matches!(is_from_block, AttestationFromBlock::False) + && indexed_payload_attestation.data.slot != self.fc_store.get_current_slot() + { + return Err( + InvalidPayloadAttestation::PayloadAttestationNotCurrentSlot { + attestation_slot: indexed_payload_attestation.data.slot, + current_slot: self.fc_store.get_current_slot(), + }, + ); + } + Ok(()) } @@ -1076,6 +1256,7 @@ where system_time_current_slot: Slot, attestation: IndexedAttestationRef, is_from_block: AttestationFromBlock, + spec: &ChainSpec, ) -> Result<(), Error> { let _timer = metrics::start_timer(&metrics::FORK_CHOICE_ON_ATTESTATION_TIMES); @@ -1098,14 +1279,21 @@ where return Ok(()); } - self.validate_on_attestation(attestation, is_from_block)?; + self.validate_on_attestation(attestation, is_from_block, spec)?; + + // Per Gloas spec: `payload_present = attestation.data.index == 1`. + let payload_present = spec + .fork_name_at_slot::(attestation.data().slot) + .gloas_enabled() + && attestation.data().index == 1; if attestation.data().slot < self.fc_store.get_current_slot() { for validator_index in attestation.attesting_indices_iter() { self.proto_array.process_attestation( *validator_index as usize, attestation.data().beacon_block_root, - attestation.data().target.epoch, + attestation.data().slot, + payload_present, )?; } } else { @@ -1122,6 +1310,59 @@ where Ok(()) } + /// Register a payload attestation with the fork choice DAG. + /// + /// `ptc` is the PTC committee for the attestation's slot: a list of validator indices + /// ordered by committee position. Each attesting validator index is resolved to its + /// position within `ptc` (its `ptc_index`) before being applied to the proto-array. + pub fn on_payload_attestation( + &mut self, + system_time_current_slot: Slot, + attestation: &IndexedPayloadAttestation, + is_from_block: AttestationFromBlock, + ptc: &[usize], + ) -> Result<(), Error> { + self.update_time(system_time_current_slot)?; + + if attestation.data.beacon_block_root.is_zero() { + return Ok(()); + } + + // TODO(gloas): Should ignore wrong-slot payload attestations at the caller, they could + // have been processed at the correct slot when received on gossip, but then have the + // wrong-slot by the time they make it to here (TOCTOU). + self.validate_on_payload_attestation(attestation, is_from_block)?; + + // Resolve validator indices to PTC committee positions. + let ptc_indices: Vec = attestation + .attesting_indices + .iter() + .filter_map(|vi| ptc.iter().position(|&p| p == *vi as usize)) + .collect(); + + // Check that all the attesters are in the PTC + if ptc_indices.len() != attestation.attesting_indices.len() { + return Err( + InvalidPayloadAttestation::PayloadAttestationAttestersNotInPtc { + attesting_indices_len: attestation.attesting_indices.len(), + attesting_indices_in_ptc: ptc_indices.len(), + } + .into(), + ); + } + + for &ptc_index in &ptc_indices { + self.proto_array.process_payload_attestation( + attestation.data.beacon_block_root, + ptc_index, + attestation.data.payload_present, + attestation.data.blob_data_available, + )?; + } + + Ok(()) + } + /// Apply an attester slashing to fork choice. /// /// We assume that the attester slashing provided to this function has already been verified. @@ -1228,7 +1469,8 @@ where self.proto_array.process_attestation( *validator_index as usize, attestation.block_root, - attestation.target_epoch, + attestation.slot, + attestation.payload_present, )?; } } @@ -1358,13 +1600,15 @@ where /// Returns the latest message for a given validator, if any. /// - /// Returns `(block_root, block_slot)`. + /// Returns `block_root, block_slot, payload_present`. /// /// ## Notes /// /// It may be prudent to call `Self::update_time` before calling this function, /// since some attestations might be queued and awaiting processing. - pub fn latest_message(&self, validator_index: usize) -> Option<(Hash256, Epoch)> { + /// + /// This function is only used in tests. + pub fn latest_message(&self, validator_index: usize) -> Option { self.proto_array.latest_message(validator_index) } @@ -1409,7 +1653,6 @@ where persisted_proto_array: proto_array::core::SszContainer, justified_balances: JustifiedBalances, reset_payload_statuses: ResetPayloadStatuses, - spec: &ChainSpec, ) -> Result> { let mut proto_array = ProtoArrayForkChoice::from_container( persisted_proto_array.clone(), @@ -1434,7 +1677,7 @@ where // Reset all blocks back to being "optimistic". This helps recover from an EL consensus // fault where an invalid payload becomes valid. - if let Err(e) = proto_array.set_all_blocks_to_optimistic::(spec) { + if let Err(e) = proto_array.set_all_blocks_to_optimistic::() { // If there is an error resetting the optimistic status then log loudly and revert // back to a proto-array which does not have the reset applied. This indicates a // significant error in Lighthouse and warrants detailed investigation. @@ -1464,7 +1707,6 @@ where persisted.proto_array, justified_balances, reset_payload_statuses, - spec, )?; let current_slot = fc_store.get_current_slot(); @@ -1472,7 +1714,7 @@ where let mut fork_choice = Self { fc_store, proto_array, - queued_attestations: persisted.queued_attestations, + queued_attestations: vec![], // Will be updated in the following call to `Self::get_head`. forkchoice_update_parameters: ForkchoiceUpdateParameters { head_hash: None, @@ -1498,7 +1740,7 @@ where // get a different result. fork_choice .proto_array - .set_all_blocks_to_optimistic::(spec)?; + .set_all_blocks_to_optimistic::()?; // If the second attempt at finding a head fails, return an error since we do not // expect this scenario. fork_choice.get_head(current_slot, spec)?; @@ -1511,10 +1753,7 @@ where /// be instantiated again later. pub fn to_persisted(&self) -> PersistedForkChoice { PersistedForkChoice { - proto_array: self - .proto_array() - .as_ssz_container(self.justified_checkpoint(), self.finalized_checkpoint()), - queued_attestations: self.queued_attestations().to_vec(), + proto_array: self.proto_array().as_ssz_container(), } } @@ -1528,16 +1767,37 @@ where /// /// This is used when persisting the state of the fork choice to disk. #[superstruct( - variants(V28), + variants(V28, V29), variant_attributes(derive(Encode, Decode, Clone)), no_enum )] pub struct PersistedForkChoice { - pub proto_array: proto_array::core::SszContainerV28, - pub queued_attestations: Vec, + #[superstruct(only(V28))] + pub proto_array_v28: proto_array::core::SszContainerV28, + #[superstruct(only(V29))] + pub proto_array: proto_array::core::SszContainerV29, + #[superstruct(only(V28))] + pub queued_attestations_v28: Vec, } -pub type PersistedForkChoice = PersistedForkChoiceV28; +pub type PersistedForkChoice = PersistedForkChoiceV29; + +impl From for PersistedForkChoiceV29 { + fn from(v28: PersistedForkChoiceV28) -> Self { + Self { + proto_array: v28.proto_array_v28.into(), + } + } +} + +impl From for PersistedForkChoiceV28 { + fn from(v29: PersistedForkChoiceV29) -> Self { + Self { + proto_array_v28: v29.proto_array.into(), + queued_attestations_v28: vec![], + } + } +} #[cfg(test)] mod tests { @@ -1574,6 +1834,7 @@ mod tests { attesting_indices: vec![], block_root: Hash256::zero(), target_epoch: Epoch::new(0), + payload_present: false, }) .collect() } diff --git a/consensus/fork_choice/src/lib.rs b/consensus/fork_choice/src/lib.rs index 8cf2936db4..159eab0ec0 100644 --- a/consensus/fork_choice/src/lib.rs +++ b/consensus/fork_choice/src/lib.rs @@ -4,10 +4,11 @@ mod metrics; pub use crate::fork_choice::{ AttestationFromBlock, Error, ForkChoice, ForkChoiceView, ForkchoiceUpdateParameters, - InvalidAttestation, InvalidBlock, PayloadVerificationStatus, PersistedForkChoice, - PersistedForkChoiceV28, QueuedAttestation, ResetPayloadStatuses, + InvalidAttestation, InvalidBlock, InvalidPayloadAttestation, PayloadVerificationStatus, + PersistedForkChoice, PersistedForkChoiceV28, PersistedForkChoiceV29, QueuedAttestation, + ResetPayloadStatuses, }; pub use fork_choice_store::ForkChoiceStore; pub use proto_array::{ - Block as ProtoBlock, ExecutionStatus, InvalidationOperation, ProposerHeadError, + Block as ProtoBlock, ExecutionStatus, InvalidationOperation, PayloadStatus, ProposerHeadError, }; diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index d3a84ee85b..d6f937c0ca 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -7,9 +7,11 @@ use beacon_chain::{ BeaconChain, BeaconChainError, BeaconForkChoiceStore, ChainConfig, ForkChoiceError, StateSkipConfig, WhenSlotSkipped, }; +use bls::AggregateSignature; use fixed_bytes::FixedBytesExtended; use fork_choice::{ - ForkChoiceStore, InvalidAttestation, InvalidBlock, PayloadVerificationStatus, QueuedAttestation, + AttestationFromBlock, ForkChoiceStore, InvalidAttestation, InvalidBlock, + InvalidPayloadAttestation, PayloadVerificationStatus, QueuedAttestation, }; use state_processing::state_advance::complete_state_advance; use std::fmt; @@ -19,8 +21,8 @@ use store::MemoryStore; use types::SingleAttestation; use types::{ BeaconBlockRef, BeaconState, ChainSpec, Checkpoint, Epoch, EthSpec, ForkName, Hash256, - IndexedAttestation, MainnetEthSpec, RelativeEpoch, SignedBeaconBlock, Slot, SubnetId, - test_utils::generate_deterministic_keypair, + IndexedAttestation, IndexedPayloadAttestation, MainnetEthSpec, PayloadAttestationData, + RelativeEpoch, SignedBeaconBlock, Slot, SubnetId, test_utils::generate_deterministic_keypair, }; pub type E = MainnetEthSpec; @@ -71,6 +73,9 @@ impl ForkChoiceTest { Self { harness } } + /// Creates a new tester with the Gloas fork active at epoch 1. + /// Genesis is a standard Fulu block (epoch 0), so block production works normally. + /// Tests that need Gloas semantics should advance the chain into epoch 1 first. /// Get a value from the `ForkChoice` instantiation. fn get(&self, func: T) -> U where @@ -923,6 +928,56 @@ async fn invalid_attestation_future_block() { .await; } +/// Gossip payload attestations must be for the current slot. A payload attestation for slot S +/// received at slot S+1 should be rejected per the spec. +#[tokio::test] +async fn non_block_payload_attestation_for_previous_slot_is_rejected() { + let test = ForkChoiceTest::new() + .apply_blocks_without_new_attestations(1) + .await; + + let chain = &test.harness.chain; + let block_a = chain + .block_at_slot(Slot::new(1), WhenSlotSkipped::Prev) + .expect("lookup should succeed") + .expect("block A should exist"); + let block_a_root = block_a.canonical_root(); + let s_plus_1 = block_a.slot().saturating_add(1_u64); + + let payload_attestation = IndexedPayloadAttestation:: { + attesting_indices: vec![0_u64].try_into().expect("valid attesting indices"), + data: PayloadAttestationData { + beacon_block_root: block_a_root, + slot: Slot::new(1), + payload_present: true, + blob_data_available: true, + }, + signature: AggregateSignature::empty(), + }; + + let ptc = &[0_usize]; + + let result = chain + .canonical_head + .fork_choice_write_lock() + .on_payload_attestation( + s_plus_1, + &payload_attestation, + AttestationFromBlock::False, + ptc, + ); + assert!( + matches!( + result, + Err(ForkChoiceError::InvalidPayloadAttestation( + InvalidPayloadAttestation::PayloadAttestationNotCurrentSlot { .. } + )) + ), + "gossip payload attestation for previous slot should be rejected, got: {:?}", + result + ); +} + /// Specification v0.12.1: /// /// assert target.root == get_ancestor(store, attestation.data.beacon_block_root, target_slot) diff --git a/consensus/proto_array/Cargo.toml b/consensus/proto_array/Cargo.toml index 7419ad813b..ee86277f9c 100644 --- a/consensus/proto_array/Cargo.toml +++ b/consensus/proto_array/Cargo.toml @@ -14,6 +14,8 @@ ethereum_ssz_derive = { workspace = true } fixed_bytes = { workspace = true } safe_arith = { workspace = true } serde = { workspace = true } +smallvec = { workspace = true } superstruct = { workspace = true } +typenum = { workspace = true } types = { workspace = true } yaml_serde = { workspace = true } diff --git a/consensus/proto_array/src/error.rs b/consensus/proto_array/src/error.rs index 35cb4007b7..bb47af97d9 100644 --- a/consensus/proto_array/src/error.rs +++ b/consensus/proto_array/src/error.rs @@ -54,6 +54,14 @@ pub enum Error { }, InvalidEpochOffset(u64), Arith(ArithError), + InvalidNodeVariant { + block_root: Hash256, + }, + BrokenBlock { + block_root: Hash256, + }, + NoViableChildren, + OnBlockRequiresProposerIndex, } impl From for Error { diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index e9deb6759f..c9764d3e44 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -1,20 +1,24 @@ mod execution_status; mod ffg_updates; +mod gloas_payload; mod no_votes; mod votes; -use crate::proto_array_fork_choice::{Block, ExecutionStatus, ProtoArrayForkChoice}; +use crate::proto_array_fork_choice::{Block, ExecutionStatus, PayloadStatus, ProtoArrayForkChoice}; use crate::{InvalidationOperation, JustifiedBalances}; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; +use ssz::BitVector; use std::collections::BTreeSet; +use std::time::Duration; use types::{ - AttestationShufflingId, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, + AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, MainnetEthSpec, Slot, }; pub use execution_status::*; pub use ffg_updates::*; +pub use gloas_payload::*; pub use no_votes::*; pub use votes::*; @@ -25,6 +29,9 @@ pub enum Operation { finalized_checkpoint: Checkpoint, justified_state_balances: Vec, expected_head: Hash256, + current_slot: Slot, + #[serde(default)] + expected_payload_status: Option, }, ProposerBoostFindHead { justified_checkpoint: Checkpoint, @@ -44,11 +51,23 @@ pub enum Operation { parent_root: Hash256, justified_checkpoint: Checkpoint, finalized_checkpoint: Checkpoint, + #[serde(default)] + execution_payload_parent_hash: Option, + #[serde(default)] + execution_payload_block_hash: Option, }, ProcessAttestation { validator_index: usize, block_root: Hash256, - target_epoch: Epoch, + attestation_slot: Slot, + }, + ProcessPayloadAttestation { + validator_index: usize, + block_root: Hash256, + attestation_slot: Slot, + payload_present: bool, + #[serde(default)] + blob_data_available: bool, }, Prune { finalized_root: Hash256, @@ -63,6 +82,29 @@ pub enum Operation { block_root: Hash256, weight: u64, }, + AssertPayloadWeights { + block_root: Hash256, + expected_full_weight: u64, + expected_empty_weight: u64, + }, + AssertParentPayloadStatus { + block_root: Hash256, + expected_status: PayloadStatus, + }, + SetPayloadTiebreak { + block_root: Hash256, + is_timely: bool, + is_data_available: bool, + }, + /// Simulate receiving and validating an execution payload for `block_root`. + /// Sets `payload_received = true` on the V29 node via the live validation path. + ProcessExecutionPayloadEnvelope { + block_root: Hash256, + }, + AssertPayloadReceived { + block_root: Hash256, + expected: bool, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -71,12 +113,23 @@ pub struct ForkChoiceTestDefinition { pub justified_checkpoint: Checkpoint, pub finalized_checkpoint: Checkpoint, pub operations: Vec, + #[serde(default)] + pub execution_payload_parent_hash: Option, + #[serde(default)] + pub execution_payload_block_hash: Option, + #[serde(skip)] + pub spec: Option, } impl ForkChoiceTestDefinition { pub fn run(self) { - let mut spec = MainnetEthSpec::default_spec(); - spec.proposer_score_boost = Some(50); + let spec = self.spec.unwrap_or_else(|| { + let mut spec = MainnetEthSpec::default_spec(); + spec.proposer_score_boost = Some(50); + // Legacy test definitions target pre-Gloas behaviour unless explicitly overridden. + spec.gloas_fork_epoch = None; + spec + }); let junk_shuffling_id = AttestationShufflingId::from_components(Epoch::new(0), Hash256::zero()); @@ -89,6 +142,10 @@ impl ForkChoiceTestDefinition { junk_shuffling_id.clone(), junk_shuffling_id, ExecutionStatus::Optimistic(ExecutionBlockHash::zero()), + self.execution_payload_parent_hash, + self.execution_payload_block_hash, + 0, + &spec, ) .expect("should create fork choice struct"); let equivocating_indices = BTreeSet::new(); @@ -100,18 +157,20 @@ impl ForkChoiceTestDefinition { finalized_checkpoint, justified_state_balances, expected_head, + current_slot, + expected_payload_status, } => { let justified_balances = JustifiedBalances::from_effective_balances(justified_state_balances) .unwrap(); - let head = fork_choice + let (head, payload_status) = fork_choice .find_head::( justified_checkpoint, finalized_checkpoint, &justified_balances, Hash256::zero(), &equivocating_indices, - Slot::new(0), + current_slot, &spec, ) .unwrap_or_else(|e| { @@ -123,6 +182,13 @@ impl ForkChoiceTestDefinition { "Operation at index {} failed head check. Operation: {:?}", op_index, op ); + if let Some(expected_status) = expected_payload_status { + assert_eq!( + payload_status, expected_status, + "Operation at index {} failed payload status check. Operation: {:?}", + op_index, op + ); + } check_bytes_round_trip(&fork_choice); } Operation::ProposerBoostFindHead { @@ -135,7 +201,7 @@ impl ForkChoiceTestDefinition { let justified_balances = JustifiedBalances::from_effective_balances(justified_state_balances) .unwrap(); - let head = fork_choice + let (head, _payload_status) = fork_choice .find_head::( justified_checkpoint, finalized_checkpoint, @@ -188,6 +254,8 @@ impl ForkChoiceTestDefinition { parent_root, justified_checkpoint, finalized_checkpoint, + execution_payload_parent_hash, + execution_payload_block_hash, } => { let block = Block { slot, @@ -211,14 +279,12 @@ impl ForkChoiceTestDefinition { ), unrealized_justified_checkpoint: None, unrealized_finalized_checkpoint: None, + execution_payload_parent_hash, + execution_payload_block_hash, + proposer_index: Some(0), }; fork_choice - .process_block::( - block, - slot, - self.justified_checkpoint, - self.finalized_checkpoint, - ) + .process_block::(block, slot, &spec, Duration::ZERO) .unwrap_or_else(|e| { panic!( "process_block op at index {} returned error: {:?}", @@ -230,10 +296,10 @@ impl ForkChoiceTestDefinition { Operation::ProcessAttestation { validator_index, block_root, - target_epoch, + attestation_slot, } => { fork_choice - .process_attestation(validator_index, block_root, target_epoch) + .process_attestation(validator_index, block_root, attestation_slot, false) .unwrap_or_else(|_| { panic!( "process_attestation op at index {} returned error", @@ -242,6 +308,28 @@ impl ForkChoiceTestDefinition { }); check_bytes_round_trip(&fork_choice); } + Operation::ProcessPayloadAttestation { + validator_index, + block_root, + attestation_slot: _, + payload_present, + blob_data_available, + } => { + fork_choice + .process_payload_attestation( + block_root, + validator_index, + payload_present, + blob_data_available, + ) + .unwrap_or_else(|_| { + panic!( + "process_payload_attestation op at index {} returned error", + op_index + ) + }); + check_bytes_round_trip(&fork_choice); + } Operation::Prune { finalized_root, prune_threshold, @@ -287,8 +375,153 @@ impl ForkChoiceTestDefinition { Operation::AssertWeight { block_root, weight } => assert_eq!( fork_choice.get_weight(&block_root).unwrap(), weight, - "block weight" + "block weight at op index {}", + op_index ), + Operation::AssertPayloadWeights { + block_root, + expected_full_weight, + expected_empty_weight, + } => { + let block_index = fork_choice + .proto_array + .indices + .get(&block_root) + .unwrap_or_else(|| { + panic!( + "AssertPayloadWeights: block root not found at op index {}", + op_index + ) + }); + let node = fork_choice + .proto_array + .nodes + .get(*block_index) + .unwrap_or_else(|| { + panic!( + "AssertPayloadWeights: node not found at op index {}", + op_index + ) + }); + let v29 = node.as_v29().unwrap_or_else(|_| { + panic!( + "AssertPayloadWeights: node is not V29 at op index {}", + op_index + ) + }); + assert_eq!( + v29.full_payload_weight, expected_full_weight, + "full_payload_weight mismatch at op index {}", + op_index + ); + assert_eq!( + v29.empty_payload_weight, expected_empty_weight, + "empty_payload_weight mismatch at op index {}", + op_index + ); + } + Operation::AssertParentPayloadStatus { + block_root, + expected_status, + } => { + let block_index = fork_choice + .proto_array + .indices + .get(&block_root) + .unwrap_or_else(|| { + panic!( + "AssertParentPayloadStatus: block root not found at op index {}", + op_index + ) + }); + let node = fork_choice + .proto_array + .nodes + .get(*block_index) + .unwrap_or_else(|| { + panic!( + "AssertParentPayloadStatus: node not found at op index {}", + op_index + ) + }); + let v29 = node.as_v29().unwrap_or_else(|_| { + panic!( + "AssertParentPayloadStatus: node is not V29 at op index {}", + op_index + ) + }); + assert_eq!( + v29.parent_payload_status, expected_status, + "parent_payload_status mismatch at op index {}", + op_index + ); + } + Operation::SetPayloadTiebreak { + block_root, + is_timely, + is_data_available, + } => { + let block_index = fork_choice + .proto_array + .indices + .get(&block_root) + .unwrap_or_else(|| { + panic!( + "SetPayloadTiebreak: block root not found at op index {}", + op_index + ) + }); + let node = fork_choice + .proto_array + .nodes + .get_mut(*block_index) + .unwrap_or_else(|| { + panic!( + "SetPayloadTiebreak: node not found at op index {}", + op_index + ) + }); + let node_v29 = node.as_v29_mut().unwrap_or_else(|_| { + panic!( + "SetPayloadTiebreak: node is not V29 at op index {}", + op_index + ) + }); + // Set all bits (exceeds any threshold) or clear all bits. + let fill = if is_timely { 0xFF } else { 0x00 }; + node_v29.payload_timeliness_votes = + BitVector::from_bytes(smallvec::smallvec![fill; 64]) + .expect("valid 512-bit bitvector"); + let fill = if is_data_available { 0xFF } else { 0x00 }; + node_v29.payload_data_availability_votes = + BitVector::from_bytes(smallvec::smallvec![fill; 64]) + .expect("valid 512-bit bitvector"); + // Per spec, is_payload_timely/is_payload_data_available require + // the payload to be in payload_states (payload_received). + node_v29.payload_received = is_timely || is_data_available; + } + Operation::ProcessExecutionPayloadEnvelope { block_root } => { + fork_choice + .on_valid_payload_envelope_received(block_root) + .unwrap_or_else(|e| { + panic!( + "on_execution_payload op at index {} returned error: {}", + op_index, e + ) + }); + check_bytes_round_trip(&fork_choice); + } + Operation::AssertPayloadReceived { + block_root, + expected, + } => { + let actual = fork_choice.is_payload_received(&block_root); + assert_eq!( + actual, expected, + "payload_received mismatch at op index {}", + op_index + ); + } } } } @@ -314,8 +547,7 @@ fn get_checkpoint(i: u64) -> Checkpoint { } fn check_bytes_round_trip(original: &ProtoArrayForkChoice) { - // The checkpoint are ignored `ProtoArrayForkChoice::from_bytes` so any value is ok - let bytes = original.as_bytes(Checkpoint::default(), Checkpoint::default()); + let bytes = original.as_bytes(); let decoded = ProtoArrayForkChoice::from_bytes(&bytes, original.balances.clone()) .expect("fork choice should decode from bytes"); assert!( diff --git a/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs b/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs index aa26a84306..794310ef89 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/execution_status.rs @@ -16,6 +16,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 2. @@ -35,6 +37,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -53,6 +57,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -73,6 +79,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -91,6 +99,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to block 1 @@ -101,7 +111,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is now 1, because 1 has a vote. @@ -120,6 +130,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -143,7 +155,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -162,6 +174,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -196,6 +210,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -216,6 +232,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -245,7 +263,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Ensure that the head is still 2 @@ -266,6 +284,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -315,6 +335,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Invalidation of 3 should have removed upstream weight. @@ -347,7 +369,7 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(1), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Ensure that the head has switched back to 1 @@ -368,6 +390,8 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -399,6 +423,9 @@ pub fn get_execution_status_test_definition_01() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } @@ -418,6 +445,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 2. @@ -437,6 +466,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -455,6 +486,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -475,6 +508,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -493,6 +528,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to block 1 @@ -503,7 +540,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is now 1, because 1 has a vote. @@ -522,6 +559,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -545,7 +584,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -564,6 +603,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -598,6 +639,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -618,6 +661,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -647,7 +692,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Move validator #1 vote from 2 to 3 @@ -660,7 +705,7 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Ensure that the head is now 3. @@ -681,6 +726,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -730,6 +777,8 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Invalidation of 3 should have removed upstream weight. @@ -763,6 +812,9 @@ pub fn get_execution_status_test_definition_02() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } @@ -782,6 +834,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 2. @@ -801,6 +855,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -819,6 +875,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -839,6 +897,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -857,6 +917,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to block 1 @@ -867,7 +929,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is now 1, because 1 has a vote. @@ -886,6 +948,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -909,7 +973,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is 1. @@ -928,6 +992,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); ops.push(Operation::AssertWeight { @@ -962,6 +1028,8 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is now 3, applying a proposer boost to 3 as well. @@ -985,13 +1053,15 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { proposer_boost_root: get_root(3), }); + // Stored weights are pure attestation scores (proposer boost is applied + // on-the-fly in the walk's `get_weight`, not baked into `node.weight()`). ops.push(Operation::AssertWeight { block_root: get_root(0), - weight: 33_250, + weight: 2_000, }); ops.push(Operation::AssertWeight { block_root: get_root(1), - weight: 33_250, + weight: 2_000, }); ops.push(Operation::AssertWeight { block_root: get_root(2), @@ -999,8 +1069,7 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { }); ops.push(Operation::AssertWeight { block_root: get_root(3), - // This is a "magic number" generated from `calculate_committee_fraction`. - weight: 31_250, + weight: 0, }); // Invalidate the payload of 3. @@ -1065,6 +1134,9 @@ pub fn get_execution_status_test_definition_03() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs b/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs index 3b31616145..76f9a95315 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/ffg_updates.rs @@ -10,6 +10,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Build the following tree (stick? lol). @@ -27,6 +29,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { 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, }); ops.push(Operation::ProcessBlock { slot: Slot::new(2), @@ -34,6 +38,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(1), justified_checkpoint: get_checkpoint(1), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(3), @@ -41,6 +47,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(2), justified_checkpoint: get_checkpoint(2), finalized_checkpoint: get_checkpoint(1), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that with justified epoch 0 we find 3 @@ -57,6 +65,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that with justified epoch 1 we find 3 @@ -77,6 +87,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that with justified epoch 2 we find 3 @@ -93,6 +105,8 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(1), justified_state_balances: balances, expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, }); // END OF TESTS @@ -101,6 +115,9 @@ pub fn get_ffg_case_01_test_definition() -> ForkChoiceTestDefinition { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } @@ -114,6 +131,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Build the following tree. @@ -137,6 +156,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { 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, }); ops.push(Operation::ProcessBlock { slot: Slot::new(2), @@ -147,6 +168,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(1), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(3), @@ -157,6 +180,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(1), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(4), @@ -167,6 +192,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(1), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(5), @@ -177,6 +204,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(3), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Right branch @@ -186,6 +215,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { 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, }); ops.push(Operation::ProcessBlock { slot: Slot::new(2), @@ -193,6 +224,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(2), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(3), @@ -200,6 +233,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { parent_root: get_root(4), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(4), @@ -210,6 +245,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(2), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(5), @@ -220,6 +257,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { root: get_root(4), }, finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that if we start at 0 we find 10 (just: 0, fin: 0). @@ -240,6 +279,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above, but with justified epoch 2. ops.push(Operation::FindHead { @@ -250,6 +291,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above, but with justified epoch 3. // @@ -264,6 +307,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to 1. @@ -282,7 +327,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(0), + attestation_slot: Slot::new(0), }); // Ensure that if we start at 0 we find 9 (just: 0, fin: 0). @@ -303,6 +348,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Save as above but justified epoch 2. ops.push(Operation::FindHead { @@ -313,6 +360,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Save as above but justified epoch 3. // @@ -327,6 +376,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to 2. @@ -345,7 +396,7 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(0), + attestation_slot: Slot::new(0), }); // Ensure that if we start at 0 we find 10 (just: 0, fin: 0). @@ -366,6 +417,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 2. ops.push(Operation::FindHead { @@ -376,6 +429,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 3. // @@ -390,6 +445,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that if we start at 1 we find 9 (just: 0, fin: 0). @@ -413,6 +470,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 2. ops.push(Operation::FindHead { @@ -423,6 +482,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 3. // @@ -437,6 +498,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that if we start at 2 we find 10 (just: 0, fin: 0). @@ -457,6 +520,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 2. ops.push(Operation::FindHead { @@ -467,6 +532,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Same as above but justified epoch 3. // @@ -481,6 +548,8 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { finalized_checkpoint: get_checkpoint(0), justified_state_balances: balances, expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // END OF TESTS @@ -489,6 +558,9 @@ pub fn get_ffg_case_02_test_definition() -> ForkChoiceTestDefinition { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } 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 new file mode 100644 index 0000000000..ea37780795 --- /dev/null +++ b/consensus/proto_array/src/fork_choice_test_definition/gloas_payload.rs @@ -0,0 +1,893 @@ +use super::*; + +fn gloas_spec() -> ChainSpec { + let mut spec = MainnetEthSpec::default_spec(); + spec.proposer_score_boost = Some(50); + spec.gloas_fork_epoch = Some(Epoch::new(0)); + spec +} + +pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Build two branches off genesis where one child extends parent's payload chain (Full) + // and the other does not (Empty). + 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::ProcessBlock { + slot: Slot::new(1), + root: get_root(2), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // Extend both branches to verify that head selection follows the selected chain. + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + 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(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the Gloas fork choice tree. + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + }); + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + }); + + // With equal full/empty parent weights, tiebreak decides which chain to follow. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: false, + is_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(4), + current_slot: Slot::new(0), + expected_payload_status: 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()), + } +} + +pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + 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)), + }); + + // One Full and one Empty vote for the same head block: tie probes via runtime tiebreak, + // which defaults to Empty unless timely+data-available evidence is set. + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: true, + blob_data_available: false, + }); + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 1, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: false, + blob_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(1), + current_slot: Slot::new(0), + // With MainnetEthSpec PTC_SIZE=512, 1 bit set out of 256 threshold → not timely → Empty. + expected_payload_status: Some(PayloadStatus::Empty), + }); + // PTC votes write to bitfields only, not to full/empty weight. + // Weight is 0 because no CL attestations target this block. + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(1), + expected_full_weight: 0, + expected_empty_weight: 0, + }); + + // Flip validator 0 to Empty; both bits now clear. + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(3), + payload_present: false, + blob_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: Some(PayloadStatus::Empty), + }); + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(1), + expected_full_weight: 0, + expected_empty_weight: 0, + }); + + // Same-slot attestation to a new head candidate should be Pending (no payload bucket change). + // Root 5 is an Empty child of root_1 (parent_hash doesn't match root_1's block_hash), + // so it's reachable through root_1's Empty direction (root_1 has no payload_received). + ops.push(Operation::ProcessBlock { + slot: Slot::new(3), + root: get_root(5), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(101)), + execution_payload_block_hash: Some(get_hash(5)), + }); + ops.push(Operation::ProcessPayloadAttestation { + validator_index: 2, + block_root: get_root(5), + attestation_slot: Slot::new(3), + payload_present: true, + blob_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1, 1], + expected_head: get_root(5), + current_slot: Slot::new(0), + expected_payload_status: Some(PayloadStatus::Empty), + }); + ops.push(Operation::AssertPayloadWeights { + block_root: get_root(5), + expected_full_weight: 0, + expected_empty_weight: 0, + }); + + 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()), + } +} + +/// Test that CL attestation weight can flip the head between Full/Empty branches, +/// overriding the tiebreaker. +pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Competing branches with distinct payload ancestry (Full vs Empty from genesis). + 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::ProcessBlock { + slot: Slot::new(1), + root: get_root(2), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + 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(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the Gloas fork choice tree. + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // Equal branch weights: tiebreak FULL picks branch rooted at 3. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // CL attestation to Empty branch (root 4) from validator 0 → head flips to 4. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(4), + attestation_slot: Slot::new(3), + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(4), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // CL attestation back to Full branch (root 3) → head returns to 3. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(3), + attestation_slot: Slot::new(4), + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: 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()), + } +} + +/// CL attestation weight overrides payload preference tiebreaker. +pub fn get_gloas_weight_priority_over_payload_preference_test_definition() +-> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Build two branches where one child extends payload (Full) and the other doesn't (Empty). + 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::ProcessBlock { + slot: Slot::new(1), + root: get_root(2), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + 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(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the Gloas fork choice tree. + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // Parent prefers Full on equal branch weights (tiebreaker). + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // Two CL attestations to the Empty branch make it strictly heavier, + // overriding the Full tiebreaker. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(4), + attestation_slot: Slot::new(3), + }); + ops.push(Operation::ProcessAttestation { + validator_index: 1, + block_root: get_root(4), + attestation_slot: Slot::new(3), + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(4), + current_slot: Slot::new(0), + expected_payload_status: 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()), + } +} + +pub fn get_gloas_parent_empty_when_child_points_to_grandparent_test_definition() +-> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Build a three-block chain A -> B -> C (CL parent links). + // A: EL parent = genesis hash(0), EL hash = hash(1). + 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)), + }); + + // B: EL parent = hash(1), EL hash = hash(2). + 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)), + }); + + // C: CL parent is B, but EL parent points to A (hash 1), not B (hash 2). + // This models B's payload not arriving in time, so C records parent status as Empty. + 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(1)), + execution_payload_block_hash: Some(get_hash(3)), + }); + + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(3), + expected_status: PayloadStatus::Empty, + }); + + 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()), + } +} + +/// Test interleaving of blocks, regular attestations, and tiebreaker. +/// +/// genesis → block 1 (Full) → block 3 +/// → block 2 (Empty) → block 4 +/// +/// With equal CL weight, tiebreaker determines which branch wins. +/// An extra CL attestation can override the tiebreaker. +pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Step 1: Two competing blocks at slot 1. + 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::ProcessBlock { + slot: Slot::new(1), + root: get_root(2), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // Step 2: Regular attestations arrive, one per branch (equal CL weight). + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(1), + }); + ops.push(Operation::ProcessAttestation { + validator_index: 1, + block_root: get_root(2), + attestation_slot: Slot::new(1), + }); + + // Step 3: Child blocks at slot 2. + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + 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(3)), + }); + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(4), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(4)), + }); + + // Mark root_1 as having received its execution payload so that + // its FULL virtual node exists in the Gloas fork choice tree. + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(1), + }); + + // Step 4: Set tiebreaker to Empty on genesis → Empty branch wins. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: false, + is_data_available: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(4), + current_slot: Slot::new(1), + expected_payload_status: None, + }); + + // Step 5: Flip tiebreaker to Full → Full branch wins. + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(3), + current_slot: Slot::new(100), + expected_payload_status: None, + }); + + // Step 6: Add extra CL weight to Empty branch → overrides Full tiebreaker. + ops.push(Operation::ProcessAttestation { + validator_index: 2, + block_root: get_root(4), + attestation_slot: Slot::new(3), + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1, 1], + expected_head: get_root(4), + current_slot: Slot::new(100), + expected_payload_status: 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()), + } +} + +/// Test interleaving of blocks, payload validation, and attestations. +/// +/// Scenario: +/// - Genesis block (slot 0) +/// - Block 1 (slot 1) extends genesis, Full chain +/// - Block 2 (slot 1) extends genesis, Empty chain +/// - Before payload arrives: payload_received is false for block 1 +/// - Process execution payload for block 1 → payload_received becomes true +/// - Payload attestations arrive voting block 1's payload as timely + available +/// - Head should follow block 1 because the PTC votes now count (payload_received = true) +pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1: extends genesis Full chain. + 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)), + }); + + // Block 2 at slot 1: extends genesis Empty chain (parent_hash doesn't match genesis EL hash). + ops.push(Operation::ProcessBlock { + slot: Slot::new(1), + root: get_root(2), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(99)), + execution_payload_block_hash: Some(get_hash(100)), + }); + + // Both children have parent_payload_status set correctly. + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + }); + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + }); + + // Per spec `get_forkchoice_store`: genesis starts with payload_received=true + // (anchor block is in `payload_states`). + ops.push(Operation::AssertPayloadReceived { + block_root: get_root(0), + expected: true, + }); + + // Give one vote to each child so they have equal weight. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(1), + }); + ops.push(Operation::ProcessAttestation { + validator_index: 1, + block_root: get_root(2), + attestation_slot: Slot::new(1), + }); + + // Equal weight, payload_received=true on genesis → tiebreaker uses + // payload_received (not previous slot, equal payload weights) → prefers Full. + // Block 1 (Full) wins because it matches the Full preference. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(1), + current_slot: Slot::new(100), + expected_payload_status: None, + }); + + // ProcessExecutionPayloadEnvelope on genesis is a no-op (already received at init). + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(0), + }); + + ops.push(Operation::AssertPayloadReceived { + block_root: get_root(0), + expected: true, + }); + + // Set PTC votes on genesis as timely + data available (simulates PTC voting). + // This doesn't change the preference since genesis is not the previous slot + // (slot 0 + 1 != current_slot 100). + ops.push(Operation::SetPayloadTiebreak { + block_root: get_root(0), + is_timely: true, + is_data_available: true, + }); + + // Still prefers Full via payload_received tiebreaker → Block 1 (Full) wins. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1], + expected_head: get_root(1), + current_slot: Slot::new(100), + expected_payload_status: 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()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gloas_fork_boundary_spec() -> ChainSpec { + let mut spec = MainnetEthSpec::default_spec(); + spec.proposer_score_boost = Some(50); + spec.gloas_fork_epoch = Some(Epoch::new(1)); + spec + } + + /// Gloas fork boundary: a chain starting pre-Gloas (V17 nodes) that crosses into + /// Gloas (V29 nodes). The head should advance through the fork boundary. + /// + /// Parameters: + /// - `skip_first_gloas_slot`: if true, there is no block at the first Gloas slot (slot 32); + /// the first V29 block appears at slot 33. + /// - `first_gloas_block_full`: if true, the first V29 block extends the parent V17 node's + /// EL chain (Full parent payload status). If false, it doesn't (Empty). + fn get_gloas_fork_boundary_test_definition( + skip_first_gloas_slot: bool, + first_gloas_block_full: bool, + ) -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block at slot 31 — last pre-Gloas slot. Created as a V17 node because + // gloas_fork_epoch = 1 → Gloas starts at slot 32. + // + // The test harness sets execution_status = Optimistic(ExecutionBlockHash::from_root(root)), + // so this V17 node's EL block hash = ExecutionBlockHash::from_root(get_root(1)). + ops.push(Operation::ProcessBlock { + slot: Slot::new(31), + root: get_root(1), + parent_root: get_root(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + }); + + // First Gloas block (V29 node). + let gloas_slot = if skip_first_gloas_slot { 33 } else { 32 }; + + // The first Gloas block should always have the pre-Gloas block as its execution parent, + // although this is currently not checked anywhere (the spec doesn't mention this). + ops.push(Operation::ProcessBlock { + slot: Slot::new(gloas_slot), + root: get_root(2), + parent_root: get_root(1), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: Some(get_hash(1)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // Parent payload status of fork boundary block should always be Empty. + let expected_parent_status = PayloadStatus::Empty; + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(2), + expected_status: expected_parent_status, + }); + + // Mark root 2's execution payload as received so the Full virtual child exists. + if first_gloas_block_full { + ops.push(Operation::ProcessExecutionPayloadEnvelope { + block_root: get_root(2), + }); + } + + // Extend the chain with another V29 block (Full child of root 2). + ops.push(Operation::ProcessBlock { + slot: Slot::new(gloas_slot + 1), + root: get_root(3), + parent_root: get_root(2), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + execution_payload_parent_hash: if first_gloas_block_full { + Some(get_hash(2)) + } else { + Some(get_hash(1)) + }, + execution_payload_block_hash: Some(get_hash(3)), + }); + + // Head should advance to the tip of the chain through the fork boundary. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(gloas_slot + 1), + expected_payload_status: None, + }); + + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(3), + expected_status: if first_gloas_block_full { + PayloadStatus::Full + } else { + PayloadStatus::Empty + }, + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + // Genesis is V17 (slot 0 < Gloas fork slot 32), these are unused for V17. + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: Some(gloas_fork_boundary_spec()), + } + } + + #[test] + fn fork_boundary_no_skip_full() { + get_gloas_fork_boundary_test_definition(false, true).run(); + } + + #[test] + fn fork_boundary_no_skip_empty() { + get_gloas_fork_boundary_test_definition(false, false).run(); + } + + #[test] + fn fork_boundary_skip_first_gloas_slot_full() { + get_gloas_fork_boundary_test_definition(true, true).run(); + } + + #[test] + fn fork_boundary_skip_first_gloas_slot_empty() { + get_gloas_fork_boundary_test_definition(true, false).run(); + } + + #[test] + fn chain_following() { + let test = get_gloas_chain_following_test_definition(); + test.run(); + } + + #[test] + fn payload_probe() { + let test = get_gloas_payload_probe_test_definition(); + test.run(); + } + + #[test] + fn find_head_vote_transition() { + let test = get_gloas_find_head_vote_transition_test_definition(); + test.run(); + } + + #[test] + fn weight_priority_over_payload_preference() { + let test = get_gloas_weight_priority_over_payload_preference_test_definition(); + test.run(); + } + + #[test] + fn parent_empty_when_child_points_to_grandparent() { + let test = get_gloas_parent_empty_when_child_points_to_grandparent_test_definition(); + test.run(); + } + + #[test] + fn interleaved_attestations() { + let test = get_gloas_interleaved_attestations_test_definition(); + test.run(); + } + + #[test] + fn payload_received_interleaving() { + let test = get_gloas_payload_received_interleaving_test_definition(); + test.run(); + } +} diff --git a/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs b/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs index d20eaacb99..7b5ee31c64 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/no_votes.rs @@ -18,6 +18,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: Hash256::zero(), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 2 // @@ -36,6 +38,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is 2 // @@ -53,6 +57,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 1 // @@ -71,6 +77,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is still 2 // @@ -88,6 +96,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 3 // @@ -108,6 +118,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure 2 is still the head // @@ -127,6 +139,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 4 // @@ -147,6 +161,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is 4. // @@ -166,6 +182,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(4), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 5 with a justified epoch of 2 // @@ -185,6 +203,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure the head is now 5 whilst the justified epoch is 0. // @@ -206,6 +226,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(5), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Ensure there is no error when starting from a block that has the // wrong justified epoch. @@ -232,6 +254,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(5), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Set the justified epoch to 2 and the start block to 5 and ensure 5 is the head. // @@ -250,6 +274,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(5), + current_slot: Slot::new(0), + expected_payload_status: None, }, // Add block 6 // @@ -271,6 +297,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: Hash256::zero(), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }, // Ensure 6 is the head // @@ -291,6 +319,8 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(6), + current_slot: Slot::new(0), + expected_payload_status: None, }, ]; @@ -305,6 +335,9 @@ pub fn get_no_votes_test_definition() -> ForkChoiceTestDefinition { root: Hash256::zero(), }, operations, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } diff --git a/consensus/proto_array/src/fork_choice_test_definition/votes.rs b/consensus/proto_array/src/fork_choice_test_definition/votes.rs index 01994fff9b..ac97a592b7 100644 --- a/consensus/proto_array/src/fork_choice_test_definition/votes.rs +++ b/consensus/proto_array/src/fork_choice_test_definition/votes.rs @@ -16,6 +16,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(0), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 2. @@ -35,6 +37,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is 2 @@ -53,6 +57,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a block with a hash of 1 that comes off the genesis block (this is a fork compared @@ -73,6 +79,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -91,6 +99,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to block 1 @@ -101,7 +111,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(1), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is now 1, because 1 has a vote. @@ -120,6 +130,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(1), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add a vote to block 2 @@ -130,7 +142,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(2), - target_epoch: Epoch::new(2), + attestation_slot: Slot::new(2), }); // Ensure that the head is 2 since 1 and 2 both have a vote @@ -149,6 +161,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add block 3. @@ -170,6 +184,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is still 2 @@ -190,6 +206,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Move validator #0 vote from 1 to 3 @@ -202,7 +220,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Ensure that the head is still 2 @@ -223,6 +241,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Move validator #1 vote from 2 to 1 (this is an equivocation, but fork choice doesn't @@ -236,7 +256,7 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(1), - target_epoch: Epoch::new(3), + attestation_slot: Slot::new(3), }); // Ensure that the head is now 3 @@ -257,6 +277,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add block 4. @@ -280,6 +302,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that the head is now 4 @@ -302,6 +326,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(4), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add block 5, which has a justified epoch of 2. @@ -327,19 +353,22 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(1), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); - // Ensure that 5 is filtered out and the head stays at 4. + // Block 5 has incompatible finalized checkpoint, so `get_filtered_block_tree` + // excludes the entire 1->3->4->5 branch (no viable leaf). Head moves to 2. // // 0 // / \ - // 2 1 + // head-> 2 1 // | // 3 // | - // 4 <- head + // 4 // / - // 5 + // 5 <- incompatible finalized checkpoint ops.push(Operation::FindHead { justified_checkpoint: Checkpoint { epoch: Epoch::new(1), @@ -350,7 +379,9 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { root: get_root(0), }, justified_state_balances: balances.clone(), - expected_head: get_root(4), + expected_head: get_root(2), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add block 6, which has a justified epoch of 0. @@ -376,6 +407,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(1), root: get_root(0), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Move both votes to 5. @@ -392,12 +425,12 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(5), - target_epoch: Epoch::new(4), + attestation_slot: Slot::new(4), }); ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(5), - target_epoch: Epoch::new(4), + attestation_slot: Slot::new(4), }); // Add blocks 7, 8 and 9. Adding these blocks helps test the `best_descendant` @@ -430,6 +463,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(0), @@ -443,6 +478,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); ops.push(Operation::ProcessBlock { slot: Slot::new(0), @@ -456,6 +493,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure that 6 is the head, even though 5 has all the votes. This is testing to ensure @@ -487,6 +526,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(6), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Change fork-choice justified epoch to 1, and the start block to 5 and ensure that 9 is @@ -520,6 +561,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Change fork-choice justified epoch to 1, and the start block to 5 and ensure that 9 is @@ -545,12 +588,12 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(9), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), }); ops.push(Operation::ProcessAttestation { validator_index: 1, block_root: get_root(9), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), }); // Add block 10 @@ -582,6 +625,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Double-check the head is still 9 (no diagram this time) @@ -596,6 +641,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Introduce 2 more validators into the system @@ -621,12 +668,12 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { ops.push(Operation::ProcessAttestation { validator_index: 2, block_root: get_root(10), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), }); ops.push(Operation::ProcessAttestation { validator_index: 3, block_root: get_root(10), - target_epoch: Epoch::new(5), + attestation_slot: Slot::new(5), }); // Check the head is now 10. @@ -657,6 +704,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Set the balances of the last two validators to zero @@ -682,6 +731,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Set the balances of the last two validators back to 1 @@ -707,6 +758,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(10), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Remove the last two validators @@ -733,6 +786,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that pruning below the prune threshold does not prune. @@ -754,6 +809,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Ensure that pruning above the prune threshold does prune. @@ -792,6 +849,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances.clone(), expected_head: get_root(9), + current_slot: Slot::new(0), + expected_payload_status: None, }); // Add block 11 @@ -817,6 +876,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { epoch: Epoch::new(2), root: get_root(5), }, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, }); // Ensure the head is now 11 @@ -841,6 +902,8 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { }, justified_state_balances: balances, expected_head: get_root(11), + current_slot: Slot::new(0), + expected_payload_status: None, }); ForkChoiceTestDefinition { @@ -854,6 +917,9 @@ pub fn get_votes_test_definition() -> ForkChoiceTestDefinition { root: get_root(0), }, operations: ops, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + spec: None, } } diff --git a/consensus/proto_array/src/lib.rs b/consensus/proto_array/src/lib.rs index 04e57d791b..702c014f07 100644 --- a/consensus/proto_array/src/lib.rs +++ b/consensus/proto_array/src/lib.rs @@ -8,13 +8,13 @@ mod ssz_container; pub use crate::justified_balances::JustifiedBalances; pub use crate::proto_array::{InvalidationOperation, calculate_committee_fraction}; pub use crate::proto_array_fork_choice::{ - Block, DisallowedReOrgOffsets, DoNotReOrg, ExecutionStatus, ProposerHeadError, - ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, + Block, DisallowedReOrgOffsets, DoNotReOrg, ExecutionStatus, LatestMessage, PayloadStatus, + ProposerHeadError, ProposerHeadInfo, ProtoArrayForkChoice, ReOrgThreshold, }; pub use error::Error; pub mod core { pub use super::proto_array::{ProposerBoost, ProtoArray, ProtoNode}; pub use super::proto_array_fork_choice::VoteTracker; - pub use super::ssz_container::{SszContainer, SszContainerV28}; + pub use super::ssz_container::{SszContainer, SszContainerV28, SszContainerV29}; } diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 5bfcdae463..dfb43f5f34 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1,12 +1,18 @@ use crate::error::InvalidBestNodeInfo; -use crate::{Block, ExecutionStatus, JustifiedBalances, error::Error}; +use crate::proto_array_fork_choice::IndexedForkChoiceNode; +use crate::{ + Block, ExecutionStatus, JustifiedBalances, LatestMessage, PayloadStatus, error::Error, +}; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; +use ssz::BitVector; use ssz::Encode; use ssz::four_byte_option_impl; use ssz_derive::{Decode, Encode}; use std::collections::{HashMap, HashSet}; +use std::time::Duration; use superstruct::superstruct; +use typenum::U512; use types::{ AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, Slot, @@ -17,6 +23,14 @@ use types::{ four_byte_option_impl!(four_byte_option_usize, usize); four_byte_option_impl!(four_byte_option_checkpoint, Checkpoint); +fn all_true_bitvector() -> BitVector { + let mut bv = BitVector::new(); + for i in 0..bv.len() { + let _ = bv.set(i, true); + } + bv +} + /// Defines an operation which may invalidate the `execution_status` of some nodes. #[derive(Clone, Debug)] pub enum InvalidationOperation { @@ -68,47 +82,151 @@ impl InvalidationOperation { } } -pub type ProtoNode = ProtoNodeV17; - #[superstruct( - variants(V17), - variant_attributes(derive(Clone, PartialEq, Debug, Encode, Decode, Serialize, Deserialize)), - no_enum + variants(V17, V29), + variant_attributes(derive(Clone, PartialEq, Debug, Encode, Decode, Serialize, Deserialize)) )] +#[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Clone)] +#[ssz(enum_behaviour = "union")] pub struct ProtoNode { /// The `slot` is not necessary for `ProtoArray`, it just exists so external components can /// easily query the block slot. This is useful for upstream fork choice logic. + #[superstruct(getter(copy))] pub slot: Slot, /// The `state_root` is not necessary for `ProtoArray` either, it also just exists for upstream /// components (namely attestation verification). + #[superstruct(getter(copy))] pub state_root: Hash256, /// The root that would be used for the `attestation.data.target.root` if a LMD vote was cast /// for this block. /// /// The `target_root` is not necessary for `ProtoArray` either, it also just exists for upstream /// components (namely fork choice attestation verification). + #[superstruct(getter(copy))] pub target_root: Hash256, pub current_epoch_shuffling_id: AttestationShufflingId, pub next_epoch_shuffling_id: AttestationShufflingId, + #[superstruct(getter(copy))] pub root: Hash256, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_usize")] pub parent: Option, - #[superstruct(only(V17))] + #[superstruct(only(V17, V29), partial_getter(copy))] pub justified_checkpoint: Checkpoint, - #[superstruct(only(V17))] + #[superstruct(only(V17, V29), partial_getter(copy))] pub finalized_checkpoint: Checkpoint, + #[superstruct(getter(copy))] pub weight: u64, + #[superstruct(only(V17), partial_getter(copy))] #[ssz(with = "four_byte_option_usize")] pub best_child: Option, + #[superstruct(only(V17), partial_getter(copy))] #[ssz(with = "four_byte_option_usize")] pub best_descendant: Option, /// Indicates if an execution node has marked this block as valid. Also contains the execution - /// block hash. + /// block hash. This is only used pre-Gloas. + #[superstruct(only(V17), partial_getter(copy))] pub execution_status: ExecutionStatus, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_checkpoint")] pub unrealized_justified_checkpoint: Option, + #[superstruct(getter(copy))] #[ssz(with = "four_byte_option_checkpoint")] pub unrealized_finalized_checkpoint: Option, + + /// We track the parent payload status from which the current node was extended. + #[superstruct(only(V29), partial_getter(copy))] + pub parent_payload_status: PayloadStatus, + #[superstruct(only(V29), partial_getter(copy))] + pub empty_payload_weight: u64, + #[superstruct(only(V29), partial_getter(copy))] + pub full_payload_weight: u64, + #[superstruct(only(V29), partial_getter(copy))] + pub execution_payload_block_hash: ExecutionBlockHash, + #[superstruct(only(V29), partial_getter(copy))] + pub execution_payload_parent_hash: ExecutionBlockHash, + /// Equivalent to spec's `block_timeliness[root][ATTESTATION_TIMELINESS_INDEX]`. + #[superstruct(only(V29), partial_getter(copy))] + pub block_timeliness_attestation_threshold: bool, + /// Equivalent to spec's `block_timeliness[root][PTC_TIMELINESS_INDEX]`. + #[superstruct(only(V29), partial_getter(copy))] + pub block_timeliness_ptc_threshold: bool, + /// Equivalent to spec's `store.payload_timeliness_vote[root]`. + /// PTC timeliness vote bitfield, indexed by PTC committee position. + /// Bit i set means PTC member i voted `payload_present = true`. + /// Tiebreak derived as: `num_set_bits() > ptc_size / 2`. + #[superstruct(only(V29))] + pub payload_timeliness_votes: BitVector, + /// Equivalent to spec's `store.payload_data_availability_vote[root]`. + /// PTC data availability vote bitfield, indexed by PTC committee position. + /// Bit i set means PTC member i voted `blob_data_available = true`. + /// Tiebreak derived as: `num_set_bits() > ptc_size / 2`. + #[superstruct(only(V29))] + pub payload_data_availability_votes: BitVector, + /// Whether the execution payload for this block has been received and validated locally. + /// Maps to `root in store.payload_states` in the spec. + #[superstruct(only(V29), partial_getter(copy))] + pub payload_received: bool, + /// The proposer index for this block, used by `should_apply_proposer_boost` + /// to detect equivocations at the parent's slot. + #[superstruct(only(V29), partial_getter(copy))] + pub proposer_index: u64, + /// Weight from equivocating validators that voted for this block. + /// Used by `is_head_weak` to match the spec's monotonicity guarantee: + /// more attestations can only increase head weight, never decrease it. + #[superstruct(only(V29), partial_getter(copy))] + pub equivocating_attestation_score: u64, +} + +impl ProtoNode { + /// Generic version of spec's `parent_payload_status` that works for pre-Gloas nodes by + /// considering their parents Empty. + pub fn get_parent_payload_status(&self) -> PayloadStatus { + self.parent_payload_status().unwrap_or(PayloadStatus::Empty) + } + + pub fn is_parent_node_full(&self) -> bool { + self.get_parent_payload_status() == PayloadStatus::Full + } + + pub fn attestation_score(&self, payload_status: PayloadStatus) -> u64 { + match payload_status { + PayloadStatus::Pending => self.weight(), + // Pre-Gloas (V17) nodes have no payload separation — all weight + // is in `weight()`. Post-Gloas (V29) nodes track per-status weights. + PayloadStatus::Empty => self + .empty_payload_weight() + .unwrap_or_else(|_| self.weight()), + PayloadStatus::Full => self.full_payload_weight().unwrap_or_else(|_| self.weight()), + } + } + + pub fn is_payload_timely(&self) -> bool { + let Ok(node) = self.as_v29() else { + return false; + }; + + // Equivalent to `if root not in store.payload_states` in the spec. + if !node.payload_received { + return false; + } + + node.payload_timeliness_votes.num_set_bits() > E::ptc_size() / 2 + } + + pub fn is_payload_data_available(&self) -> bool { + let Ok(node) = self.as_v29() else { + return false; + }; + + // Equivalent to `if root not in store.payload_states` in the spec. + if !node.payload_received { + return false; + } + + // TODO(gloas): add function on EthSpec for DATA_AVAILABILITY_TIMELY_THRESHOLD + node.payload_data_availability_votes.num_set_bits() > E::ptc_size() / 2 + } } #[derive(PartialEq, Debug, Encode, Decode, Serialize, Deserialize, Copy, Clone)] @@ -126,6 +244,122 @@ impl Default for ProposerBoost { } } +/// Accumulated score changes for a single proto-array node during a `find_head` pass. +/// +/// `delta` tracks the ordinary LMD-GHOST balance change applied to the concrete block node. +/// This is the same notion of weight that pre-gloas fork choice used. +/// +/// +/// Under gloas we also need to track how votes contribute to the parent's virtual payload +/// branches: +/// +/// - `empty_delta` is the balance change attributable to votes that support the `Empty` payload +/// interpretation of the node +/// - `full_delta` is the balance change attributable to votes that support the `Full` payload +/// interpretation of the node +/// +/// Votes in `Pending` state only affect `delta`; they do not contribute to either payload bucket. +/// During score application these payload deltas are propagated independently up the tree so that +/// ancestors can compare children using payload-aware tie breaking. +#[derive(Clone, PartialEq, Debug, Copy)] +pub struct NodeDelta { + /// Total weight change for the node. All votes contribute regardless of payload status. + pub delta: i64, + /// Weight change from `PayloadStatus::Empty` votes. + pub empty_delta: i64, + /// Weight change from `PayloadStatus::Full` votes. + pub full_delta: i64, + /// Weight from equivocating validators that voted for this node. + pub equivocating_attestation_delta: u64, +} + +impl NodeDelta { + /// Classify a vote into the payload bucket it contributes to for `block_slot`. + /// + /// Per the gloas model: + /// + /// - a same-slot vote is `Pending` + /// - a later vote with `payload_present = true` is `Full` + /// - a later vote with `payload_present = false` is `Empty` + /// + /// This classification is used only for payload-aware accounting; all votes still contribute to + /// the aggregate `delta`. + pub fn payload_status( + vote_slot: Slot, + payload_present: bool, + block_slot: Slot, + ) -> PayloadStatus { + if vote_slot == block_slot { + PayloadStatus::Pending + } else if payload_present { + PayloadStatus::Full + } else { + PayloadStatus::Empty + } + } + + /// Add `balance` to the payload bucket selected by `status`. + /// + /// `Pending` votes do not affect payload buckets, so this becomes a no-op for that case. + pub fn add_payload_delta( + &mut self, + status: PayloadStatus, + balance: u64, + index: usize, + ) -> Result<(), Error> { + let field = match status { + PayloadStatus::Full => &mut self.full_delta, + PayloadStatus::Empty => &mut self.empty_delta, + PayloadStatus::Pending => return Ok(()), + }; + *field = field + .checked_add(balance as i64) + .ok_or(Error::DeltaOverflow(index))?; + Ok(()) + } + + /// Create a delta that only affects the aggregate block weight. + /// + /// This is useful for callers or tests that only care about ordinary LMD-GHOST weight changes + /// and do not need payload-aware accounting. + pub fn from_delta(delta: i64) -> Self { + Self { + delta, + empty_delta: 0, + full_delta: 0, + equivocating_attestation_delta: 0, + } + } + + /// Subtract `balance` from the payload bucket selected by `status`. + /// + /// `Pending` votes do not affect payload buckets, so this becomes a no-op for that case. + pub fn sub_payload_delta( + &mut self, + status: PayloadStatus, + balance: u64, + index: usize, + ) -> Result<(), Error> { + let field = match status { + PayloadStatus::Full => &mut self.full_delta, + PayloadStatus::Empty => &mut self.empty_delta, + PayloadStatus::Pending => return Ok(()), + }; + *field = field + .checked_sub(balance as i64) + .ok_or(Error::DeltaOverflow(index))?; + Ok(()) + } +} + +/// Compare NodeDelta with i64 by comparing the aggregate `delta` field. +/// This is used by tests that only care about the total weight delta. +impl PartialEq for NodeDelta { + fn eq(&self, other: &i64) -> bool { + self.delta == *other + } +} + #[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] pub struct ProtoArray { /// Do not attempt to prune the tree unless it has at least this many nodes. Small prunes @@ -133,7 +367,6 @@ pub struct ProtoArray { pub prune_threshold: usize, pub nodes: Vec, pub indices: HashMap, - pub previous_proposer_boost: ProposerBoost, } impl ProtoArray { @@ -153,13 +386,7 @@ impl ProtoArray { #[allow(clippy::too_many_arguments)] pub fn apply_score_changes( &mut self, - mut deltas: Vec, - best_justified_checkpoint: Checkpoint, - best_finalized_checkpoint: Checkpoint, - new_justified_balances: &JustifiedBalances, - proposer_boost_root: Hash256, - current_slot: Slot, - spec: &ChainSpec, + mut deltas: Vec, ) -> Result<(), Error> { if deltas.len() != self.indices.len() { return Err(Error::InvalidDeltaLen { @@ -168,9 +395,6 @@ impl ProtoArray { }); } - // Default the proposer boost score to zero. - let mut proposer_score = 0; - // Iterate backwards through all indices in `self.nodes`. for node_index in (0..self.nodes.len()).rev() { let node = self @@ -181,116 +405,95 @@ impl ProtoArray { // There is no need to adjust the balances or manage parent of the zero hash since it // is an alias to the genesis block. The weight applied to the genesis block is // irrelevant as we _always_ choose it and it's impossible for it to have a parent. - if node.root == Hash256::zero() { + if node.root() == Hash256::zero() { continue; } - let execution_status_is_invalid = node.execution_status.is_invalid(); - - let mut node_delta = if execution_status_is_invalid { - // If the node has an invalid execution payload, reduce its weight to zero. - 0_i64 - .checked_sub(node.weight as i64) - .ok_or(Error::InvalidExecutionDeltaOverflow(node_index))? + let execution_status_is_invalid = if let Ok(proto_node) = node.as_v17() + && proto_node.execution_status.is_invalid() + { + true } else { - deltas - .get(node_index) - .copied() - .ok_or(Error::InvalidNodeDelta(node_index))? + false }; - // If we find the node for which the proposer boost was previously applied, decrease - // the delta by the previous score amount. - if self.previous_proposer_boost.root != Hash256::zero() - && self.previous_proposer_boost.root == node.root - // Invalid nodes will always have a weight of zero so there's no need to subtract - // the proposer boost delta. - && !execution_status_is_invalid - { - node_delta = node_delta - .checked_sub(self.previous_proposer_boost.score as i64) - .ok_or(Error::DeltaOverflow(node_index))?; - } - // If we find the node matching the current proposer boost root, increase - // the delta by the new score amount (unless the block has an invalid execution status). - // - // https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/fork-choice.md#get_latest_attesting_balance - if let Some(proposer_score_boost) = spec.proposer_score_boost - && proposer_boost_root != Hash256::zero() - && proposer_boost_root == node.root - // Invalid nodes (or their ancestors) should not receive a proposer boost. - && !execution_status_is_invalid - { - proposer_score = - calculate_committee_fraction::(new_justified_balances, proposer_score_boost) - .ok_or(Error::ProposerBoostOverflow(node_index))?; - node_delta = node_delta - .checked_add(proposer_score as i64) - .ok_or(Error::DeltaOverflow(node_index))?; - } + let node_delta = deltas + .get(node_index) + .copied() + .ok_or(Error::InvalidNodeDelta(node_index))?; + + let delta = if execution_status_is_invalid { + // If the node has an invalid execution payload, reduce its weight to zero. + 0_i64 + .checked_sub(node.weight() as i64) + .ok_or(Error::InvalidExecutionDeltaOverflow(node_index))? + } else { + node_delta.delta + }; + + let (node_empty_delta, node_full_delta) = if node.as_v29().is_ok() { + (node_delta.empty_delta, node_delta.full_delta) + } else { + (0, 0) + }; + + // Proposer boost is NOT applied here. It is computed on-the-fly + // during the virtual tree walk in `get_weight`, matching the spec's + // `get_weight` which adds boost separately from `get_attestation_score`. // Apply the delta to the node. if execution_status_is_invalid { // Invalid nodes always have a weight of 0. - node.weight = 0 - } else if node_delta < 0 { - // Note: I am conflicted about whether to use `saturating_sub` or `checked_sub` - // here. - // - // I can't think of any valid reason why `node_delta.abs()` should be greater than - // `node.weight`, so I have chosen `checked_sub` to try and fail-fast if there is - // some error. - // - // However, I am not fully convinced that some valid case for `saturating_sub` does - // not exist. - node.weight = node - .weight - .checked_sub(node_delta.unsigned_abs()) - .ok_or(Error::DeltaOverflow(node_index))?; + *node.weight_mut() = 0; } else { - node.weight = node - .weight - .checked_add(node_delta as u64) - .ok_or(Error::DeltaOverflow(node_index))?; + *node.weight_mut() = apply_delta(node.weight(), delta, node_index)?; + } + + // Apply post-Gloas score deltas. + if let Ok(node) = node.as_v29_mut() { + node.empty_payload_weight = + apply_delta(node.empty_payload_weight, node_empty_delta, node_index)?; + node.full_payload_weight = + apply_delta(node.full_payload_weight, node_full_delta, node_index)?; + node.equivocating_attestation_score = node + .equivocating_attestation_score + .saturating_add(node_delta.equivocating_attestation_delta); } // Update the parent delta (if any). - if let Some(parent_index) = node.parent { + if let Some(parent_index) = node.parent() { let parent_delta = deltas .get_mut(parent_index) .ok_or(Error::InvalidParentDelta(parent_index))?; - // Back-propagate the nodes delta to its parent. - *parent_delta += node_delta; - } - } + // Back-propagate the node's delta to its parent. + parent_delta.delta = parent_delta + .delta + .checked_add(delta) + .ok_or(Error::DeltaOverflow(parent_index))?; - // After applying all deltas, update the `previous_proposer_boost`. - self.previous_proposer_boost = ProposerBoost { - root: proposer_boost_root, - score: proposer_score, - }; - - // A second time, iterate backwards through all indices in `self.nodes`. - // - // We _must_ perform these functions separate from the weight-updating loop above to ensure - // that we have a fully coherent set of weights before updating parent - // best-child/descendant. - for node_index in (0..self.nodes.len()).rev() { - let node = self - .nodes - .get_mut(node_index) - .ok_or(Error::InvalidNodeIndex(node_index))?; - - // If the node has a parent, try to update its best-child and best-descendant. - if let Some(parent_index) = node.parent { - self.maybe_update_best_child_and_descendant::( - parent_index, - node_index, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; + // Route ALL child weight into the parent's FULL or EMPTY bucket + // based on the child's `parent_payload_status` (the ancestor path + // direction). If this child is on the FULL path from the parent, + // all weight supports the parent's FULL virtual node, and vice versa. + if let Ok(child_v29) = node.as_v29() { + if child_v29.parent_payload_status == PayloadStatus::Full { + parent_delta.full_delta = parent_delta + .full_delta + .checked_add(delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + } else { + parent_delta.empty_delta = parent_delta + .empty_delta + .checked_add(delta) + .ok_or(Error::DeltaOverflow(parent_index))?; + } + } else { + // This is a v17 node with a v17 parent. + // There is no empty or full weight for v17 nodes, so nothing to propagate. + // In the tree walk, the v17 nodes have an empty child with 0 weight, which + // wins by default (it is the only child). + } } } @@ -304,71 +507,285 @@ impl ProtoArray { &mut self, block: Block, current_slot: Slot, - best_justified_checkpoint: Checkpoint, - best_finalized_checkpoint: Checkpoint, + spec: &ChainSpec, + time_into_slot: Duration, ) -> Result<(), Error> { // If the block is already known, simply ignore it. if self.indices.contains_key(&block.root) { return Ok(()); } - let node_index = self.nodes.len(); - - let node = ProtoNode { - slot: block.slot, - root: block.root, - target_root: block.target_root, - current_epoch_shuffling_id: block.current_epoch_shuffling_id, - next_epoch_shuffling_id: block.next_epoch_shuffling_id, - state_root: block.state_root, - parent: block - .parent_root - .and_then(|parent| self.indices.get(&parent).copied()), - justified_checkpoint: block.justified_checkpoint, - finalized_checkpoint: block.finalized_checkpoint, - weight: 0, - best_child: None, - best_descendant: None, - execution_status: block.execution_status, - unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, - unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + // We do not allow `proposer_index=None` for calls to `on_block`, it is only non-optional + // for backwards-compatibility with pre-Gloas V17 proto nodes. + let Some(proposer_index) = block.proposer_index else { + return Err(Error::OnBlockRequiresProposerIndex); }; - // If the parent has an invalid execution status, return an error before adding the block to - // `self`. - if let Some(parent_index) = node.parent { + let node_index = self.nodes.len(); + + let parent_index = block + .parent_root + .and_then(|parent| self.indices.get(&parent).copied()); + + let node = if !spec.fork_name_at_slot::(block.slot).gloas_enabled() { + ProtoNode::V17(ProtoNodeV17 { + slot: block.slot, + root: block.root, + target_root: block.target_root, + current_epoch_shuffling_id: block.current_epoch_shuffling_id, + next_epoch_shuffling_id: block.next_epoch_shuffling_id, + state_root: block.state_root, + parent: parent_index, + justified_checkpoint: block.justified_checkpoint, + finalized_checkpoint: block.finalized_checkpoint, + weight: 0, + best_child: None, + best_descendant: None, + execution_status: block.execution_status, + unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, + unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + }) + } else { + let is_current_slot = current_slot == block.slot; + + let execution_payload_block_hash = + block + .execution_payload_block_hash + .ok_or(Error::BrokenBlock { + block_root: block.root, + })?; + + let execution_payload_parent_hash = + block + .execution_payload_parent_hash + .ok_or(Error::BrokenBlock { + block_root: block.root, + })?; + + let parent_payload_status: PayloadStatus = + if let Some(parent_node) = parent_index.and_then(|idx| self.nodes.get(idx)) { + match parent_node { + ProtoNode::V29(v29) => { + // Both parent and child are Gloas blocks. The parent is full if the + // block hash in the parent node matches the parent block hash in the + // child bid. + if execution_payload_parent_hash == v29.execution_payload_block_hash { + PayloadStatus::Full + } else { + PayloadStatus::Empty + } + } + ProtoNode::V17(_) => { + // Parent is pre-Gloas, pre-Gloas blocks are treated as having Empty + // payload status. This case is reached during the fork transition. + PayloadStatus::Empty + } + } + } else { + // TODO(gloas): re-assess this assumption + // Parent is missing (genesis or pruned due to finalization). Default to Full + // since this path should only be hit at Gloas genesis. + PayloadStatus::Full + }; + + // Per spec `get_forkchoice_store`: the anchor (genesis) block has + // its payload state initialized (`payload_states = {anchor_root: ...}`). + // Without `payload_received = true` on genesis, the FULL virtual + // child doesn't exist in the spec's `get_node_children`, making all + // Full concrete children of genesis unreachable in `get_head`. + let is_genesis = parent_index.is_none(); + + ProtoNode::V29(ProtoNodeV29 { + slot: block.slot, + root: block.root, + target_root: block.target_root, + current_epoch_shuffling_id: block.current_epoch_shuffling_id, + next_epoch_shuffling_id: block.next_epoch_shuffling_id, + state_root: block.state_root, + parent: parent_index, + justified_checkpoint: block.justified_checkpoint, + finalized_checkpoint: block.finalized_checkpoint, + weight: 0, + unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, + unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + parent_payload_status, + empty_payload_weight: 0, + full_payload_weight: 0, + execution_payload_block_hash, + execution_payload_parent_hash, + // Per spec `get_forkchoice_store`: the anchor block's PTC votes are + // initialized to all-True, ensuring `is_payload_timely` and + // `is_payload_data_available` return true for the anchor. + payload_timeliness_votes: if is_genesis { + all_true_bitvector() + } else { + BitVector::default() + }, + payload_data_availability_votes: if is_genesis { + all_true_bitvector() + } else { + BitVector::default() + }, + payload_received: is_genesis, + proposer_index, + // Spec: `record_block_timeliness` + `get_forkchoice_store`. + // Anchor gets [True, True]. Others computed from time_into_slot. + block_timeliness_attestation_threshold: is_genesis + || (is_current_slot + && time_into_slot < spec.get_attestation_due::(current_slot)), + block_timeliness_ptc_threshold: is_genesis + || (is_current_slot && time_into_slot < spec.get_payload_attestation_due()), + equivocating_attestation_score: 0, + }) + }; + + // If the parent has an invalid execution status, return an error before adding the + // block to `self`. This applies only when the parent is a V17 node with execution tracking. + if let Some(parent_index) = node.parent() { let parent = self .nodes .get(parent_index) .ok_or(Error::InvalidNodeIndex(parent_index))?; - if parent.execution_status.is_invalid() { + + // Execution status tracking only exists on V17 (pre-Gloas) nodes. + if let Ok(v17) = parent.as_v17() + && v17.execution_status.is_invalid() + { return Err(Error::ParentExecutionStatusIsInvalid { block_root: block.root, - parent_root: parent.root, + parent_root: parent.root(), }); } } - self.indices.insert(node.root, node_index); + self.indices.insert(node.root(), node_index); self.nodes.push(node.clone()); - if let Some(parent_index) = node.parent { - self.maybe_update_best_child_and_descendant::( - parent_index, - node_index, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; - - if matches!(block.execution_status, ExecutionStatus::Valid(_)) { - self.propagate_execution_payload_validation_by_index(parent_index)?; - } + if let Some(parent_index) = node.parent() + && matches!(block.execution_status, ExecutionStatus::Valid(_)) + { + self.propagate_execution_payload_validation_by_index(parent_index)?; } Ok(()) } + /// Spec: `is_head_weak`. + // TODO(gloas): the spec adds weight from equivocating validators in the + // head slot's *committees*, regardless of who they voted for. We approximate + // with `equivocating_attestation_score` which only tracks equivocating + // validators whose vote pointed at this block. This under-counts when an + // equivocating validator is in the committee but voted for a different fork, + // which could allow a re-org the spec wouldn't. In practice the deviation + // is small — it requires equivocating validators voting for competing forks + // AND the head weight to be exactly at the reorg threshold boundary. + // Fixing this properly requires committee computation from BeaconState, + // which is not available in proto_array. The fix would be to pass + // pre-computed equivocating committee weight from the beacon_chain caller. + fn is_head_weak( + &self, + head_node: &ProtoNode, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> bool { + let reorg_threshold = calculate_committee_fraction::( + justified_balances, + spec.reorg_head_weight_threshold.unwrap_or(20), + ) + .unwrap_or(0); + + let head_weight = head_node + .attestation_score(PayloadStatus::Pending) + .saturating_add(head_node.equivocating_attestation_score().unwrap_or(0)); + + head_weight < reorg_threshold + } + + /// Spec's `should_apply_proposer_boost` for Gloas. + /// + /// Returns `true` if the proposer boost should be kept. Returns `false` if the + /// boost should be subtracted (invalidated) because the parent is weak and there + /// are no equivocating blocks at the parent's slot. + fn should_apply_proposer_boost( + &self, + proposer_boost_root: Hash256, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result { + if proposer_boost_root.is_zero() { + return Ok(false); + } + + let block_index = *self + .indices + .get(&proposer_boost_root) + .ok_or(Error::NodeUnknown(proposer_boost_root))?; + let block = self + .nodes + .get(block_index) + .ok_or(Error::InvalidNodeIndex(block_index))?; + let parent_index = block + .parent() + .ok_or(Error::NodeUnknown(proposer_boost_root))?; + let parent = self + .nodes + .get(parent_index) + .ok_or(Error::InvalidNodeIndex(parent_index))?; + let slot = block.slot(); + + // Apply proposer boost if `parent` is not from the previous slot + if parent.slot().saturating_add(1_u64) < slot { + return Ok(true); + } + + // Apply proposer boost if `parent` is not weak + if !self.is_head_weak::(parent, justified_balances, spec) { + return Ok(true); + } + + // Parent is weak. Apply boost unless there's an equivocating block at + // the parent's slot from the same proposer. + let parent_slot = parent.slot(); + let parent_root = parent.root(); + let parent_proposer = parent.proposer_index(); + + let has_equivocation = self.nodes.iter().any(|node| { + if let Ok(timeliness) = node.block_timeliness_ptc_threshold() + && let Ok(proposer_index) = node.proposer_index() + { + timeliness + && Ok(proposer_index) == parent_proposer + && node.slot() == parent_slot + && node.root() != parent_root + } else { + // Pre-Gloas. + false + } + }); + + Ok(!has_equivocation) + } + + /// Process a valid execution payload envelope for a Gloas block. + /// + /// Sets `payload_received` to true. + pub fn on_valid_payload_envelope_received(&mut self, block_root: Hash256) -> Result<(), Error> { + let index = *self + .indices + .get(&block_root) + .ok_or(Error::NodeUnknown(block_root))?; + let node = self + .nodes + .get_mut(index) + .ok_or(Error::InvalidNodeIndex(index))?; + let v29 = node + .as_v29_mut() + .map_err(|_| Error::InvalidNodeVariant { block_root })?; + v29.payload_received = true; + + Ok(()) + } + /// Updates the `block_root` and all ancestors to have validated execution payloads. /// /// Returns an error if: @@ -388,6 +805,8 @@ impl ProtoArray { /// Updates the `verified_node_index` and all ancestors to have validated execution payloads. /// + /// This function is a no-op if called for a Gloas block. + /// /// Returns an error if: /// /// - The `verified_node_index` is unknown. @@ -402,32 +821,39 @@ impl ProtoArray { .nodes .get_mut(index) .ok_or(Error::InvalidNodeIndex(index))?; - let parent_index = match node.execution_status { - // We have reached a node that we already know is valid. No need to iterate further - // since we assume an ancestors have already been set to valid. - ExecutionStatus::Valid(_) => return Ok(()), - // We have reached an irrelevant node, this node is prior to a terminal execution - // block. There's no need to iterate further, it's impossible for this block to have - // any relevant ancestors. - ExecutionStatus::Irrelevant(_) => return Ok(()), - // The block has an unknown status, set it to valid since any ancestor of a valid - // payload can be considered valid. - ExecutionStatus::Optimistic(payload_block_hash) => { - node.execution_status = ExecutionStatus::Valid(payload_block_hash); - if let Some(parent_index) = node.parent { - parent_index - } else { - // We have reached the root block, iteration complete. - return Ok(()); + let parent_index = match node { + ProtoNode::V17(node) => match node.execution_status { + // We have reached a node that we already know is valid. No need to iterate further + // since we assume an ancestors have already been set to valid. + ExecutionStatus::Valid(_) => return Ok(()), + // We have reached an irrelevant node, this node is prior to a terminal execution + // block. There's no need to iterate further, it's impossible for this block to have + // any relevant ancestors. + ExecutionStatus::Irrelevant(_) => return Ok(()), + // The block has an unknown status, set it to valid since any ancestor of a valid + // payload can be considered valid. + ExecutionStatus::Optimistic(payload_block_hash) => { + node.execution_status = ExecutionStatus::Valid(payload_block_hash); + if let Some(parent_index) = node.parent { + parent_index + } else { + // We have reached the root block, iteration complete. + return Ok(()); + } } - } - // An ancestor of the valid payload was invalid. This is a serious error which - // indicates a consensus failure in the execution node. This is unrecoverable. - ExecutionStatus::Invalid(ancestor_payload_block_hash) => { - return Err(Error::InvalidAncestorOfValidPayload { - ancestor_block_root: node.root, - ancestor_payload_block_hash, - }); + // An ancestor of the valid payload was invalid. This is a serious error which + // indicates a consensus failure in the execution node. This is unrecoverable. + ExecutionStatus::Invalid(ancestor_payload_block_hash) => { + return Err(Error::InvalidAncestorOfValidPayload { + ancestor_block_root: node.root, + ancestor_payload_block_hash, + }); + } + }, + // Gloas nodes should not be marked valid by this function, which exists only + // for pre-Gloas fork choice. + ProtoNode::V29(_) => { + return Ok(()); } }; @@ -438,6 +864,7 @@ impl ProtoArray { /// Invalidate zero or more blocks, as specified by the `InvalidationOperation`. /// /// See the documentation of `InvalidationOperation` for usage. + // TODO(gloas): this needs some tests for the mixed Gloas/pre-Gloas case. pub fn propagate_execution_payload_invalidation( &mut self, op: &InvalidationOperation, @@ -484,10 +911,11 @@ impl ProtoArray { .get_mut(index) .ok_or(Error::InvalidNodeIndex(index))?; - match node.execution_status { - ExecutionStatus::Valid(hash) - | ExecutionStatus::Invalid(hash) - | ExecutionStatus::Optimistic(hash) => { + let node_execution_status = node.execution_status(); + match node_execution_status { + Ok(ExecutionStatus::Valid(hash)) + | Ok(ExecutionStatus::Invalid(hash)) + | Ok(ExecutionStatus::Optimistic(hash)) => { // If we're no longer processing the `head_block_root` and the last valid // ancestor is unknown, exit this loop and proceed to invalidate and // descendants of `head_block_root`/`latest_valid_ancestor_root`. @@ -496,74 +924,51 @@ impl ProtoArray { // supplied, don't validate any ancestors. The alternative is to invalidate // *all* ancestors, which would likely involve shutting down the client due to // an invalid justified checkpoint. - if !latest_valid_ancestor_is_descendant && node.root != head_block_root { + if !latest_valid_ancestor_is_descendant && node.root() != head_block_root { break; } else if op.latest_valid_ancestor() == Some(hash) { - // If the `best_child` or `best_descendant` of the latest valid hash was - // invalidated, set those fields to `None`. - // - // In theory, an invalid `best_child` necessarily infers an invalid - // `best_descendant`. However, we check each variable independently to - // defend against errors which might result in an invalid block being set as - // head. - if node - .best_child - .is_some_and(|i| invalidated_indices.contains(&i)) - { - node.best_child = None - } - if node - .best_descendant - .is_some_and(|i| invalidated_indices.contains(&i)) - { - node.best_descendant = None - } - + // Reached latest valid block, stop invalidating further. break; } } - ExecutionStatus::Irrelevant(_) => break, + Ok(ExecutionStatus::Irrelevant(_)) => break, + Err(_) => break, } // Only invalidate the head block if either: // // - The head block was specifically indicated to be invalidated. // - The latest valid hash is a known ancestor. - if node.root != head_block_root + if node.root() != head_block_root || op.invalidate_block_root() || latest_valid_ancestor_is_descendant { - match &node.execution_status { + match node.execution_status() { // It's illegal for an execution client to declare that some previously-valid block // is now invalid. This is a consensus failure on their behalf. - ExecutionStatus::Valid(hash) => { + Ok(ExecutionStatus::Valid(hash)) => { return Err(Error::ValidExecutionStatusBecameInvalid { - block_root: node.root, - payload_block_hash: *hash, + block_root: node.root(), + payload_block_hash: hash, }); } - ExecutionStatus::Optimistic(hash) => { + Ok(ExecutionStatus::Optimistic(hash)) => { invalidated_indices.insert(index); - node.execution_status = ExecutionStatus::Invalid(*hash); - - // It's impossible for an invalid block to lead to a "best" block, so set these - // fields to `None`. - // - // Failing to set these values will result in `Self::node_leads_to_viable_head` - // returning `false` for *valid* ancestors of invalid blocks. - node.best_child = None; - node.best_descendant = None; + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Invalid(hash); + } } // The block is already invalid, but keep going backwards to ensure all ancestors // are updated. - ExecutionStatus::Invalid(_) => (), + Ok(ExecutionStatus::Invalid(_)) => (), // This block is pre-merge, therefore it has no execution status. Nor do its // ancestors. - ExecutionStatus::Irrelevant(_) => break, + Ok(ExecutionStatus::Irrelevant(_)) => break, + Err(_) => break, } } - if let Some(parent_index) = node.parent { + if let Some(parent_index) = node.parent() { index = parent_index } else { // The root of the block tree has been reached (aka the finalized block), without @@ -597,24 +1002,27 @@ impl ProtoArray { .get_mut(index) .ok_or(Error::InvalidNodeIndex(index))?; - if let Some(parent_index) = node.parent + if let Some(parent_index) = node.parent() && invalidated_indices.contains(&parent_index) { - match &node.execution_status { - ExecutionStatus::Valid(hash) => { + match node.execution_status() { + Ok(ExecutionStatus::Valid(hash)) => { return Err(Error::ValidExecutionStatusBecameInvalid { - block_root: node.root, - payload_block_hash: *hash, + block_root: node.root(), + payload_block_hash: hash, }); } - ExecutionStatus::Optimistic(hash) | ExecutionStatus::Invalid(hash) => { - node.execution_status = ExecutionStatus::Invalid(*hash) + Ok(ExecutionStatus::Optimistic(hash)) | Ok(ExecutionStatus::Invalid(hash)) => { + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Invalid(hash) + } } - ExecutionStatus::Irrelevant(_) => { + Ok(ExecutionStatus::Irrelevant(_)) => { return Err(Error::IrrelevantDescendant { - block_root: node.root, + block_root: node.root(), }); } + Err(_) => (), } invalidated_indices.insert(index); @@ -632,13 +1040,17 @@ impl ProtoArray { /// been called without a subsequent `Self::apply_score_changes` call. This is because /// `on_new_block` does not attempt to walk backwards through the tree and update the /// best-child/best-descendant links. + #[allow(clippy::too_many_arguments)] pub fn find_head( &self, justified_root: &Hash256, current_slot: Slot, best_justified_checkpoint: Checkpoint, best_finalized_checkpoint: Checkpoint, - ) -> Result { + proposer_boost_root: Hash256, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result<(Hash256, PayloadStatus), Error> { let justified_index = self .indices .get(justified_root) @@ -652,25 +1064,30 @@ impl ProtoArray { // Since there are no valid descendants of a justified block with an invalid execution // payload, there would be no head to choose from. - // - // Fork choice is effectively broken until a new justified root is set. It might not be - // practically possible to set a new justified root if we are unable to find a new head. - // - // This scenario is *unsupported*. It represents a serious consensus failure. - if justified_node.execution_status.is_invalid() { + // Execution status tracking only exists on V17 (pre-Gloas) nodes. + if let Ok(v17) = justified_node.as_v17() + && v17.execution_status.is_invalid() + { return Err(Error::InvalidJustifiedCheckpointExecutionStatus { justified_root: *justified_root, }); } - let best_descendant_index = justified_node.best_descendant.unwrap_or(justified_index); - - let best_node = self - .nodes - .get(best_descendant_index) - .ok_or(Error::InvalidBestDescendant(best_descendant_index))?; + let best_fc_node = self.find_head_walk::( + justified_index, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + proposer_boost_root, + justified_balances, + spec, + )?; // Perform a sanity check that the node is indeed valid to be the head. + let best_node = self + .nodes + .get(best_fc_node.proto_node_index) + .ok_or(Error::InvalidNodeIndex(best_fc_node.proto_node_index))?; if !self.node_is_viable_for_head::( best_node, current_slot, @@ -682,13 +1099,381 @@ impl ProtoArray { start_root: *justified_root, justified_checkpoint: best_justified_checkpoint, finalized_checkpoint: best_finalized_checkpoint, - head_root: best_node.root, - head_justified_checkpoint: best_node.justified_checkpoint, - head_finalized_checkpoint: best_node.finalized_checkpoint, + head_root: best_node.root(), + head_justified_checkpoint: *best_node.justified_checkpoint(), + head_finalized_checkpoint: *best_node.finalized_checkpoint(), }))); } - Ok(best_node.root) + Ok((best_fc_node.root, best_fc_node.payload_status)) + } + + /// Spec: `get_filtered_block_tree`. + /// + /// Returns the set of node indices on viable branches — those with at least + /// one leaf descendant with correct justified/finalized checkpoints. + fn get_filtered_block_tree( + &self, + start_index: usize, + current_slot: Slot, + best_justified_checkpoint: Checkpoint, + best_finalized_checkpoint: Checkpoint, + ) -> HashSet { + let mut viable = HashSet::new(); + self.filter_block_tree::( + start_index, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + &mut viable, + ); + viable + } + + /// Spec: `filter_block_tree`. + fn filter_block_tree( + &self, + node_index: usize, + current_slot: Slot, + best_justified_checkpoint: Checkpoint, + best_finalized_checkpoint: Checkpoint, + viable: &mut HashSet, + ) -> bool { + let Some(node) = self.nodes.get(node_index) else { + return false; + }; + + // Skip invalid children — they aren't in store.blocks in the spec. + let children: Vec = self + .nodes + .iter() + .enumerate() + .filter(|(_, child)| { + child.parent() == Some(node_index) + && !child + .execution_status() + .is_ok_and(|status| status.is_invalid()) + }) + .map(|(i, _)| i) + .collect(); + + if !children.is_empty() { + // Evaluate ALL children (no short-circuit) to mark all viable branches. + let any_viable = children + .iter() + .map(|&child_index| { + self.filter_block_tree::( + child_index, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + viable, + ) + }) + .collect::>() + .into_iter() + .any(|v| v); + if any_viable { + viable.insert(node_index); + return true; + } + return false; + } + + // Leaf node: check viability. + if self.node_is_viable_for_head::( + node, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + ) { + viable.insert(node_index); + return true; + } + false + } + + /// Spec: `get_head`. + #[allow(clippy::too_many_arguments)] + fn find_head_walk( + &self, + start_index: usize, + current_slot: Slot, + best_justified_checkpoint: Checkpoint, + best_finalized_checkpoint: Checkpoint, + proposer_boost_root: Hash256, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result { + let mut head = IndexedForkChoiceNode { + root: best_justified_checkpoint.root, + proto_node_index: start_index, + payload_status: PayloadStatus::Pending, + }; + + // Spec: `get_filtered_block_tree`. + let viable_nodes = self.get_filtered_block_tree::( + start_index, + current_slot, + best_justified_checkpoint, + best_finalized_checkpoint, + ); + + // Compute once rather than per-child per-level. + let apply_proposer_boost = + self.should_apply_proposer_boost::(proposer_boost_root, justified_balances, spec)?; + + loop { + let children: Vec<_> = self + .get_node_children(&head)? + .into_iter() + .filter(|(fc_node, _)| viable_nodes.contains(&fc_node.proto_node_index)) + .collect(); + + if children.is_empty() { + return Ok(head); + } + + head = children + .into_iter() + .map(|(child, ref proto_node)| -> Result<_, Error> { + let weight = self.get_weight::( + &child, + proto_node, + apply_proposer_boost, + proposer_boost_root, + current_slot, + justified_balances, + spec, + )?; + let payload_status_tiebreaker = self.get_payload_status_tiebreaker::( + &child, + proto_node, + current_slot, + proposer_boost_root, + )?; + Ok((child, weight, payload_status_tiebreaker)) + }) + .collect::, Error>>()? + .into_iter() + .max_by_key(|(child, weight, payload_status_tiebreaker)| { + (*weight, child.root, *payload_status_tiebreaker) + }) + .map(|(child, _, _)| child) + .ok_or(Error::NoViableChildren)?; + } + } + + /// Spec: `get_weight`. + #[allow(clippy::too_many_arguments)] + fn get_weight( + &self, + fc_node: &IndexedForkChoiceNode, + proto_node: &ProtoNode, + apply_proposer_boost: bool, + proposer_boost_root: Hash256, + current_slot: Slot, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result { + if fc_node.payload_status == PayloadStatus::Pending + || proto_node.slot().saturating_add(1_u64) != current_slot + { + let attestation_score = proto_node.attestation_score(fc_node.payload_status); + + if !apply_proposer_boost { + return Ok(attestation_score); + } + + // Spec: proposer boost is treated as a synthetic vote. + let message = LatestMessage { + slot: current_slot, + root: proposer_boost_root, + payload_present: false, + }; + let proposer_score = if self.is_supporting_vote(fc_node, &message)? { + get_proposer_score::(justified_balances, spec)? + } else { + 0 + }; + + Ok(attestation_score.saturating_add(proposer_score)) + } else { + Ok(0) + } + } + + /// Spec: `is_supporting_vote`. + fn is_supporting_vote( + &self, + node: &IndexedForkChoiceNode, + message: &LatestMessage, + ) -> Result { + let block = self + .nodes + .get(node.proto_node_index) + .ok_or(Error::InvalidNodeIndex(node.proto_node_index))?; + + if node.root == message.root { + if node.payload_status == PayloadStatus::Pending { + return Ok(true); + } + // For the proposer boost case: message.slot == current_slot == block.slot, + // so this returns false — boost does not support EMPTY/FULL of the + // boosted block itself, only its ancestors. + if message.slot <= block.slot() { + return Ok(false); + } + if message.payload_present { + Ok(node.payload_status == PayloadStatus::Full) + } else { + Ok(node.payload_status == PayloadStatus::Empty) + } + } else { + let ancestor = self.get_ancestor_node(message.root, block.slot())?; + Ok(node.root == ancestor.root + && (node.payload_status == PayloadStatus::Pending + || node.payload_status == ancestor.payload_status)) + } + } + + /// Spec: `get_ancestor` (modified to return ForkChoiceNode with payload_status). + fn get_ancestor_node(&self, root: Hash256, slot: Slot) -> Result { + let index = *self.indices.get(&root).ok_or(Error::NodeUnknown(root))?; + let block = self + .nodes + .get(index) + .ok_or(Error::InvalidNodeIndex(index))?; + + if block.slot() <= slot { + return Ok(IndexedForkChoiceNode { + root, + proto_node_index: index, + payload_status: PayloadStatus::Pending, + }); + } + + // Walk up until we find the ancestor at `slot`. + let mut child_index = index; + let mut current_index = block.parent().ok_or(Error::NodeUnknown(block.root()))?; + + loop { + let current = self + .nodes + .get(current_index) + .ok_or(Error::InvalidNodeIndex(current_index))?; + + if current.slot() <= slot { + let child = self + .nodes + .get(child_index) + .ok_or(Error::InvalidNodeIndex(child_index))?; + return Ok(IndexedForkChoiceNode { + root: current.root(), + proto_node_index: current_index, + payload_status: child.get_parent_payload_status(), + }); + } + + child_index = current_index; + current_index = current.parent().ok_or(Error::NodeUnknown(current.root()))?; + } + } + + /// Spec: `get_node_children`. + fn get_node_children( + &self, + node: &IndexedForkChoiceNode, + ) -> Result, Error> { + if node.payload_status == PayloadStatus::Pending { + let proto_node = self + .nodes + .get(node.proto_node_index) + .ok_or(Error::InvalidNodeIndex(node.proto_node_index))?; + let mut children = vec![(node.with_status(PayloadStatus::Empty), proto_node.clone())]; + // The FULL virtual child only exists if the payload has been received. + if proto_node.payload_received().is_ok_and(|received| received) { + children.push((node.with_status(PayloadStatus::Full), proto_node.clone())); + } + Ok(children) + } else { + Ok(self + .nodes + .iter() + .enumerate() + .filter(|(_, child_node)| { + child_node.parent() == Some(node.proto_node_index) + && child_node.get_parent_payload_status() == node.payload_status + }) + .map(|(child_index, child_node)| { + ( + IndexedForkChoiceNode { + root: child_node.root(), + proto_node_index: child_index, + payload_status: PayloadStatus::Pending, + }, + child_node.clone(), + ) + }) + .collect()) + } + } + + fn get_payload_status_tiebreaker( + &self, + fc_node: &IndexedForkChoiceNode, + proto_node: &ProtoNode, + current_slot: Slot, + proposer_boost_root: Hash256, + ) -> Result { + if fc_node.payload_status == PayloadStatus::Pending + || proto_node.slot().saturating_add(1_u64) != current_slot + { + Ok(fc_node.payload_status as u8) + } else if fc_node.payload_status == PayloadStatus::Empty { + Ok(1) + } else if self.should_extend_payload::(fc_node, proto_node, proposer_boost_root)? { + Ok(2) + } else { + Ok(0) + } + } + + fn should_extend_payload( + &self, + fc_node: &IndexedForkChoiceNode, + proto_node: &ProtoNode, + proposer_boost_root: Hash256, + ) -> Result { + // Per spec: `proposer_root == Root()` is one of the `or` conditions that + // makes `should_extend_payload` return True. + if proposer_boost_root.is_zero() { + return Ok(true); + } + + let proposer_boost_node_index = *self + .indices + .get(&proposer_boost_root) + .ok_or(Error::NodeUnknown(proposer_boost_root))?; + let proposer_boost_node = self + .nodes + .get(proposer_boost_node_index) + .ok_or(Error::InvalidNodeIndex(proposer_boost_node_index))?; + + let parent_index = proposer_boost_node + .parent() + .ok_or(Error::NodeUnknown(proposer_boost_root))?; + let proposer_boost_parent_root = self + .nodes + .get(parent_index) + .ok_or(Error::InvalidNodeIndex(parent_index))? + .root(); + + Ok( + (proto_node.is_payload_timely::() && proto_node.is_payload_data_available::()) + || proposer_boost_parent_root != fc_node.root + || proposer_boost_node.is_parent_node_full(), + ) } /// Update the tree with new finalization information. The tree is only actually pruned if both @@ -721,7 +1506,7 @@ impl ProtoArray { .nodes .get(node_index) .ok_or(Error::InvalidNodeIndex(node_index))? - .root; + .root(); self.indices.remove(root); } @@ -738,176 +1523,15 @@ impl ProtoArray { // Iterate through all the existing nodes and adjust their indices to match the new layout // of `self.nodes`. for node in self.nodes.iter_mut() { - if let Some(parent) = node.parent { + if let Some(parent) = node.parent() { // If `node.parent` is less than `finalized_index`, set it to `None`. - node.parent = parent.checked_sub(finalized_index); - } - if let Some(best_child) = node.best_child { - node.best_child = Some( - best_child - .checked_sub(finalized_index) - .ok_or(Error::IndexOverflow("best_child"))?, - ); - } - if let Some(best_descendant) = node.best_descendant { - node.best_descendant = Some( - best_descendant - .checked_sub(finalized_index) - .ok_or(Error::IndexOverflow("best_descendant"))?, - ); + *node.parent_mut() = parent.checked_sub(finalized_index); } } Ok(()) } - /// Observe the parent at `parent_index` with respect to the child at `child_index` and - /// potentially modify the `parent.best_child` and `parent.best_descendant` values. - /// - /// ## Detail - /// - /// There are four outcomes: - /// - /// - The child is already the best child but it's now invalid due to a FFG change and should be removed. - /// - The child is already the best child and the parent is updated with the new - /// best-descendant. - /// - The child is not the best child but becomes the best child. - /// - The child is not the best child and does not become the best child. - fn maybe_update_best_child_and_descendant( - &mut self, - parent_index: usize, - child_index: usize, - current_slot: Slot, - best_justified_checkpoint: Checkpoint, - best_finalized_checkpoint: Checkpoint, - ) -> Result<(), Error> { - let child = self - .nodes - .get(child_index) - .ok_or(Error::InvalidNodeIndex(child_index))?; - - let parent = self - .nodes - .get(parent_index) - .ok_or(Error::InvalidNodeIndex(parent_index))?; - - let child_leads_to_viable_head = self.node_leads_to_viable_head::( - child, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; - - // These three variables are aliases to the three options that we may set the - // `parent.best_child` and `parent.best_descendant` to. - // - // I use the aliases to assist readability. - let change_to_none = (None, None); - let change_to_child = ( - Some(child_index), - child.best_descendant.or(Some(child_index)), - ); - let no_change = (parent.best_child, parent.best_descendant); - - let (new_best_child, new_best_descendant) = - if let Some(best_child_index) = parent.best_child { - if best_child_index == child_index && !child_leads_to_viable_head { - // If the child is already the best-child of the parent but it's not viable for - // the head, remove it. - change_to_none - } else if best_child_index == child_index { - // If the child is the best-child already, set it again to ensure that the - // best-descendant of the parent is updated. - change_to_child - } else { - let best_child = self - .nodes - .get(best_child_index) - .ok_or(Error::InvalidBestDescendant(best_child_index))?; - - let best_child_leads_to_viable_head = self.node_leads_to_viable_head::( - best_child, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )?; - - if child_leads_to_viable_head && !best_child_leads_to_viable_head { - // The child leads to a viable head, but the current best-child doesn't. - change_to_child - } else if !child_leads_to_viable_head && best_child_leads_to_viable_head { - // The best child leads to a viable head, but the child doesn't. - no_change - } else if child.weight == best_child.weight { - // Tie-breaker of equal weights by root. - if child.root >= best_child.root { - change_to_child - } else { - no_change - } - } else { - // Choose the winner by weight. - if child.weight > best_child.weight { - change_to_child - } else { - no_change - } - } - } - } else if child_leads_to_viable_head { - // There is no current best-child and the child is viable. - change_to_child - } else { - // There is no current best-child but the child is not viable. - no_change - }; - - let parent = self - .nodes - .get_mut(parent_index) - .ok_or(Error::InvalidNodeIndex(parent_index))?; - - parent.best_child = new_best_child; - parent.best_descendant = new_best_descendant; - - Ok(()) - } - - /// Indicates if the node itself is viable for the head, or if its best descendant is viable - /// for the head. - fn node_leads_to_viable_head( - &self, - node: &ProtoNode, - current_slot: Slot, - best_justified_checkpoint: Checkpoint, - best_finalized_checkpoint: Checkpoint, - ) -> Result { - let best_descendant_is_viable_for_head = - if let Some(best_descendant_index) = node.best_descendant { - let best_descendant = self - .nodes - .get(best_descendant_index) - .ok_or(Error::InvalidBestDescendant(best_descendant_index))?; - - self.node_is_viable_for_head::( - best_descendant, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - ) - } else { - false - }; - - Ok(best_descendant_is_viable_for_head - || self.node_is_viable_for_head::( - node, - current_slot, - best_justified_checkpoint, - best_finalized_checkpoint, - )) - } - /// This is the equivalent to the `filter_block_tree` function in the eth2 spec: /// /// https://github.com/ethereum/eth2.0-specs/blob/v0.10.0/specs/phase0/fork-choice.md#filter_block_tree @@ -921,25 +1545,27 @@ impl ProtoArray { best_justified_checkpoint: Checkpoint, best_finalized_checkpoint: Checkpoint, ) -> bool { - if node.execution_status.is_invalid() { + if let Ok(proto_node) = node.as_v17() + && proto_node.execution_status.is_invalid() + { return false; } let genesis_epoch = Epoch::new(0); let current_epoch = current_slot.epoch(E::slots_per_epoch()); - let node_epoch = node.slot.epoch(E::slots_per_epoch()); - let node_justified_checkpoint = node.justified_checkpoint; + let node_epoch = node.slot().epoch(E::slots_per_epoch()); + let node_justified_checkpoint = node.justified_checkpoint(); let voting_source = if current_epoch > node_epoch { // The block is from a prior epoch, the voting source will be pulled-up. - node.unrealized_justified_checkpoint + node.unrealized_justified_checkpoint() // Sometimes we don't track the unrealized justification. In // that case, just use the fully-realized justified checkpoint. - .unwrap_or(node_justified_checkpoint) + .unwrap_or(*node_justified_checkpoint) } else { // The block is not from a prior epoch, therefore the voting source // is not pulled up. - node_justified_checkpoint + *node_justified_checkpoint }; let correct_justified = best_justified_checkpoint.epoch == genesis_epoch @@ -948,7 +1574,7 @@ impl ProtoArray { let correct_finalized = best_finalized_checkpoint.epoch == genesis_epoch || self - .is_finalized_checkpoint_or_descendant::(node.root, best_finalized_checkpoint); + .is_finalized_checkpoint_or_descendant::(node.root(), best_finalized_checkpoint); correct_justified && correct_finalized } @@ -970,7 +1596,7 @@ impl ProtoArray { block_root: &Hash256, ) -> impl Iterator + 'a { self.iter_nodes(block_root) - .map(|node| (node.root, node.slot)) + .map(|node| (node.root(), node.slot())) } /// Returns `true` if the `descendant_root` has an ancestor with `ancestor_root`. Always @@ -991,8 +1617,8 @@ impl ProtoArray { .and_then(|ancestor_index| self.nodes.get(*ancestor_index)) .and_then(|ancestor| { self.iter_block_roots(&descendant_root) - .take_while(|(_root, slot)| *slot >= ancestor.slot) - .find(|(_root, slot)| *slot == ancestor.slot) + .take_while(|(_root, slot)| *slot >= ancestor.slot()) + .find(|(_root, slot)| *slot == ancestor.slot()) .map(|(root, _slot)| root == ancestor_root) }) .unwrap_or(false) @@ -1031,15 +1657,15 @@ impl ProtoArray { // Run this check once, outside of the loop rather than inside the loop. // If the conditions don't match for this node then they're unlikely to // start matching for its ancestors. - for checkpoint in &[node.finalized_checkpoint, node.justified_checkpoint] { - if checkpoint == &best_finalized_checkpoint { + for checkpoint in &[node.finalized_checkpoint(), node.justified_checkpoint()] { + if **checkpoint == best_finalized_checkpoint { return true; } } for checkpoint in &[ - node.unrealized_finalized_checkpoint, - node.unrealized_justified_checkpoint, + node.unrealized_finalized_checkpoint(), + node.unrealized_justified_checkpoint(), ] { if checkpoint.is_some_and(|cp| cp == best_finalized_checkpoint) { return true; @@ -1049,13 +1675,13 @@ impl ProtoArray { loop { // If `node` is less than or equal to the finalized slot then `node` // must be the finalized block. - if node.slot <= finalized_slot { - return node.root == finalized_root; + if node.slot() <= finalized_slot { + return node.root() == finalized_root; } // Since `node` is from a higher slot that the finalized checkpoint, // replace `node` with the parent of `node`. - if let Some(parent) = node.parent.and_then(|index| self.nodes.get(index)) { + if let Some(parent) = node.parent().and_then(|index| self.nodes.get(index)) { node = parent } else { // If `node` is not the finalized block and its parent does not @@ -1077,11 +1703,12 @@ impl ProtoArray { .iter() .rev() .find(|node| { - node.execution_status - .block_hash() + node.execution_status() + .ok() + .and_then(|execution_status| execution_status.block_hash()) .is_some_and(|node_block_hash| node_block_hash == *block_hash) }) - .map(|node| node.root) + .map(|node| node.root()) } /// Returns all nodes that have zero children and are descended from the finalized checkpoint. @@ -1095,13 +1722,17 @@ impl ProtoArray { ) -> Vec<&ProtoNode> { self.nodes .iter() - .filter(|node| { - node.best_child.is_none() + .enumerate() + .filter(|(i, node)| { + // TODO(gloas): we unoptimized this for Gloas fork choice, could re-optimize. + let num_children = self.nodes.iter().filter(|n| n.parent() == Some(*i)).count(); + num_children == 0 && self.is_finalized_checkpoint_or_descendant::( - node.root, + node.root(), best_finalized_checkpoint, ) }) + .map(|(_, node)| node) .collect() } } @@ -1121,6 +1752,31 @@ pub fn calculate_committee_fraction( .checked_div(100) } +/// Spec: `get_proposer_score`. +fn get_proposer_score( + justified_balances: &JustifiedBalances, + spec: &ChainSpec, +) -> Result { + let Some(proposer_score_boost) = spec.proposer_score_boost else { + return Ok(0); + }; + calculate_committee_fraction::(justified_balances, proposer_score_boost) + .ok_or(Error::ProposerBoostOverflow(0)) +} + +/// Apply a signed delta to an unsigned weight, returning an error on overflow. +fn apply_delta(weight: u64, delta: i64, index: usize) -> Result { + if delta < 0 { + weight + .checked_sub(delta.unsigned_abs()) + .ok_or(Error::DeltaOverflow(index)) + } else { + weight + .checked_add(delta as u64) + .ok_or(Error::DeltaOverflow(index)) + } +} + /// Reverse iterator over one path through a `ProtoArray`. pub struct Iter<'a> { next_node_index: Option, @@ -1133,7 +1789,7 @@ impl<'a> Iterator for Iter<'a> { fn next(&mut self) -> Option { let next_node_index = self.next_node_index?; let node = self.proto_array.nodes.get(next_node_index)?; - self.next_node_index = node.parent; + self.next_node_index = node.parent(); Some(node) } } diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 3edf1e0644..0ecaea3971 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -2,8 +2,7 @@ use crate::{ JustifiedBalances, error::Error, proto_array::{ - InvalidationOperation, Iter, ProposerBoost, ProtoArray, ProtoNode, - calculate_committee_fraction, + InvalidationOperation, Iter, NodeDelta, ProtoArray, ProtoNode, calculate_committee_fraction, }, ssz_container::SszContainer, }; @@ -14,22 +13,74 @@ use ssz_derive::{Decode, Encode}; use std::{ collections::{BTreeSet, HashMap}, fmt, + time::Duration, }; use types::{ AttestationShufflingId, ChainSpec, Checkpoint, Epoch, EthSpec, ExecutionBlockHash, Hash256, - Slot, + Slot, StatePayloadStatus, }; pub const DEFAULT_PRUNE_THRESHOLD: usize = 256; #[derive(Default, PartialEq, Clone, Encode, Decode)] pub struct VoteTracker { + current_root: Hash256, + next_root: Hash256, + current_slot: Slot, + next_slot: Slot, + current_payload_present: bool, + next_payload_present: bool, +} + +// Can be deleted once the V28 schema migration is buried. +// Matches the on-disk format from schema v28: current_root, next_root, next_epoch. +#[derive(Default, PartialEq, Clone, Encode, Decode)] +pub struct VoteTrackerV28 { current_root: Hash256, next_root: Hash256, next_epoch: Epoch, } -/// Represents the verification status of an execution payload. +// This impl is only used upon upgrade from pre-Gloas to Gloas with all pre-Gloas nodes. +// The payload status is `false` for pre-Gloas nodes. +impl From for VoteTracker { + fn from(v: VoteTrackerV28) -> Self { + VoteTracker { + current_root: v.current_root, + next_root: v.next_root, + // The v28 format stored next_epoch rather than slots. Default to 0 since the + // vote tracker will be updated on the next attestation. + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, + } + } +} + +// This impl is only used upon downgrade from V29 to V28, with exclusively pre-Gloas nodes. +impl From for VoteTrackerV28 { + fn from(v: VoteTracker) -> Self { + // Drop the payload_present fields. This is safe because this is only called on pre-Gloas + // nodes. + VoteTrackerV28 { + current_root: v.current_root, + next_root: v.next_root, + // The v28 format stored next_epoch. Default to 0 since the vote tracker will be + // updated on the next attestation. + next_epoch: Epoch::new(0), + } + } +} + +/// Spec's `LatestMessage` type. Only used in tests. +pub struct LatestMessage { + pub slot: Slot, + pub root: Hash256, + pub payload_present: bool, +} + +/// Represents the verification status of an execution payload pre-Gloas. #[derive(Clone, Copy, Debug, PartialEq, Encode, Decode, Serialize, Deserialize)] #[ssz(enum_behaviour = "union")] pub enum ExecutionStatus { @@ -49,6 +100,46 @@ pub enum ExecutionStatus { Irrelevant(bool), } +/// Represents the status of an execution payload post-Gloas. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Encode, Decode, Serialize, Deserialize)] +#[ssz(enum_behaviour = "tag")] +#[repr(u8)] +pub enum PayloadStatus { + Empty = 0, + Full = 1, + Pending = 2, +} + +impl PayloadStatus { + /// Convert a `PayloadStatus` into the equivalent `StatePayloadStatus`. + /// + /// This maps `Empty` onto `StatePayloadStatus::Pending` because empty and pending fork choice + /// nodes correspond to the exact same state. + pub fn as_state_payload_status(self) -> StatePayloadStatus { + match self { + Self::Empty | Self::Pending => StatePayloadStatus::Pending, + Self::Full => StatePayloadStatus::Full, + } + } +} + +/// Spec's `ForkChoiceNode` augmented with ProtoNode index. +pub struct IndexedForkChoiceNode { + pub root: Hash256, + pub proto_node_index: usize, + pub payload_status: PayloadStatus, +} + +impl IndexedForkChoiceNode { + pub fn with_status(&self, payload_status: PayloadStatus) -> Self { + Self { + root: self.root, + proto_node_index: self.proto_node_index, + payload_status, + } + } +} + impl ExecutionStatus { pub fn is_execution_enabled(&self) -> bool { !matches!(self, ExecutionStatus::Irrelevant(_)) @@ -159,6 +250,11 @@ pub struct Block { pub execution_status: ExecutionStatus, pub unrealized_justified_checkpoint: Option, pub unrealized_finalized_checkpoint: Option, + + /// post-Gloas fields + pub execution_payload_parent_hash: Option, + pub execution_payload_block_hash: Option, + pub proposer_index: Option, } impl Block { @@ -422,12 +518,15 @@ impl ProtoArrayForkChoice { current_epoch_shuffling_id: AttestationShufflingId, next_epoch_shuffling_id: AttestationShufflingId, execution_status: ExecutionStatus, + execution_payload_parent_hash: Option, + execution_payload_block_hash: Option, + proposer_index: u64, + spec: &ChainSpec, ) -> Result { let mut proto_array = ProtoArray { prune_threshold: DEFAULT_PRUNE_THRESHOLD, nodes: Vec::with_capacity(1), indices: HashMap::with_capacity(1), - previous_proposer_boost: ProposerBoost::default(), }; let block = Block { @@ -445,14 +544,20 @@ impl ProtoArrayForkChoice { execution_status, unrealized_justified_checkpoint: Some(justified_checkpoint), unrealized_finalized_checkpoint: Some(finalized_checkpoint), + execution_payload_parent_hash, + execution_payload_block_hash, + proposer_index: Some(proposer_index), }; proto_array .on_block::( block, current_slot, - justified_checkpoint, - finalized_checkpoint, + spec, + // Anchor block is always timely (delay=0 ensures both timeliness + // checks pass). Combined with `is_genesis` override in on_block, + // this matches spec's `block_timeliness = {anchor: [True, True]}`. + Duration::ZERO, ) .map_err(|e| format!("Failed to add finalized block to proto_array: {:?}", e))?; @@ -463,6 +568,18 @@ impl ProtoArrayForkChoice { }) } + /// Mark a Gloas payload envelope as valid and received. + /// + /// This must only be called for valid Gloas payloads. + pub fn on_valid_payload_envelope_received( + &mut self, + block_root: Hash256, + ) -> Result<(), String> { + self.proto_array + .on_valid_payload_envelope_received(block_root) + .map_err(|e| format!("Failed to process execution payload: {:?}", e)) + } + /// See `ProtoArray::propagate_execution_payload_validation` for documentation. pub fn process_execution_payload_validation( &mut self, @@ -488,36 +605,71 @@ impl ProtoArrayForkChoice { &mut self, validator_index: usize, block_root: Hash256, - target_epoch: Epoch, + attestation_slot: Slot, + payload_present: bool, ) -> Result<(), String> { let vote = self.votes.get_mut(validator_index); - if target_epoch > vote.next_epoch || *vote == VoteTracker::default() { + if attestation_slot > vote.next_slot || *vote == VoteTracker::default() { vote.next_root = block_root; - vote.next_epoch = target_epoch; + vote.next_slot = attestation_slot; + vote.next_payload_present = payload_present; } Ok(()) } + /// Process a PTC vote by setting the appropriate bits on the target block's V29 node. + /// + /// `ptc_index` is the voter's position in the PTC committee (resolved by the caller). + /// This writes directly to the node's bitfields, bypassing the delta pipeline. + pub fn process_payload_attestation( + &mut self, + block_root: Hash256, + ptc_index: usize, + payload_present: bool, + blob_data_available: bool, + ) -> Result<(), String> { + let node_index = self + .proto_array + .indices + .get(&block_root) + .copied() + .ok_or_else(|| { + format!("process_payload_attestation: unknown block root {block_root:?}") + })?; + let node = self.proto_array.nodes.get_mut(node_index).ok_or_else(|| { + format!("process_payload_attestation: invalid node index {node_index}") + })?; + let v29 = node + .as_v29_mut() + .map_err(|_| format!("process_payload_attestation: node {block_root:?} is not V29"))?; + + v29.payload_timeliness_votes + .set(ptc_index, payload_present) + .map_err(|e| format!("process_payload_attestation: timeliness set failed: {e:?}"))?; + v29.payload_data_availability_votes + .set(ptc_index, blob_data_available) + .map_err(|e| { + format!("process_payload_attestation: data availability set failed: {e:?}") + })?; + + Ok(()) + } + pub fn process_block( &mut self, block: Block, current_slot: Slot, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, + spec: &ChainSpec, + time_into_slot: Duration, ) -> Result<(), String> { if block.parent_root.is_none() { return Err("Missing parent root".to_string()); } self.proto_array - .on_block::( - block, - current_slot, - justified_checkpoint, - finalized_checkpoint, - ) + .on_block::(block, current_slot, spec, time_into_slot) .map_err(|e| format!("process_block_error: {:?}", e)) } @@ -531,12 +683,19 @@ impl ProtoArrayForkChoice { equivocating_indices: &BTreeSet, current_slot: Slot, spec: &ChainSpec, - ) -> Result { + ) -> Result<(Hash256, PayloadStatus), String> { let old_balances = &mut self.balances; let new_balances = justified_state_balances; + let node_slots = self + .proto_array + .nodes + .iter() + .map(|node| node.slot()) + .collect::>(); let deltas = compute_deltas( &self.proto_array.indices, + &node_slots, &mut self.votes, &old_balances.effective_balances, &new_balances.effective_balances, @@ -545,15 +704,7 @@ impl ProtoArrayForkChoice { .map_err(|e| format!("find_head compute_deltas failed: {:?}", e))?; self.proto_array - .apply_score_changes::( - deltas, - justified_checkpoint, - finalized_checkpoint, - new_balances, - proposer_boost_root, - current_slot, - spec, - ) + .apply_score_changes::(deltas) .map_err(|e| format!("find_head apply_score_changes failed: {:?}", e))?; *old_balances = new_balances.clone(); @@ -564,6 +715,9 @@ impl ProtoArrayForkChoice { current_slot, justified_checkpoint, finalized_checkpoint, + proposer_boost_root, + new_balances, + spec, ) .map_err(|e| format!("find_head failed: {:?}", e)) } @@ -593,13 +747,13 @@ impl ProtoArrayForkChoice { )?; // Only re-org a single slot. This prevents cascading failures during asynchrony. - let head_slot_ok = info.head_node.slot + 1 == current_slot; + let head_slot_ok = info.head_node.slot().saturating_add(1_u64) == current_slot; if !head_slot_ok { return Err(DoNotReOrg::HeadDistance.into()); } // Only re-org if the head's weight is less than the heads configured committee fraction. - let head_weight = info.head_node.weight; + let head_weight = info.head_node.weight(); let re_org_head_weight_threshold = info.re_org_head_weight_threshold; let weak_head = head_weight < re_org_head_weight_threshold; if !weak_head { @@ -610,8 +764,10 @@ impl ProtoArrayForkChoice { .into()); } - // Only re-org if the parent's weight is greater than the parents configured committee fraction. - let parent_weight = info.parent_node.weight; + // Spec: `is_parent_strong`. Use payload-aware weight matching the + // payload path the head node is on from its parent. + let parent_payload_status = info.head_node.get_parent_payload_status(); + let parent_weight = info.parent_node.attestation_score(parent_payload_status); let re_org_parent_weight_threshold = info.re_org_parent_weight_threshold; let parent_strong = parent_weight > re_org_parent_weight_threshold; if !parent_strong { @@ -650,14 +806,14 @@ impl ProtoArrayForkChoice { let parent_node = nodes.pop().ok_or(DoNotReOrg::MissingHeadOrParentNode)?; let head_node = nodes.pop().ok_or(DoNotReOrg::MissingHeadOrParentNode)?; - let parent_slot = parent_node.slot; - let head_slot = head_node.slot; - let re_org_block_slot = head_slot + 1; + let parent_slot = parent_node.slot(); + let head_slot = head_node.slot(); + let re_org_block_slot = head_slot.saturating_add(1_u64); // Check finalization distance. let proposal_epoch = re_org_block_slot.epoch(E::slots_per_epoch()); let finalized_epoch = head_node - .unrealized_finalized_checkpoint + .unrealized_finalized_checkpoint() .ok_or(DoNotReOrg::MissingHeadFinalizedCheckpoint)? .epoch; let epochs_since_finalization = proposal_epoch.saturating_sub(finalized_epoch).as_u64(); @@ -689,10 +845,10 @@ impl ProtoArrayForkChoice { } // Check FFG. - let ffg_competitive = parent_node.unrealized_justified_checkpoint - == head_node.unrealized_justified_checkpoint - && parent_node.unrealized_finalized_checkpoint - == head_node.unrealized_finalized_checkpoint; + let ffg_competitive = parent_node.unrealized_justified_checkpoint() + == head_node.unrealized_justified_checkpoint() + && parent_node.unrealized_finalized_checkpoint() + == head_node.unrealized_finalized_checkpoint(); if !ffg_competitive { return Err(DoNotReOrg::JustificationAndFinalizationNotCompetitive.into()); } @@ -720,20 +876,17 @@ impl ProtoArrayForkChoice { /// This will operate on *all* blocks, even those that do not descend from the finalized /// ancestor. pub fn contains_invalid_payloads(&mut self) -> bool { - self.proto_array - .nodes - .iter() - .any(|node| node.execution_status.is_invalid()) + self.proto_array.nodes.iter().any(|node| { + node.execution_status() + .is_ok_and(|status| status.is_invalid()) + }) } /// For all nodes, regardless of their relationship to the finalized block, set their execution /// status to be optimistic. /// /// In practice this means forgetting any `VALID` or `INVALID` statuses. - pub fn set_all_blocks_to_optimistic( - &mut self, - spec: &ChainSpec, - ) -> Result<(), String> { + pub fn set_all_blocks_to_optimistic(&mut self) -> Result<(), String> { // Iterate backwards through all nodes in the `proto_array`. Whilst it's not strictly // required to do this process in reverse, it seems natural when we consider how LMD votes // are counted. @@ -748,19 +901,21 @@ impl ProtoArrayForkChoice { .get_mut(node_index) .ok_or("unreachable index out of bounds in proto_array nodes")?; - match node.execution_status { - ExecutionStatus::Invalid(block_hash) => { - node.execution_status = ExecutionStatus::Optimistic(block_hash); + match node.execution_status() { + Ok(ExecutionStatus::Invalid(block_hash)) => { + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Optimistic(block_hash); + } // Restore the weight of the node, it would have been set to `0` in // `apply_score_changes` when it was invalidated. - let mut restored_weight: u64 = self + let restored_weight: u64 = self .votes .0 .iter() .enumerate() .filter_map(|(validator_index, vote)| { - if vote.current_root == node.root { + if vote.current_root == node.root() { // Any voting validator that does not have a balance should be // ignored. This is consistent with `compute_deltas`. self.balances.effective_balances.get(validator_index) @@ -770,36 +925,16 @@ impl ProtoArrayForkChoice { }) .sum(); - // If the invalid root was boosted, apply the weight to it and - // ancestors. - if let Some(proposer_score_boost) = spec.proposer_score_boost - && self.proto_array.previous_proposer_boost.root == node.root - { - // Compute the score based upon the current balances. We can't rely on - // the `previous_proposr_boost.score` since it is set to zero with an - // invalid node. - let proposer_score = - calculate_committee_fraction::(&self.balances, proposer_score_boost) - .ok_or("Failed to compute proposer boost")?; - // Store the score we've applied here so it can be removed in - // a later call to `apply_score_changes`. - self.proto_array.previous_proposer_boost.score = proposer_score; - // Apply this boost to this node. - restored_weight = restored_weight - .checked_add(proposer_score) - .ok_or("Overflow when adding boost to weight")?; - } - // Add the restored weight to the node and all ancestors. if restored_weight > 0 { let mut node_or_ancestor = node; loop { - node_or_ancestor.weight = node_or_ancestor - .weight + *node_or_ancestor.weight_mut() = node_or_ancestor + .weight() .checked_add(restored_weight) .ok_or("Overflow when adding weight to ancestor")?; - if let Some(parent_index) = node_or_ancestor.parent { + if let Some(parent_index) = node_or_ancestor.parent() { node_or_ancestor = self .proto_array .nodes @@ -815,11 +950,14 @@ impl ProtoArrayForkChoice { } // There are no balance changes required if the node was either valid or // optimistic. - ExecutionStatus::Valid(block_hash) | ExecutionStatus::Optimistic(block_hash) => { - node.execution_status = ExecutionStatus::Optimistic(block_hash) + Ok(ExecutionStatus::Valid(block_hash)) + | Ok(ExecutionStatus::Optimistic(block_hash)) => { + if let ProtoNode::V17(node) = node { + node.execution_status = ExecutionStatus::Optimistic(block_hash) + } } // An irrelevant node cannot become optimistic, this is a no-op. - ExecutionStatus::Irrelevant(_) => (), + Ok(ExecutionStatus::Irrelevant(_)) | Err(_) => (), } } @@ -856,30 +994,48 @@ impl ProtoArrayForkChoice { pub fn get_block(&self, block_root: &Hash256) -> Option { let block = self.get_proto_node(block_root)?; let parent_root = block - .parent + .parent() .and_then(|i| self.proto_array.nodes.get(i)) - .map(|parent| parent.root); + .map(|parent| parent.root()); Some(Block { - slot: block.slot, - root: block.root, + slot: block.slot(), + root: block.root(), parent_root, - state_root: block.state_root, - target_root: block.target_root, - current_epoch_shuffling_id: block.current_epoch_shuffling_id.clone(), - next_epoch_shuffling_id: block.next_epoch_shuffling_id.clone(), - justified_checkpoint: block.justified_checkpoint, - finalized_checkpoint: block.finalized_checkpoint, - execution_status: block.execution_status, - unrealized_justified_checkpoint: block.unrealized_justified_checkpoint, - unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint, + state_root: block.state_root(), + target_root: block.target_root(), + current_epoch_shuffling_id: block.current_epoch_shuffling_id().clone(), + next_epoch_shuffling_id: block.next_epoch_shuffling_id().clone(), + justified_checkpoint: *block.justified_checkpoint(), + finalized_checkpoint: *block.finalized_checkpoint(), + execution_status: block + .execution_status() + .unwrap_or_else(|_| ExecutionStatus::irrelevant()), + unrealized_justified_checkpoint: block.unrealized_justified_checkpoint(), + unrealized_finalized_checkpoint: block.unrealized_finalized_checkpoint(), + execution_payload_parent_hash: block.execution_payload_parent_hash().ok(), + execution_payload_block_hash: block.execution_payload_block_hash().ok(), + proposer_index: block.proposer_index().ok(), }) } /// Returns the `block.execution_status` field, if the block is present. pub fn get_block_execution_status(&self, block_root: &Hash256) -> Option { let block = self.get_proto_node(block_root)?; - Some(block.execution_status) + Some( + block + .execution_status() + .unwrap_or_else(|_| ExecutionStatus::irrelevant()), + ) + } + + /// Returns whether the execution payload for a block has been received. + /// + /// Returns `false` for pre-Gloas (V17) nodes or unknown blocks. + pub fn is_payload_received(&self, block_root: &Hash256) -> bool { + self.get_proto_node(block_root) + .and_then(|node| node.payload_received().ok()) + .unwrap_or(false) } /// Returns the weight of a given block. @@ -888,9 +1044,11 @@ impl ProtoArrayForkChoice { self.proto_array .nodes .get(*block_index) - .map(|node| node.weight) + .map(|node| node.weight()) } + /// Returns the payload status of the head node based on accumulated weights and tiebreaker. + /// /// See `ProtoArray` documentation. pub fn is_descendant(&self, ancestor_root: Hash256, descendant_root: Hash256) -> bool { self.proto_array @@ -907,14 +1065,17 @@ impl ProtoArrayForkChoice { .is_finalized_checkpoint_or_descendant::(descendant_root, best_finalized_checkpoint) } - pub fn latest_message(&self, validator_index: usize) -> Option<(Hash256, Epoch)> { - if validator_index < self.votes.0.len() { - let vote = &self.votes.0[validator_index]; - + /// NOTE: only used in tests. + pub fn latest_message(&self, validator_index: usize) -> Option { + if let Some(vote) = self.votes.0.get(validator_index) { if *vote == VoteTracker::default() { None } else { - Some((vote.next_root, vote.next_epoch)) + Some(LatestMessage { + root: vote.next_root, + slot: vote.next_slot, + payload_present: vote.next_payload_present, + }) } } else { None @@ -934,21 +1095,12 @@ impl ProtoArrayForkChoice { self.proto_array.iter_block_roots(block_root) } - pub fn as_ssz_container( - &self, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, - ) -> SszContainer { - SszContainer::from_proto_array(self, justified_checkpoint, finalized_checkpoint) + pub fn as_ssz_container(&self) -> SszContainer { + SszContainer::from_proto_array(self) } - pub fn as_bytes( - &self, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, - ) -> Vec { - self.as_ssz_container(justified_checkpoint, finalized_checkpoint) - .as_ssz_bytes() + pub fn as_bytes(&self) -> Vec { + self.as_ssz_container().as_ssz_bytes() } pub fn from_bytes(bytes: &[u8], balances: JustifiedBalances) -> Result { @@ -1002,12 +1154,28 @@ impl ProtoArrayForkChoice { /// always valid). fn compute_deltas( indices: &HashMap, + node_slots: &[Slot], votes: &mut ElasticList, old_balances: &[u64], new_balances: &[u64], equivocating_indices: &BTreeSet, -) -> Result, Error> { - let mut deltas = vec![0_i64; indices.len()]; +) -> Result, Error> { + let block_slot = |index: usize| -> Result { + node_slots + .get(index) + .copied() + .ok_or(Error::InvalidNodeDelta(index)) + }; + + let mut deltas = vec![ + NodeDelta { + delta: 0, + empty_delta: 0, + full_delta: 0, + equivocating_attestation_delta: 0, + }; + indices.len() + ]; for (val_index, vote) in votes.iter_mut().enumerate() { // There is no need to create a score change if the validator has never voted or both their @@ -1032,17 +1200,30 @@ fn compute_deltas( let old_balance = old_balances.get(val_index).copied().unwrap_or(0); if let Some(current_delta_index) = indices.get(&vote.current_root).copied() { - let delta = deltas - .get(current_delta_index) - .ok_or(Error::InvalidNodeDelta(current_delta_index))? + let node_delta = deltas + .get_mut(current_delta_index) + .ok_or(Error::InvalidNodeDelta(current_delta_index))?; + node_delta.delta = node_delta + .delta .checked_sub(old_balance as i64) .ok_or(Error::DeltaOverflow(current_delta_index))?; - // Array access safe due to check on previous line. - deltas[current_delta_index] = delta; + let status = NodeDelta::payload_status( + vote.current_slot, + vote.current_payload_present, + block_slot(current_delta_index)?, + ); + node_delta.sub_payload_delta(status, old_balance, current_delta_index)?; + + // Track equivocating weight for `is_head_weak` monotonicity. + node_delta.equivocating_attestation_delta = node_delta + .equivocating_attestation_delta + .saturating_add(old_balance); } vote.current_root = Hash256::zero(); + vote.current_slot = Slot::new(0); + vote.current_payload_present = false; } // We've handled this slashed validator, continue without applying an ordinary delta. continue; @@ -1059,34 +1240,52 @@ fn compute_deltas( // on-boarded less validators than the prior fork. let new_balance = new_balances.get(val_index).copied().unwrap_or(0); - if vote.current_root != vote.next_root || old_balance != new_balance { + if vote.current_root != vote.next_root + || old_balance != new_balance + || vote.current_payload_present != vote.next_payload_present + || vote.current_slot != vote.next_slot + { // We ignore the vote if it is not known in `indices`. We assume that it is outside // of our tree (i.e., pre-finalization) and therefore not interesting. if let Some(current_delta_index) = indices.get(&vote.current_root).copied() { - let delta = deltas - .get(current_delta_index) - .ok_or(Error::InvalidNodeDelta(current_delta_index))? + let node_delta = deltas + .get_mut(current_delta_index) + .ok_or(Error::InvalidNodeDelta(current_delta_index))?; + node_delta.delta = node_delta + .delta .checked_sub(old_balance as i64) .ok_or(Error::DeltaOverflow(current_delta_index))?; - // Array access safe due to check on previous line. - deltas[current_delta_index] = delta; + let status = NodeDelta::payload_status( + vote.current_slot, + vote.current_payload_present, + block_slot(current_delta_index)?, + ); + node_delta.sub_payload_delta(status, old_balance, current_delta_index)?; } // We ignore the vote if it is not known in `indices`. We assume that it is outside // of our tree (i.e., pre-finalization) and therefore not interesting. if let Some(next_delta_index) = indices.get(&vote.next_root).copied() { - let delta = deltas - .get(next_delta_index) - .ok_or(Error::InvalidNodeDelta(next_delta_index))? + let node_delta = deltas + .get_mut(next_delta_index) + .ok_or(Error::InvalidNodeDelta(next_delta_index))?; + node_delta.delta = node_delta + .delta .checked_add(new_balance as i64) .ok_or(Error::DeltaOverflow(next_delta_index))?; - // Array access safe due to check on previous line. - deltas[next_delta_index] = delta; + let status = NodeDelta::payload_status( + vote.next_slot, + vote.next_payload_present, + block_slot(next_delta_index)?, + ); + node_delta.add_payload_delta(status, new_balance, next_delta_index)?; } vote.current_root = vote.next_root; + vote.current_slot = vote.next_slot; + vote.current_payload_present = vote.next_payload_present; } } @@ -1104,8 +1303,13 @@ mod test_compute_deltas { Hash256::from_low_u64_be(i as u64 + 1) } + fn test_node_slots(count: usize) -> Vec { + vec![Slot::new(0); count] + } + #[test] fn finalized_descendant() { + let spec = MainnetEthSpec::default_spec(); let genesis_slot = Slot::new(0); let genesis_epoch = Epoch::new(0); @@ -1136,6 +1340,10 @@ mod test_compute_deltas { junk_shuffling_id.clone(), junk_shuffling_id.clone(), execution_status, + None, + None, + 0, + &spec, ) .unwrap(); @@ -1152,13 +1360,16 @@ mod test_compute_deltas { next_epoch_shuffling_id: junk_shuffling_id.clone(), justified_checkpoint: genesis_checkpoint, finalized_checkpoint: genesis_checkpoint, - execution_status, unrealized_justified_checkpoint: Some(genesis_checkpoint), + execution_status, unrealized_finalized_checkpoint: Some(genesis_checkpoint), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + proposer_index: Some(0), }, genesis_slot + 1, - genesis_checkpoint, - genesis_checkpoint, + &spec, + Duration::ZERO, ) .unwrap(); @@ -1180,10 +1391,13 @@ mod test_compute_deltas { execution_status, unrealized_justified_checkpoint: None, unrealized_finalized_checkpoint: None, + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + proposer_index: Some(0), }, genesis_slot + 1, - genesis_checkpoint, - genesis_checkpoint, + &spec, + Duration::ZERO, ) .unwrap(); @@ -1259,6 +1473,7 @@ mod test_compute_deltas { /// *checkpoint*, not just the finalized *block*. #[test] fn finalized_descendant_edge_case() { + let spec = MainnetEthSpec::default_spec(); let get_block_root = Hash256::from_low_u64_be; let genesis_slot = Slot::new(0); let junk_state_root = Hash256::zero(); @@ -1280,6 +1495,10 @@ mod test_compute_deltas { junk_shuffling_id.clone(), junk_shuffling_id.clone(), execution_status, + None, + None, + 0, + &spec, ) .unwrap(); @@ -1308,10 +1527,13 @@ mod test_compute_deltas { execution_status, unrealized_justified_checkpoint: Some(genesis_checkpoint), unrealized_finalized_checkpoint: Some(genesis_checkpoint), + execution_payload_parent_hash: None, + execution_payload_block_hash: None, + proposer_index: Some(0), }, Slot::from(block.slot), - genesis_checkpoint, - genesis_checkpoint, + &spec, + Duration::ZERO, ) .unwrap(); }; @@ -1414,7 +1636,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: Hash256::zero(), next_root: Hash256::zero(), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(0); new_balances.push(0); @@ -1422,6 +1647,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1465,7 +1691,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: Hash256::zero(), next_root: hash_from_index(0), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1473,6 +1702,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1523,7 +1753,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: Hash256::zero(), next_root: hash_from_index(i), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1531,6 +1764,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1576,7 +1810,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(0), next_root: hash_from_index(1), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(BALANCE); new_balances.push(BALANCE); @@ -1584,6 +1821,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1640,18 +1878,25 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: Hash256::zero(), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); // One validator moves their vote from the block to something outside the tree. votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: Hash256::from_low_u64_be(1337), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1693,7 +1938,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(0), next_root: hash_from_index(1), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); old_balances.push(OLD_BALANCE); new_balances.push(NEW_BALANCE); @@ -1701,6 +1949,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1762,12 +2011,16 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: hash_from_index(2), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); } let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1818,12 +2071,16 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: hash_from_index(2), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); } let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1872,7 +2129,10 @@ mod test_compute_deltas { votes.0.push(VoteTracker { current_root: hash_from_index(1), next_root: hash_from_index(2), - next_epoch: Epoch::new(0), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: false, }); } @@ -1881,6 +2141,7 @@ mod test_compute_deltas { let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &old_balances, &new_balances, @@ -1910,6 +2171,7 @@ mod test_compute_deltas { // Re-computing the deltas should be a no-op (no repeat deduction for the slashed validator). let deltas = compute_deltas( &indices, + &test_node_slots(indices.len()), &mut votes, &new_balances, &new_balances, @@ -1918,4 +2180,68 @@ mod test_compute_deltas { .expect("should compute deltas"); assert_eq!(deltas, vec![0, 0]); } + + #[test] + fn payload_bucket_changes_on_non_pending_vote() { + const BALANCE: u64 = 42; + + let mut indices = HashMap::new(); + indices.insert(hash_from_index(1), 0); + + let node_slots = vec![Slot::new(0)]; + let mut votes = ElasticList(vec![VoteTracker { + current_root: hash_from_index(1), + next_root: hash_from_index(1), + current_slot: Slot::new(1), + next_slot: Slot::new(1), + current_payload_present: false, + next_payload_present: true, + }]); + + let deltas = compute_deltas( + &indices, + &node_slots, + &mut votes, + &[BALANCE], + &[BALANCE], + &BTreeSet::new(), + ) + .expect("should compute deltas"); + + assert_eq!(deltas[0].delta, 0); + assert_eq!(deltas[0].empty_delta, -(BALANCE as i64)); + assert_eq!(deltas[0].full_delta, BALANCE as i64); + } + + #[test] + fn pending_vote_only_updates_regular_weight() { + const BALANCE: u64 = 42; + + let mut indices = HashMap::new(); + indices.insert(hash_from_index(1), 0); + + let node_slots = vec![Slot::new(0)]; + let mut votes = ElasticList(vec![VoteTracker { + current_root: hash_from_index(1), + next_root: hash_from_index(1), + current_slot: Slot::new(0), + next_slot: Slot::new(0), + current_payload_present: false, + next_payload_present: true, + }]); + + let deltas = compute_deltas( + &indices, + &node_slots, + &mut votes, + &[BALANCE], + &[BALANCE], + &BTreeSet::new(), + ) + .expect("should compute deltas"); + + assert_eq!(deltas[0].delta, 0); + assert_eq!(deltas[0].empty_delta, 0); + assert_eq!(deltas[0].full_delta, 0); + } } diff --git a/consensus/proto_array/src/ssz_container.rs b/consensus/proto_array/src/ssz_container.rs index 42696256f7..69efb35027 100644 --- a/consensus/proto_array/src/ssz_container.rs +++ b/consensus/proto_array/src/ssz_container.rs @@ -1,8 +1,8 @@ use crate::proto_array::ProposerBoost; use crate::{ Error, JustifiedBalances, - proto_array::{ProtoArray, ProtoNodeV17}, - proto_array_fork_choice::{ElasticList, ProtoArrayForkChoice, VoteTracker}, + proto_array::{ProtoArray, ProtoNode, ProtoNodeV17}, + proto_array_fork_choice::{ElasticList, ProtoArrayForkChoice, VoteTracker, VoteTrackerV28}, }; use ssz::{Encode, four_byte_option_impl}; use ssz_derive::{Decode, Encode}; @@ -14,54 +14,55 @@ use types::{Checkpoint, Hash256}; // selector. four_byte_option_impl!(four_byte_option_checkpoint, Checkpoint); -pub type SszContainer = SszContainerV28; +pub type SszContainer = SszContainerV29; #[superstruct( - variants(V28), + variants(V28, V29), variant_attributes(derive(Encode, Decode, Clone)), no_enum )] pub struct SszContainer { + #[superstruct(only(V28))] + pub votes_v28: Vec, + #[superstruct(only(V29))] pub votes: Vec, pub prune_threshold: usize, // Deprecated, remove in a future schema migration + #[superstruct(only(V28))] justified_checkpoint: Checkpoint, // Deprecated, remove in a future schema migration + #[superstruct(only(V28))] finalized_checkpoint: Checkpoint, + #[superstruct(only(V28))] pub nodes: Vec, + #[superstruct(only(V29))] + pub nodes: Vec, pub indices: Vec<(Hash256, usize)>, + #[superstruct(only(V28))] pub previous_proposer_boost: ProposerBoost, } -impl SszContainer { - pub fn from_proto_array( - from: &ProtoArrayForkChoice, - justified_checkpoint: Checkpoint, - finalized_checkpoint: Checkpoint, - ) -> Self { +impl SszContainerV29 { + pub fn from_proto_array(from: &ProtoArrayForkChoice) -> Self { let proto_array = &from.proto_array; Self { votes: from.votes.0.clone(), prune_threshold: proto_array.prune_threshold, - justified_checkpoint, - finalized_checkpoint, nodes: proto_array.nodes.clone(), indices: proto_array.indices.iter().map(|(k, v)| (*k, *v)).collect(), - previous_proposer_boost: proto_array.previous_proposer_boost, } } } -impl TryFrom<(SszContainer, JustifiedBalances)> for ProtoArrayForkChoice { +impl TryFrom<(SszContainerV29, JustifiedBalances)> for ProtoArrayForkChoice { type Error = Error; - fn try_from((from, balances): (SszContainer, JustifiedBalances)) -> Result { + fn try_from((from, balances): (SszContainerV29, JustifiedBalances)) -> Result { let proto_array = ProtoArray { prune_threshold: from.prune_threshold, nodes: from.nodes, indices: from.indices.into_iter().collect::>(), - previous_proposer_boost: from.previous_proposer_boost, }; Ok(Self { @@ -71,3 +72,50 @@ impl TryFrom<(SszContainer, JustifiedBalances)> for ProtoArrayForkChoice { }) } } + +// Convert legacy V28 to current V29. +impl From for SszContainerV29 { + fn from(v28: SszContainerV28) -> Self { + Self { + votes: v28.votes_v28.into_iter().map(Into::into).collect(), + prune_threshold: v28.prune_threshold, + nodes: v28 + .nodes + .into_iter() + .map(|mut node| { + // best_child/best_descendant are no longer used (replaced by + // the virtual tree walk). Clear during conversion. + node.best_child = None; + node.best_descendant = None; + ProtoNode::V17(node) + }) + .collect(), + indices: v28.indices, + } + } +} + +// Downgrade current V29 to legacy V28 (lossy: V29 nodes lose payload-specific fields). +impl From for SszContainerV28 { + fn from(v29: SszContainerV29) -> Self { + Self { + votes_v28: v29.votes.into_iter().map(Into::into).collect(), + prune_threshold: v29.prune_threshold, + // These checkpoints are not consumed in v28 paths since the upgrade from v17, + // we can safely default the values. + justified_checkpoint: Checkpoint::default(), + finalized_checkpoint: Checkpoint::default(), + nodes: v29 + .nodes + .into_iter() + .filter_map(|node| match node { + ProtoNode::V17(v17) => Some(v17), + ProtoNode::V29(_) => None, + }) + .collect(), + indices: v29.indices, + // Proposer boost is not tracked in V29 (computed on-the-fly), so reset it. + previous_proposer_boost: ProposerBoost::default(), + } + } +} diff --git a/consensus/types/src/attestation/indexed_payload_attestation.rs b/consensus/types/src/attestation/indexed_payload_attestation.rs index 4de805570c..bb2087e330 100644 --- a/consensus/types/src/attestation/indexed_payload_attestation.rs +++ b/consensus/types/src/attestation/indexed_payload_attestation.rs @@ -2,7 +2,6 @@ use crate::test_utils::TestRandom; use crate::{EthSpec, ForkName, PayloadAttestationData}; use bls::AggregateSignature; use context_deserialize::context_deserialize; -use core::slice::Iter; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::VariableList; @@ -21,12 +20,6 @@ pub struct IndexedPayloadAttestation { pub signature: AggregateSignature, } -impl IndexedPayloadAttestation { - pub fn attesting_indices_iter(&self) -> Iter<'_, u64> { - self.attesting_indices.iter() - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index cc79d3fc29..e612c8b6db 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -107,6 +107,8 @@ pub struct ChainSpec { pub shard_committee_period: u64, pub proposer_reorg_cutoff_bps: u64, pub attestation_due_bps: u64, + pub attestation_due_bps_gloas: u64, + pub payload_attestation_due_bps: u64, pub aggregate_due_bps: u64, pub sync_message_due_bps: u64, pub contribution_due_bps: u64, @@ -115,6 +117,8 @@ pub struct ChainSpec { * Derived time values (computed at startup via `compute_derived_values()`) */ pub unaggregated_attestation_due: Duration, + pub unaggregated_attestation_due_gloas: Duration, + pub payload_attestation_due: Duration, pub aggregate_attestation_due: Duration, pub sync_message_due: Duration, pub contribution_and_proof_due: Duration, @@ -877,6 +881,20 @@ impl ChainSpec { self.unaggregated_attestation_due } + /// Spec: `get_attestation_due_ms`. Returns the epoch-appropriate threshold. + pub fn get_attestation_due(&self, slot: Slot) -> Duration { + if self.fork_name_at_slot::(slot).gloas_enabled() { + self.unaggregated_attestation_due_gloas + } else { + self.unaggregated_attestation_due + } + } + + /// Spec: `get_payload_attestation_due_ms`. + pub fn get_payload_attestation_due(&self) -> Duration { + self.payload_attestation_due + } + /// Get the duration into a slot in which an aggregated attestation is due. /// Returns the pre-computed value from `compute_derived_values()`. pub fn get_aggregate_attestation_due(&self) -> Duration { @@ -949,6 +967,12 @@ impl ChainSpec { self.unaggregated_attestation_due = self .compute_slot_component_duration(self.attestation_due_bps) .expect("invalid chain spec: cannot compute unaggregated_attestation_due"); + self.unaggregated_attestation_due_gloas = self + .compute_slot_component_duration(self.attestation_due_bps_gloas) + .expect("invalid chain spec: cannot compute unaggregated_attestation_due_gloas"); + self.payload_attestation_due = self + .compute_slot_component_duration(self.payload_attestation_due_bps) + .expect("invalid chain spec: cannot compute payload_attestation_due"); self.aggregate_attestation_due = self .compute_slot_component_duration(self.aggregate_due_bps) .expect("invalid chain spec: cannot compute aggregate_attestation_due"); @@ -1079,6 +1103,8 @@ impl ChainSpec { shard_committee_period: 256, proposer_reorg_cutoff_bps: 1667, attestation_due_bps: 3333, + attestation_due_bps_gloas: 2500, + payload_attestation_due_bps: 7500, aggregate_due_bps: 6667, sync_message_due_bps: 3333, contribution_due_bps: 6667, @@ -1087,6 +1113,8 @@ impl ChainSpec { * Derived time values (set by `compute_derived_values()`) */ unaggregated_attestation_due: Duration::from_millis(3999), + unaggregated_attestation_due_gloas: Duration::from_millis(3000), + payload_attestation_due: Duration::from_millis(9000), aggregate_attestation_due: Duration::from_millis(8000), sync_message_due: Duration::from_millis(3999), contribution_and_proof_due: Duration::from_millis(8000), @@ -1390,6 +1418,8 @@ impl ChainSpec { * Precomputed for 6000ms slot: 3333 bps = 1999ms, 6667 bps = 4000ms */ unaggregated_attestation_due: Duration::from_millis(1999), + unaggregated_attestation_due_gloas: Duration::from_millis(1500), + payload_attestation_due: Duration::from_millis(4500), aggregate_attestation_due: Duration::from_millis(4000), sync_message_due: Duration::from_millis(1999), contribution_and_proof_due: Duration::from_millis(4000), @@ -1479,6 +1509,8 @@ impl ChainSpec { shard_committee_period: 256, proposer_reorg_cutoff_bps: 1667, attestation_due_bps: 3333, + attestation_due_bps_gloas: 2500, + payload_attestation_due_bps: 7500, aggregate_due_bps: 6667, /* @@ -1486,6 +1518,8 @@ impl ChainSpec { * Precomputed for 5000ms slot: 3333 bps = 1666ms, 6667 bps = 3333ms */ unaggregated_attestation_due: Duration::from_millis(1666), + unaggregated_attestation_due_gloas: Duration::from_millis(1250), + payload_attestation_due: Duration::from_millis(3750), aggregate_attestation_due: Duration::from_millis(3333), sync_message_due: Duration::from_millis(1666), contribution_and_proof_due: Duration::from_millis(3333), @@ -2062,6 +2096,12 @@ pub struct Config { #[serde(default = "default_attestation_due_bps")] #[serde(with = "serde_utils::quoted_u64")] attestation_due_bps: u64, + #[serde(default = "default_attestation_due_bps_gloas")] + #[serde(with = "serde_utils::quoted_u64")] + attestation_due_bps_gloas: u64, + #[serde(default = "default_payload_attestation_due_bps")] + #[serde(with = "serde_utils::quoted_u64")] + payload_attestation_due_bps: u64, #[serde(default = "default_aggregate_due_bps")] #[serde(with = "serde_utils::quoted_u64")] aggregate_due_bps: u64, @@ -2288,6 +2328,14 @@ const fn default_attestation_due_bps() -> u64 { 3333 } +const fn default_attestation_due_bps_gloas() -> u64 { + 2500 +} + +const fn default_payload_attestation_due_bps() -> u64 { + 7500 +} + const fn default_aggregate_due_bps() -> u64 { 6667 } @@ -2539,6 +2587,8 @@ impl Config { proposer_reorg_cutoff_bps: spec.proposer_reorg_cutoff_bps, attestation_due_bps: spec.attestation_due_bps, + attestation_due_bps_gloas: spec.attestation_due_bps_gloas, + payload_attestation_due_bps: spec.payload_attestation_due_bps, aggregate_due_bps: spec.aggregate_due_bps, sync_message_due_bps: spec.sync_message_due_bps, contribution_due_bps: spec.contribution_due_bps, @@ -2632,6 +2682,8 @@ impl Config { min_epochs_for_data_column_sidecars_requests, proposer_reorg_cutoff_bps, attestation_due_bps, + attestation_due_bps_gloas, + payload_attestation_due_bps, aggregate_due_bps, sync_message_due_bps, contribution_due_bps, @@ -2731,6 +2783,8 @@ impl Config { proposer_reorg_cutoff_bps, attestation_due_bps, + attestation_due_bps_gloas, + payload_attestation_due_bps, aggregate_due_bps, sync_message_due_bps, contribution_due_bps, @@ -3634,11 +3688,9 @@ mod yaml_tests { "EIP7928_FORK_VERSION", "EIP7928_FORK_EPOCH", // Gloas params not yet in Config - "ATTESTATION_DUE_BPS_GLOAS", "AGGREGATE_DUE_BPS_GLOAS", "SYNC_MESSAGE_DUE_BPS_GLOAS", "CONTRIBUTION_DUE_BPS_GLOAS", - "PAYLOAD_ATTESTATION_DUE_BPS", "MAX_REQUEST_PAYLOADS", // Gloas fork choice params not yet in Config "REORG_HEAD_WEIGHT_THRESHOLD", diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 07a7d4c6b6..06f204ab01 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -30,7 +30,8 @@ use types::{ Attestation, AttestationRef, AttesterSlashing, AttesterSlashingRef, BeaconBlock, BeaconState, BlobSidecar, BlobsList, BlockImportSource, Checkpoint, DataColumnSidecar, DataColumnSidecarList, DataColumnSubnetId, ExecutionBlockHash, Hash256, IndexedAttestation, - KzgProof, ProposerPreparationData, SignedBeaconBlock, Slot, Uint256, + KzgProof, ProposerPreparationData, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, + Uint256, }; // When set to true, cache any states fetched from the db. @@ -72,6 +73,7 @@ pub struct Checks { proposer_boost_root: Option, get_proposer_head: Option, should_override_forkchoice_update: Option, + head_payload_status: Option, } #[derive(Debug, Clone, Deserialize)] @@ -94,7 +96,15 @@ impl From for PayloadStatusV1 { #[derive(Debug, Clone, Deserialize)] #[serde(untagged, deny_unknown_fields)] -pub enum Step { +pub enum Step< + TBlock, + TBlobs, + TColumns, + TAttestation, + TAttesterSlashing, + TPowBlock, + TExecutionPayload = String, +> { Tick { tick: u64, }, @@ -128,6 +138,10 @@ pub enum Step, valid: bool, }, + OnExecutionPayload { + execution_payload: TExecutionPayload, + valid: bool, + }, } #[derive(Debug, Clone, Deserialize)] @@ -151,6 +165,7 @@ pub struct ForkChoiceTest { Attestation, AttesterSlashing, PowBlock, + SignedExecutionPayloadEnvelope, >, >, } @@ -271,6 +286,17 @@ impl LoadCase for ForkChoiceTest { valid, }) } + Step::OnExecutionPayload { + execution_payload, + valid, + } => { + let envelope = + ssz_decode_file(&path.join(format!("{execution_payload}.ssz_snappy")))?; + Ok(Step::OnExecutionPayload { + execution_payload: envelope, + valid, + }) + } }) .collect::>()?; let anchor_state = ssz_decode_state(&path.join("anchor_state.ssz_snappy"), spec)?; @@ -359,6 +385,7 @@ impl Case for ForkChoiceTest { proposer_boost_root, get_proposer_head, should_override_forkchoice_update: should_override_fcu, + head_payload_status, } = checks.as_ref(); if let Some(expected_head) = head { @@ -405,6 +432,10 @@ impl Case for ForkChoiceTest { if let Some(expected_proposer_head) = get_proposer_head { tester.check_expected_proposer_head(*expected_proposer_head)?; } + + if let Some(expected_status) = head_payload_status { + tester.check_head_payload_status(*expected_status)?; + } } Step::MaybeValidBlockAndColumns { @@ -414,6 +445,12 @@ impl Case for ForkChoiceTest { } => { tester.process_block_and_columns(block.clone(), columns.clone(), *valid)?; } + Step::OnExecutionPayload { + execution_payload, + valid, + } => { + tester.process_execution_payload(execution_payload, *valid)?; + } } } @@ -584,6 +621,18 @@ impl Tester { self.apply_invalid_block(&block)?; } + // Per spec test runner: an on_block step implies receiving block's attestations + // and attester slashings. + if success { + for attestation in block.message().body().attestations() { + let att = attestation.clone_as_attestation(); + let _ = self.process_attestation(&att); + } + for attester_slashing in block.message().body().attester_slashings() { + self.process_attester_slashing(attester_slashing); + } + } + Ok(()) } @@ -674,6 +723,18 @@ impl Tester { self.apply_invalid_block(&block)?; } + // Per spec test runner: an on_block step implies receiving block's attestations + // and attester slashings. + if success { + for attestation in block.message().body().attestations() { + let att = attestation.clone_as_attestation(); + let _ = self.process_attestation(&att); + } + for attester_slashing in block.message().body().attester_slashings() { + self.process_attester_slashing(attester_slashing); + } + } + Ok(()) } @@ -913,7 +974,7 @@ impl Tester { ) -> Result<(), Error> { let mut fc = self.harness.chain.canonical_head.fork_choice_write_lock(); let slot = self.harness.chain.slot().unwrap(); - let canonical_head = fc.get_head(slot, &self.harness.spec).unwrap(); + let (canonical_head, _) = fc.get_head(slot, &self.harness.spec).unwrap(); let proposer_head_result = fc.get_proposer_head( slot, canonical_head, @@ -923,7 +984,7 @@ impl Tester { DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, ); let proposer_head = match proposer_head_result { - Ok(head) => head.parent_node.root, + Ok(head) => head.parent_node.root(), Err(ProposerHeadError::DoNotReOrg(_)) => canonical_head, _ => panic!("Unexpected error in get proposer head"), }; @@ -931,6 +992,58 @@ impl Tester { check_equal("proposer_head", proposer_head, expected_proposer_head) } + pub fn process_execution_payload( + &self, + signed_envelope: &SignedExecutionPayloadEnvelope, + valid: bool, + ) -> Result<(), Error> { + let block_root = signed_envelope.message.beacon_block_root; + + // Store the envelope in the database so that child blocks extending + // the FULL path can load the parent's post-payload state. + if valid { + self.harness + .chain + .store + .put_payload_envelope(&block_root, signed_envelope.clone()) + .map_err(|e| { + Error::InternalError(format!( + "Failed to store payload envelope for {block_root:?}: {e:?}", + )) + })?; + } + + let result = self + .harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(block_root); + + if valid { + result.map_err(|e| { + Error::InternalError(format!( + "on_execution_payload for block root {} failed: {:?}", + block_root, e + )) + })?; + } else if result.is_ok() { + return Err(Error::DidntFail(format!( + "on_execution_payload for block root {} should have failed", + block_root + ))); + } + + Ok(()) + } + + pub fn check_head_payload_status(&self, expected_status: u8) -> Result<(), Error> { + let head = self.find_head()?; + // PayloadStatus repr: Empty=0, Full=1, Pending=2 (matches spec constants). + let actual = head.head_payload_status() as u8; + check_equal("head_payload_status", actual, expected_status) + } + pub fn check_should_override_fcu( &self, expected_should_override_fcu: ShouldOverrideFcu, diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index f8c16aec0b..4373d6b7d1 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -704,15 +704,27 @@ impl Handler for ForkChoiceHandler { return false; } - // No FCU override tests prior to bellatrix. + // No FCU override tests prior to bellatrix, and removed in Gloas. if self.handler_name == "should_override_forkchoice_update" - && !fork_name.bellatrix_enabled() + && (!fork_name.bellatrix_enabled() || fork_name.gloas_enabled()) { return false; } - // Deposit tests exist only after Electra. - if self.handler_name == "deposit_with_reorg" && !fork_name.electra_enabled() { + // Deposit tests exist only for Electra and Fulu (not Gloas). + if self.handler_name == "deposit_with_reorg" + && (!fork_name.electra_enabled() || fork_name.gloas_enabled()) + { + return false; + } + + // Proposer head tests removed in Gloas. + if self.handler_name == "get_proposer_head" && fork_name.gloas_enabled() { + return false; + } + + // on_execution_payload tests exist only for Gloas. + if self.handler_name == "on_execution_payload" && !fork_name.gloas_enabled() { return false; } @@ -722,8 +734,7 @@ impl Handler for ForkChoiceHandler { } fn disabled_forks(&self) -> Vec { - // TODO(gloas): remove once we have Gloas fork choice tests - vec![ForkName::Gloas] + vec![] } } diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 3254bb6e90..62eb2dd038 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -1038,6 +1038,12 @@ fn fork_choice_deposit_with_reorg() { // There is no mainnet variant for this test. } +#[test] +fn fork_choice_on_execution_payload() { + ForkChoiceHandler::::new("on_execution_payload").run(); + ForkChoiceHandler::::new("on_execution_payload").run(); +} + #[test] fn optimistic_sync() { OptimisticSyncHandler::::default().run(); From 27af0ed82c06019f10a24a6b117fd1a50b45a22a Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Sat, 4 Apr 2026 03:35:08 +1100 Subject: [PATCH 104/189] Add test for protocol registration completeness (#8920) Co-Authored-By: Jimmy Chen --- .../lighthouse_network/src/rpc/protocol.rs | 106 +++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/beacon_node/lighthouse_network/src/rpc/protocol.rs b/beacon_node/lighthouse_network/src/rpc/protocol.rs index 2c92e17c44..c949dfe17d 100644 --- a/beacon_node/lighthouse_network/src/rpc/protocol.rs +++ b/beacon_node/lighthouse_network/src/rpc/protocol.rs @@ -11,7 +11,7 @@ use std::io; use std::marker::PhantomData; use std::sync::{Arc, LazyLock}; use std::time::Duration; -use strum::{AsRefStr, Display, EnumString, IntoStaticStr}; +use strum::{AsRefStr, Display, EnumIter, EnumString, IntoStaticStr}; use tokio_util::{ codec::Framed, compat::{Compat, FuturesAsyncReadCompatExt}, @@ -329,7 +329,7 @@ pub enum Encoding { } /// All valid protocol name and version combinations. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumIter)] pub enum SupportedProtocol { StatusV1, StatusV2, @@ -499,6 +499,10 @@ impl UpgradeInfo for RPCProtocol { SupportedProtocol::LightClientFinalityUpdateV1, Encoding::SSZSnappy, )); + supported_protocols.push(ProtocolId::new( + SupportedProtocol::LightClientUpdatesByRangeV1, + Encoding::SSZSnappy, + )); } supported_protocols } @@ -1133,3 +1137,101 @@ impl RPCError { } } } + +#[cfg(test)] +mod tests { + use super::*; + use libp2p::core::UpgradeInfo; + use std::collections::HashSet; + use strum::IntoEnumIterator; + use types::{Hash256, Slot}; + + type E = MainnetEthSpec; + + /// Whether this protocol should appear in `currently_supported()` for the given context. + /// + /// Uses an exhaustive match so that adding a new `SupportedProtocol` variant + /// causes a compile error until this function is updated. + fn expected_in_currently_supported( + protocol: SupportedProtocol, + fork_context: &ForkContext, + ) -> bool { + use SupportedProtocol::*; + match protocol { + StatusV1 | StatusV2 | GoodbyeV1 | PingV1 | BlocksByRangeV1 | BlocksByRangeV2 + | BlocksByRootV1 | BlocksByRootV2 | MetaDataV1 | MetaDataV2 => true, + + BlobsByRangeV1 | BlobsByRootV1 => fork_context.fork_exists(ForkName::Deneb), + + DataColumnsByRootV1 | DataColumnsByRangeV1 | MetaDataV3 => { + fork_context.spec.is_peer_das_scheduled() + } + + PayloadEnvelopesByRangeV1 | PayloadEnvelopesByRootV1 => { + fork_context.fork_exists(ForkName::Gloas) + } + + // Light client protocols are not in currently_supported() + LightClientBootstrapV1 + | LightClientOptimisticUpdateV1 + | LightClientFinalityUpdateV1 + | LightClientUpdatesByRangeV1 => false, + } + } + + /// Whether this protocol should appear in `protocol_info()` when light client server is + /// enabled. + /// + /// Uses an exhaustive match so that adding a new `SupportedProtocol` variant + /// causes a compile error until this function is updated. + fn expected_in_protocol_info(protocol: SupportedProtocol, fork_context: &ForkContext) -> bool { + use SupportedProtocol::*; + match protocol { + LightClientBootstrapV1 + | LightClientOptimisticUpdateV1 + | LightClientFinalityUpdateV1 + | LightClientUpdatesByRangeV1 => true, + + _ => expected_in_currently_supported(protocol, fork_context), + } + } + + #[test] + fn all_protocols_registered() { + for fork in ForkName::list_all() { + let spec = fork.make_genesis_spec(E::default_spec()); + let fork_context = Arc::new(ForkContext::new::(Slot::new(0), Hash256::ZERO, &spec)); + + let currently_supported: HashSet = + SupportedProtocol::currently_supported(&fork_context) + .into_iter() + .map(|pid| pid.versioned_protocol) + .collect(); + + let rpc_protocol = RPCProtocol:: { + fork_context: fork_context.clone(), + max_rpc_size: spec.max_payload_size as usize, + enable_light_client_server: true, + phantom: PhantomData, + }; + let protocol_info: HashSet = rpc_protocol + .protocol_info() + .into_iter() + .map(|pid| pid.versioned_protocol) + .collect(); + + for protocol in SupportedProtocol::iter() { + assert_eq!( + currently_supported.contains(&protocol), + expected_in_currently_supported(protocol, &fork_context), + "{protocol:?} registration mismatch in currently_supported() at {fork:?}" + ); + assert_eq!( + protocol_info.contains(&protocol), + expected_in_protocol_info(protocol, &fork_context), + "{protocol:?} registration mismatch in protocol_info() at {fork:?}" + ); + } + } + } +} From 7559dd28090d47c565dbd1fbbe6e20077d27b6b9 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:36:26 -0500 Subject: [PATCH 105/189] Use spec constants for PTC thresholds in fork choice (#9088) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- consensus/proto_array/src/proto_array.rs | 6 +++--- consensus/types/src/core/eth_spec.rs | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index dfb43f5f34..1f7291b260 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -211,7 +211,7 @@ impl ProtoNode { return false; } - node.payload_timeliness_votes.num_set_bits() > E::ptc_size() / 2 + node.payload_timeliness_votes.num_set_bits() > E::payload_timely_threshold() } pub fn is_payload_data_available(&self) -> bool { @@ -224,8 +224,8 @@ impl ProtoNode { return false; } - // TODO(gloas): add function on EthSpec for DATA_AVAILABILITY_TIMELY_THRESHOLD - node.payload_data_availability_votes.num_set_bits() > E::ptc_size() / 2 + node.payload_data_availability_votes.num_set_bits() + > E::data_availability_timely_threshold() } } diff --git a/consensus/types/src/core/eth_spec.rs b/consensus/types/src/core/eth_spec.rs index 36d61fbbf9..4159091f5d 100644 --- a/consensus/types/src/core/eth_spec.rs +++ b/consensus/types/src/core/eth_spec.rs @@ -448,6 +448,11 @@ pub trait EthSpec: 'static + Default + Sync + Send + Clone + Debug + PartialEq + fn payload_timely_threshold() -> usize { Self::PTCSize::to_usize() / 2 } + + /// Returns the `DATA_AVAILABILITY_TIMELY_THRESHOLD` constant (PTC_SIZE / 2). + fn data_availability_timely_threshold() -> usize { + Self::PTCSize::to_usize() / 2 + } } /// Macro to inherit some type values from another EthSpec. From 9f0696f93fce05c4b411ee43663d1633e4600ecf Mon Sep 17 00:00:00 2001 From: Mac L Date: Mon, 6 Apr 2026 06:54:41 +0400 Subject: [PATCH 106/189] Remove unused `exit-future` (#9095) Remove the `exit-future` crate as it is unused. Co-Authored-By: Mac L --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 96d57e0210..db6853d44d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -145,7 +145,6 @@ ethereum_serde_utils = "0.8.0" ethereum_ssz = { version = "0.10.0", features = ["context_deserialize"] } ethereum_ssz_derive = "0.10.0" execution_layer = { path = "beacon_node/execution_layer" } -exit-future = "0.2" filesystem = { path = "common/filesystem" } fixed_bytes = { path = "consensus/fixed_bytes" } fnv = "1" From 243eecc46528fccecfc7e7d762d674502df04ed9 Mon Sep 17 00:00:00 2001 From: Mac L Date: Tue, 7 Apr 2026 10:23:11 +0400 Subject: [PATCH 107/189] Add `cargo-hack` to CI to check crate features (#8927) #8926 Add a step to CI which runs `cargo check` across all combinations of features for certain crates using `cargo-hack` Co-Authored-By: Mac L --- .github/workflows/test-suite.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index d9efbfc148..c2ce6f89be 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -427,6 +427,22 @@ jobs: cache-target: release - name: Run Makefile to trigger the bash script run: make cli-local + cargo-hack: + name: cargo-hack + needs: [check-labels] + if: needs.check-labels.outputs.skip_ci != 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Get latest version of stable Rust + uses: moonrepo/setup-rust@v1 + with: + channel: stable + - uses: taiki-e/install-action@cargo-hack + - name: Check types feature powerset + run: cargo hack check -p types --feature-powerset --no-dev-deps --exclude-features arbitrary-fuzz,portable + - name: Check eth2 feature powerset + run: cargo hack check -p eth2 --feature-powerset --no-dev-deps cargo-sort: name: cargo-sort needs: [check-labels] @@ -470,6 +486,7 @@ jobs: 'compile-with-beta-compiler', 'cli-check', 'lockbud', + 'cargo-hack', 'cargo-sort', ] steps: From 2749e18d0e35e6f148642623327acac5a7066658 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Thu, 9 Apr 2026 03:44:19 +0900 Subject: [PATCH 108/189] Gloas serve post block state for finalized/justified state requests (#9092) Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Pawan Dhananjay --- beacon_node/http_api/src/block_id.rs | 10 +++--- beacon_node/http_api/src/state_id.rs | 51 ++++++++++++++++++++++------ common/eth2/src/types.rs | 8 +++++ 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/beacon_node/http_api/src/block_id.rs b/beacon_node/http_api/src/block_id.rs index e6b1ed0879..f4645f1304 100644 --- a/beacon_node/http_api/src/block_id.rs +++ b/beacon_node/http_api/src/block_id.rs @@ -1,5 +1,5 @@ use crate::version::inconsistent_fork_rejection; -use crate::{ExecutionOptimistic, state_id::checkpoint_slot_and_execution_optimistic}; +use crate::{ExecutionOptimistic, state_id::checkpoint_block_and_execution_optimistic}; use beacon_chain::kzg_utils::reconstruct_blobs; use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, WhenSlotSkipped}; use eth2::beacon_response::{ExecutionOptimisticFinalizedMetadata, UnversionedResponse}; @@ -60,15 +60,15 @@ impl BlockId { CoreBlockId::Finalized => { let finalized_checkpoint = chain.canonical_head.cached_head().finalized_checkpoint(); - let (_slot, execution_optimistic) = - checkpoint_slot_and_execution_optimistic(chain, finalized_checkpoint)?; + let (_block, execution_optimistic) = + checkpoint_block_and_execution_optimistic(chain, finalized_checkpoint)?; Ok((finalized_checkpoint.root, execution_optimistic, true)) } CoreBlockId::Justified => { let justified_checkpoint = chain.canonical_head.cached_head().justified_checkpoint(); - let (_slot, execution_optimistic) = - checkpoint_slot_and_execution_optimistic(chain, justified_checkpoint)?; + let (_block, execution_optimistic) = + checkpoint_block_and_execution_optimistic(chain, justified_checkpoint)?; Ok((justified_checkpoint.root, execution_optimistic, false)) } CoreBlockId::Slot(slot) => { diff --git a/beacon_node/http_api/src/state_id.rs b/beacon_node/http_api/src/state_id.rs index 13fb9b2c58..ce18388926 100644 --- a/beacon_node/http_api/src/state_id.rs +++ b/beacon_node/http_api/src/state_id.rs @@ -2,6 +2,7 @@ use crate::ExecutionOptimistic; use crate::metrics; use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes}; use eth2::types::StateId as CoreStateId; +use proto_array::Block; use std::fmt; use std::str::FromStr; use types::{BeaconState, Checkpoint, EthSpec, Fork, Hash256, Slot}; @@ -19,6 +20,8 @@ impl StateId { Self(CoreStateId::Slot(slot)) } + // TODO(gloas) add tests for finalized and justified checkpoint states to ensure + // we return the post block state for gloas /// Return the state root identified by `self`. pub fn root( &self, @@ -41,15 +44,41 @@ impl StateId { CoreStateId::Finalized => { let finalized_checkpoint = chain.canonical_head.cached_head().finalized_checkpoint(); - let (slot, execution_optimistic) = - checkpoint_slot_and_execution_optimistic(chain, finalized_checkpoint)?; + + let slot = finalized_checkpoint + .epoch + .start_slot(T::EthSpec::slots_per_epoch()); + let (block, execution_optimistic) = + checkpoint_block_and_execution_optimistic(chain, finalized_checkpoint)?; + + if chain + .spec + .fork_name_at_slot::(block.slot) + .gloas_enabled() + { + return Ok((block.state_root, execution_optimistic, true)); + } + (slot, execution_optimistic, true) } CoreStateId::Justified => { let justified_checkpoint = chain.canonical_head.cached_head().justified_checkpoint(); - let (slot, execution_optimistic) = - checkpoint_slot_and_execution_optimistic(chain, justified_checkpoint)?; + + let slot = justified_checkpoint + .epoch + .start_slot(T::EthSpec::slots_per_epoch()); + let (block, execution_optimistic) = + checkpoint_block_and_execution_optimistic(chain, justified_checkpoint)?; + + if chain + .spec + .fork_name_at_slot::(block.slot) + .gloas_enabled() + { + return Ok((block.state_root, execution_optimistic, false)); + } + (slot, execution_optimistic, false) } CoreStateId::Slot(slot) => ( @@ -254,13 +283,11 @@ impl fmt::Display for StateId { } } -/// Returns the first slot of the checkpoint's `epoch` and the execution status of the checkpoint's -/// `root`. -pub fn checkpoint_slot_and_execution_optimistic( +/// Returns checkpoint block and the execution status of the checkpoint's `root`. +pub fn checkpoint_block_and_execution_optimistic( chain: &BeaconChain, checkpoint: Checkpoint, -) -> Result<(Slot, ExecutionOptimistic), warp::reject::Rejection> { - let slot = checkpoint.epoch.start_slot(T::EthSpec::slots_per_epoch()); +) -> Result<(Block, ExecutionOptimistic), warp::reject::Rejection> { let fork_choice = chain.canonical_head.fork_choice_read_lock(); let finalized_checkpoint = fork_choice.cached_fork_choice_view().finalized_checkpoint; @@ -277,5 +304,9 @@ pub fn checkpoint_slot_and_execution_optimistic( .map_err(BeaconChainError::ForkChoiceError) .map_err(warp_utils::reject::unhandled_error)?; - Ok((slot, execution_optimistic)) + let block = fork_choice.get_block(&checkpoint.root).ok_or_else(|| { + warp_utils::reject::custom_not_found(format!("Block {:?} not found", checkpoint.root)) + })?; + + Ok((block, execution_optimistic)) } diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 54e9c98b5b..e85565c580 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -125,7 +125,15 @@ impl fmt::Display for BlockId { pub enum StateId { Head, Genesis, + /// Pre-gloas the finalized state is the checkpoint block state + /// advanced to the epoch boundary. + /// Post-gloas this state is always the checkpoint post-block state and is not advanced + /// to the epoch boundary. Finalized, + /// Pre-gloas the justified state is the checkpoint block state + /// advanced to the epoch boundary. + /// Post-gloas this state is always the checkpoint post-block state and is not advanced + /// to the epoch boundary. Justified, Slot(Slot), Root(Hash256), From 815aad37315ff513ff0787db6881ab9c520f9b06 Mon Sep 17 00:00:00 2001 From: Mike Jerred Date: Thu, 9 Apr 2026 06:36:45 +0100 Subject: [PATCH 109/189] Allow --validator-dir to be specified after subcommands (#8329) #3768 Made the --validator-dir flag global so that it can be specified in any order Co-Authored-By: Mike Jerred Co-Authored-By: chonghe <44791194+chong-he@users.noreply.github.com> --- account_manager/src/validator/mod.rs | 1 + lighthouse/tests/account_manager.rs | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/account_manager/src/validator/mod.rs b/account_manager/src/validator/mod.rs index 5a6c9439a6..2a92ad2d37 100644 --- a/account_manager/src/validator/mod.rs +++ b/account_manager/src/validator/mod.rs @@ -28,6 +28,7 @@ pub fn cli_app() -> Command { "The path to search for validator directories. \ Defaults to ~/.lighthouse/{network}/validators", ) + .global(true) .action(ArgAction::Set) .conflicts_with("datadir"), ) diff --git a/lighthouse/tests/account_manager.rs b/lighthouse/tests/account_manager.rs index 9bfcae85e5..76839dea39 100644 --- a/lighthouse/tests/account_manager.rs +++ b/lighthouse/tests/account_manager.rs @@ -248,9 +248,9 @@ impl TestValidator { store_withdrawal_key: bool, ) -> Result, String> { let mut cmd = validator_cmd(); - cmd.arg(format!("--{}", VALIDATOR_DIR_FLAG)) + cmd.arg(CREATE_CMD) + .arg(format!("--{}", VALIDATOR_DIR_FLAG)) .arg(self.validator_dir.clone().into_os_string()) - .arg(CREATE_CMD) .arg(format!("--{}", WALLETS_DIR_FLAG)) .arg(self.wallet.base_dir().into_os_string()) .arg(format!("--{}", WALLET_NAME_FLAG)) @@ -427,9 +427,9 @@ fn validator_import_launchpad() { File::create(src_dir.path().join(NOT_KEYSTORE_NAME)).unwrap(); let mut child = validator_cmd() + .arg(IMPORT_CMD) .arg(format!("--{}", VALIDATOR_DIR_FLAG)) .arg(dst_dir.path().as_os_str()) - .arg(IMPORT_CMD) .arg(format!("--{}", STDIN_INPUTS_FLAG)) // Using tty does not work well with tests. .arg(format!("--{}", import::DIR_FLAG)) .arg(src_dir.path().as_os_str()) @@ -479,10 +479,10 @@ fn validator_import_launchpad() { // Disable all the validators in validator_definition. output_result( validator_cmd() - .arg(format!("--{}", VALIDATOR_DIR_FLAG)) - .arg(dst_dir.path().as_os_str()) .arg(MODIFY_CMD) .arg(DISABLE) + .arg(format!("--{}", VALIDATOR_DIR_FLAG)) + .arg(dst_dir.path().as_os_str()) .arg(format!("--{}", ALL)), ) .unwrap(); @@ -514,10 +514,10 @@ fn validator_import_launchpad() { // Enable keystore validator again output_result( validator_cmd() - .arg(format!("--{}", VALIDATOR_DIR_FLAG)) - .arg(dst_dir.path().as_os_str()) .arg(MODIFY_CMD) .arg(ENABLE) + .arg(format!("--{}", VALIDATOR_DIR_FLAG)) + .arg(dst_dir.path().as_os_str()) .arg(format!("--{}", PUBKEY_FLAG)) .arg(format!("{}", keystore.public_key().unwrap())), ) @@ -560,9 +560,9 @@ fn validator_import_launchpad_no_password_then_add_password() { let validator_import_key_cmd = || { validator_cmd() + .arg(IMPORT_CMD) .arg(format!("--{}", VALIDATOR_DIR_FLAG)) .arg(dst_dir.path().as_os_str()) - .arg(IMPORT_CMD) .arg(format!("--{}", STDIN_INPUTS_FLAG)) // Using tty does not work well with tests. .arg(format!("--{}", import::DIR_FLAG)) .arg(src_dir.path().as_os_str()) @@ -700,9 +700,9 @@ fn validator_import_launchpad_password_file() { .unwrap(); let mut child = validator_cmd() + .arg(IMPORT_CMD) .arg(format!("--{}", VALIDATOR_DIR_FLAG)) .arg(dst_dir.path().as_os_str()) - .arg(IMPORT_CMD) .arg(format!("--{}", import::DIR_FLAG)) .arg(src_dir.path().as_os_str()) .arg(format!("--{}", import::REUSE_PASSWORD_FLAG)) From 8681e8e06ee9d1d26d655e588111ed480d1e656c Mon Sep 17 00:00:00 2001 From: Mark Liu Date: Thu, 9 Apr 2026 15:36:49 +1000 Subject: [PATCH 110/189] Reduce slow test runtimes to under 60s (#9012) Co-Authored-By: Mark Liu Co-Authored-By: Michael Sproul --- .../src/peer_manager/mod.rs | 3 ++ .../lighthouse_network/src/rpc/codec.rs | 4 ++- .../lighthouse_network/tests/rpc_tests.rs | 4 ++- .../initialized_validators/src/key_cache.rs | 30 +++++++++++++++---- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/beacon_node/lighthouse_network/src/peer_manager/mod.rs b/beacon_node/lighthouse_network/src/peer_manager/mod.rs index 2edd9de2d9..d7285c5c8e 100644 --- a/beacon_node/lighthouse_network/src/peer_manager/mod.rs +++ b/beacon_node/lighthouse_network/src/peer_manager/mod.rs @@ -3087,6 +3087,9 @@ mod tests { const MAX_TEST_PEERS: usize = 300; proptest! { + // 64 cases (down from default 256) keeps this test under 10s while + // still providing good random coverage of the pruning logic. + #![proptest_config(ProptestConfig::with_cases(64))] #[test] fn prune_excess_peers(peer_conditions in proptest::collection::vec(peer_condition_strategy(), DEFAULT_TARGET_PEERS..=MAX_TEST_PEERS)) { let target_peer_count = DEFAULT_TARGET_PEERS; diff --git a/beacon_node/lighthouse_network/src/rpc/codec.rs b/beacon_node/lighthouse_network/src/rpc/codec.rs index 346e350825..75e035ae82 100644 --- a/beacon_node/lighthouse_network/src/rpc/codec.rs +++ b/beacon_node/lighthouse_network/src/rpc/codec.rs @@ -1088,9 +1088,11 @@ mod tests { let mut block: BeaconBlockBellatrix<_, FullPayload> = BeaconBlockBellatrix::empty(spec); + // 11,000 × 1KB ≈ 11MB, just above the 10MB max_payload_size. + // Previously used 100,000 txs (~100MB) which made this test take >60s. let tx = VariableList::try_from(vec![0; 1024]).unwrap(); let txs = - VariableList::try_from(std::iter::repeat_n(tx, 100000).collect::>()).unwrap(); + VariableList::try_from(std::iter::repeat_n(tx, 11000).collect::>()).unwrap(); block.body.execution_payload.execution_payload.transactions = txs; diff --git a/beacon_node/lighthouse_network/tests/rpc_tests.rs b/beacon_node/lighthouse_network/tests/rpc_tests.rs index debe30b34f..d3f47c88bd 100644 --- a/beacon_node/lighthouse_network/tests/rpc_tests.rs +++ b/beacon_node/lighthouse_network/tests/rpc_tests.rs @@ -46,8 +46,10 @@ fn bellatrix_block_small(spec: &ChainSpec) -> BeaconBlock { /// Hence, we generate a bellatrix block just greater than `MAX_RPC_SIZE` to test rejection on the rpc layer. fn bellatrix_block_large(spec: &ChainSpec) -> BeaconBlock { let mut block = BeaconBlockBellatrix::::empty(spec); + // 11,000 × 1KB ≈ 11MB, just above the 10MB max_payload_size. + // Previously used 100,000 txs (~100MB) which caused hangs and timeouts. let tx = VariableList::try_from(vec![0; 1024]).unwrap(); - let txs = VariableList::try_from(std::iter::repeat_n(tx, 100000).collect::>()).unwrap(); + let txs = VariableList::try_from(std::iter::repeat_n(tx, 11000).collect::>()).unwrap(); block.body.execution_payload.execution_payload.transactions = txs; diff --git a/validator_client/initialized_validators/src/key_cache.rs b/validator_client/initialized_validators/src/key_cache.rs index b600013c8b..c2f60acc27 100644 --- a/validator_client/initialized_validators/src/key_cache.rs +++ b/validator_client/initialized_validators/src/key_cache.rs @@ -1,7 +1,7 @@ use account_utils::write_file_via_temporary; use bls::{Keypair, PublicKey}; use eth2_keystore::json_keystore::{ - Aes128Ctr, ChecksumModule, Cipher, CipherModule, Crypto, EmptyMap, EmptyString, KdfModule, + Aes128Ctr, ChecksumModule, Cipher, CipherModule, Crypto, EmptyMap, EmptyString, Kdf, KdfModule, Sha256Checksum, }; use eth2_keystore::{ @@ -65,10 +65,14 @@ impl KeyCache { } pub fn init_crypto() -> Crypto { + Self::build_crypto(default_kdf) + } + + fn build_crypto(kdf_fn: fn(Vec) -> Kdf) -> Crypto { let salt = rand::rng().random::<[u8; SALT_SIZE]>(); let iv = rand::rng().random::<[u8; IV_SIZE]>().to_vec().into(); - let kdf = default_kdf(salt.to_vec()); + let kdf = kdf_fn(salt.to_vec()); let cipher = Cipher::Aes128Ctr(Aes128Ctr { iv }); Crypto { @@ -116,7 +120,11 @@ impl KeyCache { } fn encrypt(&mut self) -> Result<(), Error> { - self.crypto = Self::init_crypto(); + self.encrypt_with(default_kdf) + } + + fn encrypt_with(&mut self, kdf_fn: fn(Vec) -> Kdf) -> Result<(), Error> { + self.crypto = Self::build_crypto(kdf_fn); let secret_map: SerializedKeyMap = self .pairs .iter() @@ -268,7 +276,19 @@ pub enum Error { #[cfg(test)] mod tests { use super::*; - use eth2_keystore::json_keystore::HexBytes; + use eth2_keystore::json_keystore::{HexBytes, Scrypt}; + + /// Scrypt with minimal cost (n=1024) for fast test execution. + /// Production uses n=262144 which takes ~45s per derivation. + fn insecure_kdf(salt: Vec) -> Kdf { + Kdf::Scrypt(Scrypt { + dklen: 32, + n: 1024, + p: 1, + r: 8, + salt: salt.into(), + }) + } #[tokio::test] async fn test_serialization() { @@ -302,7 +322,7 @@ mod tests { key_cache.add(keypair.clone(), uuid, password.clone()); } - key_cache.encrypt().unwrap(); + key_cache.encrypt_with(insecure_kdf).unwrap(); key_cache.state = State::DecryptedAndSaved; assert_eq!(&key_cache.uuids, &uuids); From 4b297c6ce85321b93ca3157348c9c3bcca216426 Mon Sep 17 00:00:00 2001 From: Roheemah <60899500+AbolareRoheemah@users.noreply.github.com> Date: Thu, 9 Apr 2026 06:43:50 +0100 Subject: [PATCH 111/189] added check for fee recipient per validator and added unit tests (#8454) Addresses #5403 - Added `check_fee_recipient()` method to validate individual validators - Added `check_all_fee_recipients()` to validate all validators on startup - Validator client now fails to start if any enabled validator lacks a fee recipient and no global flag is used. - Added Clear error messages to guide users on how to fix the issue - Added unit tests Co-Authored-By: AbolareRoheemah --- .../src/validator_definitions.rs | 290 +++++++++++++++++- validator_client/src/lib.rs | 3 + 2 files changed, 292 insertions(+), 1 deletion(-) diff --git a/common/account_utils/src/validator_definitions.rs b/common/account_utils/src/validator_definitions.rs index 0fc5bf5665..fe6481350c 100644 --- a/common/account_utils/src/validator_definitions.rs +++ b/common/account_utils/src/validator_definitions.rs @@ -12,7 +12,7 @@ use std::collections::HashSet; use std::fs::{self, File, create_dir_all}; use std::io; use std::path::{Path, PathBuf}; -use tracing::error; +use tracing::{debug, error}; use types::{Address, graffiti::GraffitiString}; use validator_dir::VOTING_KEYSTORE_FILE; use zeroize::Zeroizing; @@ -212,6 +212,16 @@ impl ValidatorDefinition { }, }) } + + pub fn check_fee_recipient(&self, global_fee_recipient: Option
) -> Option<&PublicKey> { + // Skip disabled validators. Also skip if validator has its own fee set, or the global flag is set + if !self.enabled || self.suggested_fee_recipient.is_some() || global_fee_recipient.is_some() + { + return None; + } + + Some(&self.voting_public_key) + } } /// A list of `ValidatorDefinition` that serves as a serde-able configuration file which defines a @@ -410,6 +420,52 @@ impl ValidatorDefinitions { .iter() .filter_map(|def| def.signing_definition.voting_keystore_password_path()) } + + /// Called after loading to run safety checks on all validators + pub fn check_all_fee_recipients( + &self, + global_fee_recipient: Option
, + ) -> Result<(), String> { + let missing: Vec<&PublicKey> = self + .0 + .iter() + .filter_map(|def| def.check_fee_recipient(global_fee_recipient)) + .collect(); + + if !missing.is_empty() { + let pubkeys = missing + .iter() + .map(|pk| pk.to_string()) + .collect::>() + .join(", "); + + return Err(format!( + "The following validators are missing a `suggested_fee_recipient`: {}. \ + Fix this by adding a `suggested_fee_recipient` in the \ + `validator_definitions.yml` or by supplying a fallback fee \ + recipient via the `--suggested-fee-recipient` flag.", + pubkeys + )); + } + + // Friendly reminder for users using the fallback flag + if global_fee_recipient.is_some() { + let count = self + .0 + .iter() + .filter(|d| d.enabled && d.suggested_fee_recipient.is_none()) + .count(); + if count > 0 { + debug!( + "The fallback --suggested-fee-recipient is being used for {} validator(s). \ + You may alternatively set the fee recipient for each validator individually via `validator_definitions.yml`.", + count + ); + } + } + + Ok(()) + } } /// Perform an exhaustive tree search of `dir`, adding any discovered voting keystore paths to @@ -485,6 +541,7 @@ pub fn is_voting_keystore(file_name: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use bls::Keypair; use std::str::FromStr; #[test] @@ -682,4 +739,235 @@ mod tests { let def: ValidatorDefinition = yaml_serde::from_str(valid_builder_proposals).unwrap(); assert_eq!(def.builder_proposals, Some(true)); } + + #[test] + fn fee_recipient_check_enabled_validator_cases() { + let def = ValidatorDefinition { + enabled: true, + voting_public_key: PublicKey::from_str( + "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" + ).unwrap(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + } + }; + + // Should return Some(pubkey) when no fee recipient is set + let check_result = def.check_fee_recipient(None); + assert!(check_result.is_some()); + + // Should return None since global fee recipient is set + let global_fee_recipient = + Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()); + let check_result = def.check_fee_recipient(global_fee_recipient); + assert!(check_result.is_none()); + } + + #[test] + fn fee_recipient_check_passes_with_validator_specific() { + let def = ValidatorDefinition { + enabled: true, + voting_public_key: PublicKey::from_str( + "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" + ).unwrap(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()), + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + // Should return None because suggested_fee_recipient is set + let check_result = def.check_fee_recipient(None); + assert!(check_result.is_none()); + } + + #[test] + fn fee_recipient_check_skips_disabled_validators() { + let def = ValidatorDefinition { + enabled: false, + voting_public_key: PublicKey::from_str( + "0xaf3c7ddab7e293834710fca2d39d068f884455ede270e0d0293dc818e4f2f0f975355067e8437955cb29aec674e5c9e7" + ).unwrap(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + // Should return None because validator is disabled + let check_result = def.check_fee_recipient(None); + assert!(check_result.is_none()); + } + + #[test] + fn check_all_fee_recipients_reports_all_missing() { + let keypair1 = Keypair::random(); + let keypair2 = Keypair::random(); + + let def1 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair1.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let def2 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair2.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, // Missing recipient + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let defs = ValidatorDefinitions::from(vec![def1, def2]); + + // Should fail because both defs have no fee recipient and no global fee recipient is set + let result = defs.check_all_fee_recipients(None); + assert!(result.is_err()); + let err = result.unwrap_err(); + + // Check that both public keys are mentioned in the error message + let pk1_string = keypair1.pk.to_string(); + let pk2_string = keypair2.pk.to_string(); + + assert!(err.contains(&pk1_string), "Error message missing pubkey 1"); + assert!(err.contains(&pk2_string), "Error message missing pubkey 2"); + assert!(err.contains("are missing a `suggested_fee_recipient`")); + } + + #[test] + fn check_all_fee_recipients_passes_all_configured() { + let keypair = Keypair::random(); + let def1 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: Some( + Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap(), + ), + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let def2 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: Some( + Address::from_str("0xb2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap(), + ), + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let defs = ValidatorDefinitions::from(vec![def1, def2]); + + // Should pass - all validators have fee recipients + assert!(defs.check_all_fee_recipients(None).is_ok()); + } + + #[test] + fn check_all_fee_recipients_passes_with_global() { + let keypair = Keypair::random(); + let def1 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let def2 = ValidatorDefinition { + enabled: true, + voting_public_key: keypair.pk.clone(), + description: String::new(), + graffiti: None, + suggested_fee_recipient: None, + gas_limit: None, + builder_proposals: None, + builder_boost_factor: None, + prefer_builder_proposals: None, + signing_definition: SigningDefinition::LocalKeystore { + voting_keystore_path: PathBuf::new(), + voting_keystore_password_path: None, + voting_keystore_password: None, + }, + }; + + let defs = ValidatorDefinitions::from(vec![def1, def2]); + + // Should pass - global fee recipient is set + let global_fee_recipient = + Some(Address::from_str("0xa2e334e71511686bcfe38bb3ee1ad8f6babcc03d").unwrap()); + assert!(defs.check_all_fee_recipients(global_fee_recipient).is_ok()); + } } diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index f70d5830ec..e26d5c3d30 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -187,6 +187,9 @@ impl ProductionValidatorClient { info!(new_validators, "Completed validator discovery"); } + // Check for all validators' fee recipient + validator_defs.check_all_fee_recipients(config.validator_store.fee_recipient)?; + let validators = InitializedValidators::from_definitions( validator_defs, config.validator_dir.clone(), From b95f99f130ace6ba105819174f74a8714ae51f2c Mon Sep 17 00:00:00 2001 From: CATS Date: Thu, 9 Apr 2026 07:54:10 +0200 Subject: [PATCH 112/189] feat(execution_layer): log more detail when JWT auth fails (#9051) Co-Authored-By: CATS Co-Authored-By: chonghe <44791194+chong-he@users.noreply.github.com> --- beacon_node/execution_layer/src/engine_api.rs | 2 +- beacon_node/execution_layer/src/engine_api/auth.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index 774eac5fe2..9c19e94c0e 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -79,7 +79,7 @@ impl From for Error { e.status(), Some(StatusCode::UNAUTHORIZED) | Some(StatusCode::FORBIDDEN) ) { - Error::Auth(auth::Error::InvalidToken) + Error::Auth(auth::Error::InvalidToken(e.to_string())) } else { Error::HttpClient(e.into()) } diff --git a/beacon_node/execution_layer/src/engine_api/auth.rs b/beacon_node/execution_layer/src/engine_api/auth.rs index af1ca195bd..3a27048b1a 100644 --- a/beacon_node/execution_layer/src/engine_api/auth.rs +++ b/beacon_node/execution_layer/src/engine_api/auth.rs @@ -14,7 +14,7 @@ pub const JWT_SECRET_LENGTH: usize = 32; #[derive(Debug)] pub enum Error { JWT(jsonwebtoken::errors::Error), - InvalidToken, + InvalidToken(String), InvalidKey(String), } From fb5a0434d7d3b485007fa5618b19de9a0f45e430 Mon Sep 17 00:00:00 2001 From: cui Date: Thu, 9 Apr 2026 13:54:14 +0800 Subject: [PATCH 113/189] Fix graffiti calculator test mock commit fallback (#9087) Co-Authored-By: Weixie Cui --- beacon_node/beacon_chain/src/graffiti_calculator.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beacon_node/beacon_chain/src/graffiti_calculator.rs b/beacon_node/beacon_chain/src/graffiti_calculator.rs index 85470715c9..403873cc00 100644 --- a/beacon_node/beacon_chain/src/graffiti_calculator.rs +++ b/beacon_node/beacon_chain/src/graffiti_calculator.rs @@ -446,7 +446,7 @@ mod tests { DEFAULT_CLIENT_VERSION.code, mock_commit .strip_prefix("0x") - .unwrap_or("&mock_commit") + .unwrap_or(&mock_commit) .get(0..4) .expect("should get first 2 bytes in hex"), "LH", @@ -459,7 +459,7 @@ mod tests { DEFAULT_CLIENT_VERSION.code, mock_commit .strip_prefix("0x") - .unwrap_or("&mock_commit") + .unwrap_or(&mock_commit) .get(0..2) .expect("should get first 2 bytes in hex"), "LH", From 7c2dcfc0d66e983f979bb4bd2ea6ac982ad22173 Mon Sep 17 00:00:00 2001 From: Mac L Date: Thu, 9 Apr 2026 12:41:02 +0400 Subject: [PATCH 114/189] Refactor `timestamp_now` (#9094) #9077 Where possible replaces all instances of `validator_monitor::timestamp_now` with `chain.slot_clock.now_duration().unwrap_or_default()`. Where chain/slot_clock is not available, instead replace it with a convenience function `slot_clock::timestamp_now`. Remove the `validator_monitor::timestamp_now` function. Co-Authored-By: Mac L --- .../src/data_column_verification.rs | 6 +-- .../beacon_chain/src/fetch_blobs/mod.rs | 2 +- .../beacon_chain/src/validator_monitor.rs | 9 +--- .../http_api/src/publish_attestations.rs | 8 ++-- beacon_node/http_api/src/publish_blocks.rs | 10 +++-- beacon_node/http_api/src/sync_committees.rs | 9 ++-- beacon_node/http_api/src/validator/mod.rs | 3 +- beacon_node/network/src/router.rs | 41 ++++++++----------- beacon_node/network/src/sync/manager.rs | 11 +++-- .../src/sync/network_context/custody.rs | 7 +++- .../src/sync/network_context/requests.rs | 2 +- common/slot_clock/src/lib.rs | 12 +++++- 12 files changed, 60 insertions(+), 60 deletions(-) diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index f47de01ddc..f2cec0980f 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -5,13 +5,12 @@ use crate::kzg_utils::{reconstruct_data_columns, validate_data_columns}; use crate::observed_data_sidecars::{ Error as ObservedDataSidecarsError, ObservationKey, ObservationStrategy, Observe, }; -use crate::validator_monitor::timestamp_now; use crate::{BeaconChain, BeaconChainError, BeaconChainTypes, metrics}; use educe::Educe; use fork_choice::ProtoBlock; use kzg::{Error as KzgError, Kzg}; use proto_array::Block; -use slot_clock::SlotClock; +use slot_clock::{SlotClock, timestamp_now}; use ssz_derive::Encode; use ssz_types::VariableList; use std::iter; @@ -570,8 +569,9 @@ pub fn validate_data_column_sidecar_for_gossip_fulu Duration { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) -} - fn u64_to_i64(n: impl Into) -> i64 { i64::try_from(n.into()).unwrap_or(i64::MAX) } diff --git a/beacon_node/http_api/src/publish_attestations.rs b/beacon_node/http_api/src/publish_attestations.rs index 947edf56d9..b93f2a0b7b 100644 --- a/beacon_node/http_api/src/publish_attestations.rs +++ b/beacon_node/http_api/src/publish_attestations.rs @@ -35,15 +35,13 @@ //! appears that this validator is capable of producing valid //! attestations and there's no immediate cause for concern. use crate::task_spawner::{Priority, TaskSpawner}; -use beacon_chain::{ - AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes, - validator_monitor::timestamp_now, -}; +use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; use beacon_processor::work_reprocessing_queue::{QueuedUnaggregate, ReprocessQueueMessage}; use beacon_processor::{Work, WorkEvent}; use eth2::types::Failure; use lighthouse_network::PubsubMessage; use network::NetworkMessage; +use slot_clock::SlotClock; use std::sync::Arc; use std::time::Duration; use tokio::sync::{mpsc::UnboundedSender, oneshot}; @@ -138,7 +136,7 @@ pub async fn publish_attestations( .collect::>(); // Gossip validate and publish attestations that can be immediately processed. - let seen_timestamp = timestamp_now(); + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); let mut prelim_results = task_spawner .clone() .blocking_task(Priority::P0, move || { diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index eb7e56e9cc..340b0bbbed 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -4,7 +4,7 @@ use std::future::Future; use beacon_chain::blob_verification::{GossipBlobError, GossipVerifiedBlob}; use beacon_chain::block_verification_types::{AsBlock, LookupBlock}; use beacon_chain::data_column_verification::GossipVerifiedDataColumn; -use beacon_chain::validator_monitor::{get_block_delay_ms, timestamp_now}; +use beacon_chain::validator_monitor::get_block_delay_ms; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChain, BeaconChainError, BeaconChainTypes, BlockError, IntoGossipVerifiedBlock, NotifyExecutionLayer, build_blob_data_column_sidecars, @@ -19,6 +19,7 @@ use lighthouse_network::PubsubMessage; use network::NetworkMessage; use rand::prelude::SliceRandom; use reqwest::StatusCode; +use slot_clock::SlotClock; use std::marker::PhantomData; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -88,7 +89,7 @@ pub async fn publish_block>( validation_level: BroadcastValidation, duplicate_status_code: StatusCode, ) -> Result { - let seen_timestamp = timestamp_now(); + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); let block_publishing_delay_for_testing = chain.config.block_publishing_delay; let data_column_publishing_delay_for_testing = chain.config.data_column_publishing_delay; @@ -113,11 +114,12 @@ pub async fn publish_block>( debug!("Signed block received in HTTP API"); /* actually publish a block */ + let publish_chain = chain.clone(); let publish_block_p2p = move |block: Arc>, sender, seen_timestamp| -> Result<(), BlockError> { - let publish_timestamp = timestamp_now(); + let publish_timestamp = publish_chain.slot_clock.now_duration().unwrap_or_default(); let publish_delay = publish_timestamp .checked_sub(seen_timestamp) .unwrap_or_else(|| Duration::from_secs(0)); @@ -676,7 +678,7 @@ pub async fn reconstruct_block( // us. late_block_logging( &chain, - timestamp_now(), + chain.slot_clock.now_duration().unwrap_or_default(), block.message(), block_root, "builder", diff --git a/beacon_node/http_api/src/sync_committees.rs b/beacon_node/http_api/src/sync_committees.rs index efba0056b9..0dba4ff429 100644 --- a/beacon_node/http_api/src/sync_committees.rs +++ b/beacon_node/http_api/src/sync_committees.rs @@ -4,10 +4,7 @@ use crate::utils::publish_pubsub_message; use beacon_chain::sync_committee_verification::{ Error as SyncVerificationError, VerifiedSyncCommitteeMessage, }; -use beacon_chain::{ - BeaconChain, BeaconChainError, BeaconChainTypes, StateSkipConfig, - validator_monitor::timestamp_now, -}; +use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, StateSkipConfig}; use eth2::types::{self as api_types}; use lighthouse_network::PubsubMessage; use network::NetworkMessage; @@ -188,7 +185,7 @@ pub fn process_sync_committee_signatures( ) -> Result<(), warp::reject::Rejection> { let mut failures = vec![]; - let seen_timestamp = timestamp_now(); + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); for (i, sync_committee_signature) in sync_committee_signatures.iter().enumerate() { let subnet_positions = match get_subnet_positions_for_sync_committee_message( @@ -319,7 +316,7 @@ pub fn process_signed_contribution_and_proofs( let mut verified_contributions = Vec::with_capacity(signed_contribution_and_proofs.len()); let mut failures = vec![]; - let seen_timestamp = timestamp_now(); + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); if let Some(latest_optimistic_update) = chain .light_client_server_cache diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 412851233e..7533510277 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -9,7 +9,6 @@ use crate::utils::{ use crate::version::{V1, V2, V3, unsupported_version_rejection}; use crate::{StateId, attester_duties, proposer_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; -use beacon_chain::validator_monitor::timestamp_now; use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; use bls::PublicKeyBytes; use eth2::types::{ @@ -871,7 +870,7 @@ pub fn post_validator_aggregate_and_proofs( network_tx: UnboundedSender>| { task_spawner.blocking_json_task(Priority::P0, move || { not_synced_filter?; - let seen_timestamp = timestamp_now(); + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); let mut verified_aggregates = Vec::with_capacity(aggregates.len()); let mut messages = Vec::with_capacity(aggregates.len()); let mut failures = Vec::new(); diff --git a/beacon_node/network/src/router.rs b/beacon_node/network/src/router.rs index e6982e6a84..3f0e329e91 100644 --- a/beacon_node/network/src/router.rs +++ b/beacon_node/network/src/router.rs @@ -19,8 +19,8 @@ use lighthouse_network::{ }; use logging::TimeLatch; use logging::crit; +use slot_clock::SlotClock; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::{debug, error, trace, warn}; @@ -351,6 +351,7 @@ impl Router { gossip_message: PubsubMessage, should_process: bool, ) { + let seen_timestamp = self.chain.slot_clock.now_duration().unwrap_or_default(); match gossip_message { PubsubMessage::AggregateAndProofAttestation(aggregate_and_proof) => self .handle_beacon_processor_send_result( @@ -358,7 +359,7 @@ impl Router { message_id, peer_id, *aggregate_and_proof, - timestamp_now(), + seen_timestamp, ), ), PubsubMessage::Attestation(subnet_attestation) => self @@ -369,7 +370,7 @@ impl Router { subnet_attestation.1, subnet_attestation.0, should_process, - timestamp_now(), + seen_timestamp, ), ), PubsubMessage::BeaconBlock(block) => self.handle_beacon_processor_send_result( @@ -378,7 +379,7 @@ impl Router { peer_id, self.network_globals.client(&peer_id), block, - timestamp_now(), + seen_timestamp, ), ), PubsubMessage::BlobSidecar(data) => { @@ -390,7 +391,7 @@ impl Router { self.network_globals.client(&peer_id), blob_index, blob_sidecar, - timestamp_now(), + seen_timestamp, ), ) } @@ -403,7 +404,7 @@ impl Router { peer_id, subnet_id, column_sidecar, - timestamp_now(), + seen_timestamp, ), ) } @@ -450,7 +451,7 @@ impl Router { message_id, peer_id, *contribution_and_proof, - timestamp_now(), + seen_timestamp, ), ) } @@ -465,7 +466,7 @@ impl Router { peer_id, sync_committtee_msg.1, sync_committtee_msg.0, - timestamp_now(), + seen_timestamp, ), ) } @@ -480,7 +481,7 @@ impl Router { message_id, peer_id, *light_client_finality_update, - timestamp_now(), + seen_timestamp, ), ) } @@ -496,7 +497,7 @@ impl Router { message_id, peer_id, *light_client_optimistic_update, - timestamp_now(), + seen_timestamp, ), ) } @@ -516,7 +517,7 @@ impl Router { message_id, peer_id, signed_execution_payload_envelope, - timestamp_now(), + seen_timestamp, ), ) } @@ -642,7 +643,7 @@ impl Router { peer_id, sync_request_id, beacon_block, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -662,7 +663,7 @@ impl Router { peer_id, sync_request_id, blob_sidecar, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } else { crit!("All blobs by range responses should belong to sync"); @@ -699,7 +700,7 @@ impl Router { peer_id, sync_request_id, beacon_block, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -733,7 +734,7 @@ impl Router { sync_request_id, peer_id, blob_sidecar, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -767,7 +768,7 @@ impl Router { sync_request_id, peer_id, data_column, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } @@ -787,7 +788,7 @@ impl Router { peer_id, sync_request_id, data_column, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), }); } else { crit!("All data columns by range responses should belong to sync"); @@ -855,9 +856,3 @@ impl HandlerNetworkContext { }) } } - -fn timestamp_now() -> Duration { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) -} diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 7e618d8980..60dcc3efc7 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -49,7 +49,6 @@ use crate::sync::block_lookups::{ use crate::sync::custody_backfill_sync::CustodyBackFillSync; use crate::sync::network_context::{PeerGroup, RpcResponseResult}; use beacon_chain::block_verification_types::AsBlock; -use beacon_chain::validator_monitor::timestamp_now; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChain, BeaconChainTypes, BlockError, EngineState, }; @@ -851,7 +850,7 @@ impl SyncManager { BlockComponent::Block(DownloadResult { value: block.block_cloned(), block_root, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), peer_group: PeerGroup::from_single(peer_id), }), ); @@ -869,7 +868,7 @@ impl SyncManager { BlockComponent::Blob(DownloadResult { value: blob, block_root, - seen_timestamp: timestamp_now(), + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), peer_group: PeerGroup::from_single(peer_id), }), ); @@ -889,7 +888,11 @@ impl SyncManager { BlockComponent::DataColumn(DownloadResult { value: data_column, block_root, - seen_timestamp: timestamp_now(), + seen_timestamp: self + .chain + .slot_clock + .now_duration() + .unwrap_or_default(), peer_group: PeerGroup::from_single(peer_id), }), ); diff --git a/beacon_node/network/src/sync/network_context/custody.rs b/beacon_node/network/src/sync/network_context/custody.rs index ae0eee9964..620962b40b 100644 --- a/beacon_node/network/src/sync/network_context/custody.rs +++ b/beacon_node/network/src/sync/network_context/custody.rs @@ -2,11 +2,11 @@ use crate::sync::network_context::{ DataColumnsByRootRequestId, DataColumnsByRootSingleBlockRequest, }; use beacon_chain::BeaconChainTypes; -use beacon_chain::validator_monitor::timestamp_now; use fnv::FnvHashMap; use lighthouse_network::PeerId; use lighthouse_network::service::api_types::{CustodyId, DataColumnsByRootRequester}; use parking_lot::RwLock; +use slot_clock::SlotClock; use std::collections::HashSet; use std::hash::{BuildHasher, RandomState}; use std::time::{Duration, Instant}; @@ -223,7 +223,10 @@ impl ActiveCustodyRequest { .collect::, _>>()?; let peer_group = PeerGroup::from_set(peers); - let max_seen_timestamp = seen_timestamps.into_iter().max().unwrap_or(timestamp_now()); + let max_seen_timestamp = seen_timestamps + .into_iter() + .max() + .unwrap_or_else(|| cx.chain.slot_clock.now_duration().unwrap_or_default()); return Ok(Some((columns, peer_group, max_seen_timestamp))); } diff --git a/beacon_node/network/src/sync/network_context/requests.rs b/beacon_node/network/src/sync/network_context/requests.rs index 8f9540693e..ad60dffb45 100644 --- a/beacon_node/network/src/sync/network_context/requests.rs +++ b/beacon_node/network/src/sync/network_context/requests.rs @@ -1,9 +1,9 @@ use std::time::Instant; use std::{collections::hash_map::Entry, hash::Hash}; -use beacon_chain::validator_monitor::timestamp_now; use fnv::FnvHashMap; use lighthouse_network::PeerId; +use slot_clock::timestamp_now; use strum::IntoStaticStr; use tracing::{Span, debug}; use types::{Hash256, Slot}; diff --git a/common/slot_clock/src/lib.rs b/common/slot_clock/src/lib.rs index abfab547b9..757d0164ca 100644 --- a/common/slot_clock/src/lib.rs +++ b/common/slot_clock/src/lib.rs @@ -2,7 +2,7 @@ mod manual_slot_clock; mod metrics; mod system_time_slot_clock; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub use crate::manual_slot_clock::ManualSlotClock as TestingSlotClock; pub use crate::manual_slot_clock::ManualSlotClock; @@ -110,3 +110,13 @@ pub trait SlotClock: Send + Sync + Sized + Clone { slot_clock } } + +/// Returns the current system time as a duration since the UNIX epoch. +/// +/// This is a convenience function for recording timestamps when `SlotClock` is not available. +/// Prefer `SlotClock::now_duration` if available. +pub fn timestamp_now() -> Duration { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() +} From c615210fefdb10852ff950ab9163c100b37bd67e Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:00:53 +0800 Subject: [PATCH 115/189] Truncated `Display` impl for `ExecutionBlockHash` (#9108) - #6689 The intention is to only modify the INFO logs that's emitted regularly to reduce the verbosity. But I understand that this change will affect other display in the logs too that uses the `ExecutionBlockHash` display. So would love some feedbacks about the change. Co-Authored-By: Tan Chee Keong Co-Authored-By: Mac L --- beacon_node/client/src/notifier.rs | 2 +- .../types/src/core/execution_block_hash.rs | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/beacon_node/client/src/notifier.rs b/beacon_node/client/src/notifier.rs index c1d8cae573..4acb8c3aed 100644 --- a/beacon_node/client/src/notifier.rs +++ b/beacon_node/client/src/notifier.rs @@ -374,7 +374,7 @@ pub fn spawn_notifier( warn!( info = "chain not fully verified, \ block and attestation production disabled until execution engine syncs", - execution_block_hash = ?hash, + execution_block_hash = ?hash, "Head is optimistic" ); format!("{} (unverified)", hash) diff --git a/consensus/types/src/core/execution_block_hash.rs b/consensus/types/src/core/execution_block_hash.rs index 91c019ce04..cbacf7cf74 100644 --- a/consensus/types/src/core/execution_block_hash.rs +++ b/consensus/types/src/core/execution_block_hash.rs @@ -18,6 +18,18 @@ impl fmt::Debug for ExecutionBlockHash { } } +impl fmt::Display for ExecutionBlockHash { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let hash = format!("{}", self.0); + write!( + f, + "{}…{}", + &hash[..6], + &hash[hash.len().saturating_sub(4)..] + ) + } +} + impl ExecutionBlockHash { pub fn zero() -> Self { Self(Hash256::zero()) @@ -102,12 +114,6 @@ impl std::str::FromStr for ExecutionBlockHash { } } -impl fmt::Display for ExecutionBlockHash { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.0) - } -} - impl From for ExecutionBlockHash { fn from(hash: Hash256) -> Self { Self(hash) From 8c8facd0cdb1b5db98f044aab257be64bdae3782 Mon Sep 17 00:00:00 2001 From: Barnabas Busa Date: Mon, 13 Apr 2026 03:02:50 +0200 Subject: [PATCH 116/189] Add missing beacon API config/spec values (#9112) Co-Authored-By: Barnabas Busa --- consensus/types/presets/gnosis/gloas.yaml | 22 ++++++++++++++ consensus/types/src/core/chain_spec.rs | 30 ++++++++++++++++--- consensus/types/src/core/config_and_preset.rs | 3 ++ consensus/types/src/core/preset.rs | 21 +++++++++++-- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/consensus/types/presets/gnosis/gloas.yaml b/consensus/types/presets/gnosis/gloas.yaml index 170accaac3..d1a48adca1 100644 --- a/consensus/types/presets/gnosis/gloas.yaml +++ b/consensus/types/presets/gnosis/gloas.yaml @@ -1 +1,23 @@ # Gnosis preset - Gloas + +# Misc +# --------------------------------------------------------------- +# 2**9 (= 512) validators +PTC_SIZE: 512 + +# Max operations per block +# --------------------------------------------------------------- +# 2**1 (= 2) attestations +MAX_PAYLOAD_ATTESTATIONS: 2 + +# State list lengths +# --------------------------------------------------------------- +# 2**40 (= 1,099,511,627,776) builder spots +BUILDER_REGISTRY_LIMIT: 1099511627776 +# 2**20 (= 1,048,576) builder pending withdrawals +BUILDER_PENDING_WITHDRAWALS_LIMIT: 1048576 + +# Withdrawals processing +# --------------------------------------------------------------- +# 2**14 (= 16,384) builders +MAX_BUILDERS_PER_WITHDRAWALS_SWEEP: 16384 diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index e612c8b6db..d06e5083c8 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -152,6 +152,7 @@ pub struct ChainSpec { pub proposer_score_boost: Option, pub reorg_head_weight_threshold: Option, pub reorg_parent_weight_threshold: Option, + pub reorg_max_epochs_since_finalization: Option, /* * Eth1 @@ -1149,6 +1150,7 @@ impl ChainSpec { proposer_score_boost: Some(40), reorg_head_weight_threshold: Some(20), reorg_parent_weight_threshold: Some(160), + reorg_max_epochs_since_finalization: Some(2), /* * Eth1 @@ -1554,6 +1556,7 @@ impl ChainSpec { proposer_score_boost: Some(40), reorg_head_weight_threshold: Some(20), reorg_parent_weight_threshold: Some(160), + reorg_max_epochs_since_finalization: Some(2), /* * Eth1 @@ -1983,6 +1986,13 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] proposer_score_boost: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + reorg_head_weight_threshold: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + reorg_parent_weight_threshold: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + reorg_max_epochs_since_finalization: Option>, + #[serde(with = "serde_utils::quoted_u64")] deposit_chain_id: u64, #[serde(with = "serde_utils::quoted_u64")] @@ -2545,6 +2555,15 @@ impl Config { max_per_epoch_activation_churn_limit: spec.max_per_epoch_activation_churn_limit, proposer_score_boost: spec.proposer_score_boost.map(|value| MaybeQuoted { value }), + reorg_head_weight_threshold: spec + .reorg_head_weight_threshold + .map(|value| MaybeQuoted { value }), + reorg_parent_weight_threshold: spec + .reorg_parent_weight_threshold + .map(|value| MaybeQuoted { value }), + reorg_max_epochs_since_finalization: spec + .reorg_max_epochs_since_finalization + .map(|value| MaybeQuoted { value }), deposit_chain_id: spec.deposit_chain_id, deposit_network_id: spec.deposit_network_id, @@ -2647,6 +2666,9 @@ impl Config { max_per_epoch_activation_churn_limit, churn_limit_quotient, proposer_score_boost, + reorg_head_weight_threshold, + reorg_parent_weight_threshold, + reorg_max_epochs_since_finalization, deposit_chain_id, deposit_network_id, deposit_contract_address, @@ -2743,6 +2765,10 @@ impl Config { max_per_epoch_activation_churn_limit, churn_limit_quotient, proposer_score_boost: proposer_score_boost.map(|q| q.value), + reorg_head_weight_threshold: reorg_head_weight_threshold.map(|q| q.value), + reorg_parent_weight_threshold: reorg_parent_weight_threshold.map(|q| q.value), + reorg_max_epochs_since_finalization: reorg_max_epochs_since_finalization + .map(|q| q.value), deposit_chain_id, deposit_network_id, deposit_contract_address, @@ -3692,10 +3718,6 @@ mod yaml_tests { "SYNC_MESSAGE_DUE_BPS_GLOAS", "CONTRIBUTION_DUE_BPS_GLOAS", "MAX_REQUEST_PAYLOADS", - // Gloas fork choice params not yet in Config - "REORG_HEAD_WEIGHT_THRESHOLD", - "REORG_PARENT_WEIGHT_THRESHOLD", - "REORG_MAX_EPOCHS_SINCE_FINALIZATION", // Heze networking "VIEW_FREEZE_CUTOFF_BPS", "INCLUSION_LIST_SUBMISSION_DUE_BPS", diff --git a/consensus/types/src/core/config_and_preset.rs b/consensus/types/src/core/config_and_preset.rs index 06f080e82b..02f9867fcb 100644 --- a/consensus/types/src/core/config_and_preset.rs +++ b/consensus/types/src/core/config_and_preset.rs @@ -133,6 +133,9 @@ pub fn get_extra_fields(spec: &ChainSpec) -> HashMap { "domain_sync_committee_selection_proof".to_uppercase() => u32_hex(spec.domain_sync_committee_selection_proof), "domain_bls_to_execution_change".to_uppercase() => u32_hex(spec.domain_bls_to_execution_change), + "domain_beacon_builder".to_uppercase() => u32_hex(spec.domain_beacon_builder), + "domain_ptc_attester".to_uppercase() => u32_hex(spec.domain_ptc_attester), + "domain_proposer_preferences".to_uppercase() => u32_hex(spec.domain_proposer_preferences), "sync_committee_subnet_count".to_uppercase() => consts::altair::SYNC_COMMITTEE_SUBNET_COUNT.to_string().into(), "target_aggregators_per_sync_subcommittee".to_uppercase() => diff --git a/consensus/types/src/core/preset.rs b/consensus/types/src/core/preset.rs index 4fa7a28204..978fc6f4a1 100644 --- a/consensus/types/src/core/preset.rs +++ b/consensus/types/src/core/preset.rs @@ -331,11 +331,28 @@ impl FuluPreset { #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] -pub struct GloasPreset {} +pub struct GloasPreset { + #[serde(with = "serde_utils::quoted_u64")] + pub ptc_size: u64, + #[serde(with = "serde_utils::quoted_u64")] + pub max_payload_attestations: u64, + #[serde(with = "serde_utils::quoted_u64")] + pub builder_registry_limit: u64, + #[serde(with = "serde_utils::quoted_u64")] + pub builder_pending_withdrawals_limit: u64, + #[serde(with = "serde_utils::quoted_u64")] + pub max_builders_per_withdrawals_sweep: u64, +} impl GloasPreset { pub fn from_chain_spec(_spec: &ChainSpec) -> Self { - Self {} + Self { + ptc_size: E::ptc_size() as u64, + max_payload_attestations: E::max_payload_attestations() as u64, + builder_registry_limit: E::BuilderRegistryLimit::to_u64(), + builder_pending_withdrawals_limit: E::builder_pending_withdrawals_limit() as u64, + max_builders_per_withdrawals_sweep: E::max_builders_per_withdrawals_sweep() as u64, + } } } From b40a17811176543306fc90565327dff06e13bace Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Wed, 15 Apr 2026 01:39:59 +0900 Subject: [PATCH 117/189] Gloas bid and preference verification (#9036) Gossip verify and cache bids and proposer preferences. This PR also ensures we subscribe to new fork topics one epoch early instead of two slots early. This is required for proposer preferences. Co-Authored-By: Eitan Seri- Levi --- beacon_node/beacon_chain/src/beacon_chain.rs | 8 + beacon_node/beacon_chain/src/builder.rs | 2 + beacon_node/beacon_chain/src/lib.rs | 2 + .../gossip_verified_bid.rs | 380 +++++++++ .../src/payload_bid_verification/mod.rs | 76 ++ .../payload_bid_cache.rs | 156 ++++ .../src/payload_bid_verification/tests.rs | 748 ++++++++++++++++++ .../gossip_verified_envelope.rs | 6 +- .../gossip_verified_proposer_preferences.rs | 223 ++++++ .../proposer_preferences_verification/mod.rs | 70 ++ .../proposer_preference_cache.rs | 107 +++ .../tests.rs | 279 +++++++ .../gossip_methods.rs | 127 ++- .../src/network_beacon_processor/mod.rs | 6 +- .../src/per_block_processing.rs | 24 +- .../process_operations.rs | 3 +- .../per_block_processing/signature_sets.rs | 47 +- consensus/types/src/builder/builder.rs | 11 +- consensus/types/src/state/beacon_state.rs | 71 +- 19 files changed, 2267 insertions(+), 79 deletions(-) create mode 100644 beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs create mode 100644 beacon_node/beacon_chain/src/payload_bid_verification/mod.rs create mode 100644 beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs create mode 100644 beacon_node/beacon_chain/src/payload_bid_verification/tests.rs create mode 100644 beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs create mode 100644 beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs create mode 100644 beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs create mode 100644 beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index e226c707a4..acf7ad9c4c 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -54,6 +54,7 @@ use crate::observed_block_producers::ObservedBlockProducers; use crate::observed_data_sidecars::ObservedDataSidecars; use crate::observed_operations::{ObservationOutcome, ObservedOperations}; use crate::observed_slashable::ObservedSlashable; +use crate::payload_bid_verification::payload_bid_cache::GossipVerifiedPayloadBidCache; #[cfg(not(test))] use crate::payload_envelope_streamer::{EnvelopeRequestSource, launch_payload_envelope_stream}; use crate::pending_payload_envelopes::PendingPayloadEnvelopes; @@ -61,6 +62,7 @@ use crate::persisted_beacon_chain::PersistedBeaconChain; use crate::persisted_custody::persist_custody_context; use crate::persisted_fork_choice::PersistedForkChoice; use crate::pre_finalization_cache::PreFinalizationBlockCache; +use crate::proposer_preferences_verification::proposer_preference_cache::GossipVerifiedProposerPreferenceCache; use crate::shuffling_cache::{BlockShufflingIds, ShufflingCache}; use crate::sync_committee_verification::{ Error as SyncCommitteeError, VerifiedSyncCommitteeMessage, VerifiedSyncContribution, @@ -466,6 +468,10 @@ pub struct BeaconChain { pub envelope_times_cache: Arc>, /// A cache used to track pre-finalization block roots for quick rejection. pub pre_finalization_block_cache: PreFinalizationBlockCache, + /// A cache used to store gossip verified payload bids. + pub gossip_verified_payload_bid_cache: GossipVerifiedPayloadBidCache, + /// A cache used to store gossip verified proposer preferences. + pub gossip_verified_proposer_preferences_cache: GossipVerifiedProposerPreferenceCache, /// A cache used to produce light_client server messages pub light_client_server_cache: LightClientServerCache, /// Sender to signal the light_client server to produce new updates @@ -6403,6 +6409,8 @@ impl BeaconChain { self.naive_aggregation_pool.write().prune(slot); self.block_times_cache.write().prune(slot); self.envelope_times_cache.write().prune(slot); + self.gossip_verified_payload_bid_cache.prune(slot); + self.gossip_verified_proposer_preferences_cache.prune(slot); // Don't run heavy-weight tasks during sync. if self.best_slot() + MAX_PER_SLOT_FORK_CHOICE_DISTANCE < slot { diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 11b87351b1..b963f7c342 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -1064,6 +1064,8 @@ where ), kzg: self.kzg.clone(), rng: Arc::new(Mutex::new(rng)), + gossip_verified_payload_bid_cache: <_>::default(), + gossip_verified_proposer_preferences_cache: <_>::default(), }; let head = beacon_chain.head_snapshot(); diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index d71aec6987..a8a706d8bc 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -43,6 +43,7 @@ pub mod observed_block_producers; pub mod observed_data_sidecars; pub mod observed_operations; mod observed_slashable; +pub mod payload_bid_verification; pub mod payload_envelope_streamer; pub mod payload_envelope_verification; pub mod pending_payload_envelopes; @@ -50,6 +51,7 @@ pub mod persisted_beacon_chain; pub mod persisted_custody; mod persisted_fork_choice; mod pre_finalization_cache; +pub mod proposer_preferences_verification; pub mod proposer_prep_service; pub mod schema_change; pub mod shuffling_cache; diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs new file mode 100644 index 0000000000..91945896df --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs @@ -0,0 +1,380 @@ +use std::sync::Arc; + +use crate::{ + BeaconChain, BeaconChainTypes, CanonicalHead, + payload_bid_verification::{PayloadBidError, payload_bid_cache::GossipVerifiedPayloadBidCache}, + proposer_preferences_verification::proposer_preference_cache::GossipVerifiedProposerPreferenceCache, +}; +use educe::Educe; +use slot_clock::SlotClock; +use state_processing::signature_sets::{ + execution_payload_bid_signature_set, get_builder_pubkey_from_state, +}; +use tracing::debug; +use types::{ + BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, SignedExecutionPayloadBid, + SignedProposerPreferences, Slot, +}; + +/// Verify that an execution payload bid is consistent with the current chain state +/// and proposer preferences. +pub(crate) fn verify_bid_consistency( + bid: &ExecutionPayloadBid, + current_slot: Slot, + proposer_preferences: &SignedProposerPreferences, + head_state: &BeaconState, + spec: &ChainSpec, +) -> Result<(), PayloadBidError> { + let bid_slot = bid.slot; + + if bid_slot != current_slot && bid_slot != current_slot.saturating_add(1u64) { + return Err(PayloadBidError::InvalidBidSlot { bid_slot }); + } + + // Execution payments are used by off protocol builders. In protocol bids + // should always have this value set to zero. + if bid.execution_payment != 0 { + return Err(PayloadBidError::ExecutionPaymentNonZero { + execution_payment: bid.execution_payment, + }); + } + + if bid.fee_recipient != proposer_preferences.message.fee_recipient { + return Err(PayloadBidError::InvalidFeeRecipient); + } + if bid.gas_limit != proposer_preferences.message.gas_limit { + return Err(PayloadBidError::InvalidGasLimit); + } + + let max_blobs_per_block = + spec.max_blobs_per_block(bid_slot.epoch(E::slots_per_epoch())) as usize; + + if bid.blob_kzg_commitments.len() > max_blobs_per_block { + return Err(PayloadBidError::InvalidBlobKzgCommitments { + max_blobs_per_block, + blob_kzg_commitments_len: bid.blob_kzg_commitments.len(), + }); + } + + let builder_index = bid.builder_index; + + let is_active_builder = head_state + .is_active_builder(builder_index, spec) + .map_err(|_| PayloadBidError::InvalidBuilder { builder_index })?; + + if !is_active_builder { + return Err(PayloadBidError::InvalidBuilder { builder_index }); + } + + if !head_state.can_builder_cover_bid(builder_index, bid.value, spec)? { + return Err(PayloadBidError::BuilderCantCoverBid { + builder_index, + builder_bid: bid.value, + }); + } + + Ok(()) +} + +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + pub canonical_head: &'a CanonicalHead, + pub gossip_verified_payload_bid_cache: &'a GossipVerifiedPayloadBidCache, + pub gossip_verified_proposer_preferences_cache: &'a GossipVerifiedProposerPreferenceCache, + pub slot_clock: &'a T::SlotClock, + pub spec: &'a ChainSpec, +} + +/// A wrapper around a `SignedExecutionPayloadBid` that indicates it has been approved for re-gossiping on +/// the p2p network. +#[derive(Educe)] +#[educe( + Debug(bound = "T: BeaconChainTypes"), + Clone(bound = "T: BeaconChainTypes") +)] +pub struct GossipVerifiedPayloadBid { + pub signed_bid: Arc>, +} + +impl GossipVerifiedPayloadBid { + pub fn new( + signed_bid: Arc>, + ctx: &GossipVerificationContext<'_, T>, + ) -> Result { + let bid_slot = signed_bid.message.slot; + let bid_parent_block_hash = signed_bid.message.parent_block_hash; + let bid_parent_block_root = signed_bid.message.parent_block_root; + let bid_value = signed_bid.message.value; + + if ctx + .gossip_verified_payload_bid_cache + .seen_builder_index(&bid_slot, signed_bid.message.builder_index) + { + return Err(PayloadBidError::BuilderAlreadySeen { + builder_index: signed_bid.message.builder_index, + slot: bid_slot, + }); + } + + // TODO(gloas): Extract into `bid_value_over_threshold` on the bid cache and potentially + // make this more sophisticate than just a <= check. + if let Some(cached_bid) = ctx.gossip_verified_payload_bid_cache.get_highest_bid( + bid_slot, + bid_parent_block_hash, + bid_parent_block_root, + ) && bid_value <= cached_bid.message.value + { + return Err(PayloadBidError::BidValueBelowCached { + cached_value: cached_bid.message.value, + incoming_value: bid_value, + }); + } + + let cached_head = ctx.canonical_head.cached_head(); + let current_slot = ctx + .slot_clock + .now() + .ok_or(PayloadBidError::UnableToReadSlot)?; + let head_state = &cached_head.snapshot.beacon_state; + + let Some(proposer_preferences) = ctx + .gossip_verified_proposer_preferences_cache + .get_preferences(&bid_slot) + else { + return Err(PayloadBidError::NoProposerPreferences { slot: bid_slot }); + }; + + let fork_choice = ctx.canonical_head.fork_choice_read_lock(); + + // TODO(gloas) reprocess bids whose parent_block_root becomes known & canonical after a reorg? + if !fork_choice.contains_block(&bid_parent_block_root) { + return Err(PayloadBidError::ParentBlockRootUnknown { + parent_block_root: bid_parent_block_root, + }); + } + + // TODO(gloas) reprocess bids whose parent_block_root becomes canonical after a reorg. + let head_root = cached_head.head_block_root(); + if !fork_choice.is_descendant(bid_parent_block_root, head_root) { + return Err(PayloadBidError::ParentBlockRootNotCanonical { + parent_block_root: bid_parent_block_root, + }); + } + + // TODO(gloas) [IGNORE] bid.parent_block_hash is the block hash of a known execution payload in fork choice. + + drop(fork_choice); + + verify_bid_consistency( + &signed_bid.message, + current_slot, + &proposer_preferences, + head_state, + ctx.spec, + )?; + + // Verify signature + execution_payload_bid_signature_set( + head_state, + |i| get_builder_pubkey_from_state(head_state, i), + &signed_bid, + ctx.spec, + ) + .map_err(|_| PayloadBidError::BadSignature)? + .ok_or(PayloadBidError::BadSignature)? + .verify() + .then_some(()) + .ok_or(PayloadBidError::BadSignature)?; + + let gossip_verified_bid = GossipVerifiedPayloadBid { signed_bid }; + + ctx.gossip_verified_payload_bid_cache + .insert_seen_builder(&gossip_verified_bid); + + ctx.gossip_verified_payload_bid_cache + .insert_highest_bid(gossip_verified_bid.clone()); + + Ok(gossip_verified_bid) + } +} + +impl BeaconChain { + /// Build a `GossipVerificationContext` from this `BeaconChain` for `GossipVerifiedPayloadBid`. + pub fn payload_bid_gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_payload_bid_cache: &self.gossip_verified_payload_bid_cache, + gossip_verified_proposer_preferences_cache: &self + .gossip_verified_proposer_preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + /// Returns `Ok(GossipVerifiedPayloadBid)` if the supplied `bid` should be forwarded onto the + /// gossip network and cached. + /// + /// ## Errors + /// + /// Returns an `Err` if the given bid was invalid, or an error was encountered during verification. + pub fn verify_payload_bid_for_gossip( + &self, + bid: Arc>, + ) -> Result, PayloadBidError> { + let slot = bid.message.slot; + let parent_block_root = bid.message.parent_block_root; + let parent_block_hash = bid.message.parent_block_hash; + + let ctx = self.payload_bid_gossip_verification_context(); + match GossipVerifiedPayloadBid::new(bid, &ctx) { + Ok(verified) => { + debug!( + %slot, + %parent_block_hash, + %parent_block_root, + "Successfully verified gossip payload bid" + ); + Ok(verified) + } + Err(e) => { + debug!( + error = e.to_string(), + %slot, + %parent_block_hash, + %parent_block_root, + "Rejected gossip payload bid" + ); + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + use bls::Signature; + use kzg::KzgCommitment; + use ssz_types::VariableList; + use types::{ + Address, BeaconState, ChainSpec, EthSpec, ExecutionPayloadBid, MinimalEthSpec, + ProposerPreferences, SignedProposerPreferences, Slot, + }; + + use super::verify_bid_consistency; + use crate::payload_bid_verification::PayloadBidError; + + type E = MinimalEthSpec; + + fn make_bid(slot: Slot, fee_recipient: Address, gas_limit: u64) -> ExecutionPayloadBid { + ExecutionPayloadBid { + slot, + fee_recipient, + gas_limit, + value: 100, + ..ExecutionPayloadBid::default() + } + } + + fn make_preferences(fee_recipient: Address, gas_limit: u64) -> SignedProposerPreferences { + SignedProposerPreferences { + message: ProposerPreferences { + fee_recipient, + gas_limit, + ..ProposerPreferences::default() + }, + signature: Signature::empty(), + } + } + + fn state_and_spec() -> (BeaconState, ChainSpec) { + let spec = E::default_spec(); + let state = BeaconState::new(0, <_>::default(), &spec); + (state, spec) + } + + #[test] + fn test_invalid_bid_slot_too_old() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(Slot::new(5), Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); + } + + #[test] + fn test_invalid_bid_slot_too_far_ahead() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(Slot::new(12), Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); + } + + #[test] + fn test_execution_payment_nonzero() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let mut bid = make_bid(current_slot, Address::ZERO, 30_000_000); + bid.execution_payment = 42; + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::ExecutionPaymentNonZero { + execution_payment: 42 + }) + )); + } + + #[test] + fn test_fee_recipient_mismatch() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(current_slot, Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::repeat_byte(0xaa), 30_000_000); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!(result, Err(PayloadBidError::InvalidFeeRecipient))); + } + + #[test] + fn test_invalid_blob_kzg_commitments() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let mut bid = make_bid(current_slot, Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 30_000_000); + + let max_blobs = spec.max_blobs_per_block(current_slot.epoch(E::slots_per_epoch())) as usize; + let commitments: Vec = (0..=max_blobs) + .map(|_| KzgCommitment::empty_for_testing()) + .collect(); + bid.blob_kzg_commitments = VariableList::new(commitments).unwrap(); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBlobKzgCommitments { .. }) + )); + } + + #[test] + fn test_gas_limit_mismatch() { + let (state, spec) = state_and_spec(); + let current_slot = Slot::new(10); + let bid = make_bid(current_slot, Address::ZERO, 30_000_000); + let prefs = make_preferences(Address::ZERO, 50_000_000); + + let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); + assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); + } +} diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs new file mode 100644 index 0000000000..514695f5c0 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -0,0 +1,76 @@ +//! Gossip verification for execution payload bids. +//! +//! A `SignedExecutionPayloadBid` is verified and wrapped as a `GossipVerifiedPayloadBid`, +//! which is then inserted into the `GossipVerifiedPayloadBidCache`. +//! +//! ```ignore +//! SignedExecutionPayloadBid +//! | +//! ▼ +//! GossipVerifiedPayloadBid -------> Insert into GossipVerifiedPayloadBidCache +//! ``` + +use types::{BeaconStateError, Hash256, Slot}; + +pub mod gossip_verified_bid; +pub mod payload_bid_cache; + +#[cfg(test)] +mod tests; + +#[derive(Debug)] +pub enum PayloadBidError { + /// The bid's parent block root is unknown. + ParentBlockRootUnknown { parent_block_root: Hash256 }, + /// The bid's parent block root is known but not on the canonical chain. + ParentBlockRootNotCanonical { parent_block_root: Hash256 }, + /// The signature is invalid. + BadSignature, + /// A bid for this builder at this slot has already been seen. + BuilderAlreadySeen { builder_index: u64, slot: Slot }, + /// Builder is not valid/active for the given epoch + InvalidBuilder { builder_index: u64 }, + /// The bid value is lower than the currently cached bid. + BidValueBelowCached { + cached_value: u64, + incoming_value: u64, + }, + /// The bids slot is not the current slot or the next slot. + InvalidBidSlot { bid_slot: Slot }, + /// The slot clock cannot be read. + UnableToReadSlot, + /// No proposer preferences for the current slot. + NoProposerPreferences { slot: Slot }, + /// The builder doesn't have enough deposited funds to cover the bid. + BuilderCantCoverBid { + builder_index: u64, + builder_bid: u64, + }, + /// The bids fee recipient doesn't match the proposer preferences fee recipient. + InvalidFeeRecipient, + /// The bids gas limit doesn't match the proposer preferences gas limit. + InvalidGasLimit, + /// The bids execution payment is non-zero + ExecutionPaymentNonZero { execution_payment: u64 }, + /// The number of blob KZG commitments exceeds the maximum allowed. + InvalidBlobKzgCommitments { + max_blobs_per_block: usize, + blob_kzg_commitments_len: usize, + }, + /// Some Beacon State error + BeaconStateError(BeaconStateError), + /// Internal error + InternalError(String), +} + +impl std::fmt::Display for PayloadBidError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for PayloadBidError { + fn from(e: BeaconStateError) -> Self { + PayloadBidError::BeaconStateError(e) + } +} diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs b/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs new file mode 100644 index 0000000000..1c98569bc5 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/payload_bid_cache.rs @@ -0,0 +1,156 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; + +use crate::{ + BeaconChainTypes, payload_bid_verification::gossip_verified_bid::GossipVerifiedPayloadBid, +}; +use parking_lot::RwLock; +use types::{BuilderIndex, ExecutionBlockHash, Hash256, SignedExecutionPayloadBid, Slot}; + +type HighestBidMap = + BTreeMap>>; + +pub struct GossipVerifiedPayloadBidCache { + highest_bid: RwLock>, + seen_builder: RwLock>>, +} + +impl Default for GossipVerifiedPayloadBidCache { + fn default() -> Self { + Self { + highest_bid: RwLock::new(BTreeMap::new()), + seen_builder: RwLock::new(BTreeMap::new()), + } + } +} + +impl GossipVerifiedPayloadBidCache { + /// Get the cached bid for the tuple `(slot, parent_block_hash, parent_block_root)`. + pub fn get_highest_bid( + &self, + slot: Slot, + parent_block_hash: ExecutionBlockHash, + parent_block_root: Hash256, + ) -> Option>> { + self.highest_bid.read().get(&slot).and_then(|map| { + map.get(&(parent_block_hash, parent_block_root)) + .map(|b| b.signed_bid.clone()) + }) + } + + /// Insert a bid for the tuple `(slot, parent_block_hash, parent_block_root)` only if + /// its value is higher than the currently cached bid for that tuple. + pub fn insert_highest_bid(&self, bid: GossipVerifiedPayloadBid) { + let key = ( + bid.signed_bid.message.parent_block_hash, + bid.signed_bid.message.parent_block_root, + ); + let mut highest_bid = self.highest_bid.write(); + let slot_map = highest_bid.entry(bid.signed_bid.message.slot).or_default(); + + if let Some(existing) = slot_map.get(&key) + && existing.signed_bid.message.value >= bid.signed_bid.message.value + { + return; + } + slot_map.insert(key, bid); + } + + /// A gossip verified bid for `BuilderIndex` already exists at `slot` + pub fn seen_builder_index(&self, slot: &Slot, builder_index: BuilderIndex) -> bool { + self.seen_builder + .read() + .get(slot) + .is_some_and(|seen_builders| seen_builders.contains(&builder_index)) + } + + /// Insert a builder into the seen cache. + pub fn insert_seen_builder(&self, bid: &GossipVerifiedPayloadBid) { + let mut seen_builder = self.seen_builder.write(); + seen_builder + .entry(bid.signed_bid.message.slot) + .or_default() + .insert(bid.signed_bid.message.builder_index); + } + + /// Prune anything before `current_slot` + pub fn prune(&self, current_slot: Slot) { + self.highest_bid + .write() + .retain(|&slot, _| slot >= current_slot); + + self.seen_builder + .write() + .retain(|&slot, _| slot >= current_slot); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bls::Signature; + use types::{ + ExecutionBlockHash, ExecutionPayloadBid, Hash256, MinimalEthSpec, + SignedExecutionPayloadBid, Slot, + }; + + use super::GossipVerifiedPayloadBidCache; + use crate::{ + payload_bid_verification::gossip_verified_bid::GossipVerifiedPayloadBid, + test_utils::EphemeralHarnessType, + }; + + type E = MinimalEthSpec; + type T = EphemeralHarnessType; + + fn make_gossip_verified( + slot: Slot, + builder_index: u64, + parent_block_hash: ExecutionBlockHash, + parent_block_root: Hash256, + value: u64, + ) -> GossipVerifiedPayloadBid { + GossipVerifiedPayloadBid { + signed_bid: Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + builder_index, + parent_block_hash, + parent_block_root, + value, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }), + } + } + + #[test] + fn prune_removes_old_retains_current() { + let cache = GossipVerifiedPayloadBidCache::::default(); + let hash = ExecutionBlockHash::zero(); + let root = Hash256::ZERO; + + for slot in [1, 2, 3, 7, 8, 9, 10] { + let verified = make_gossip_verified(Slot::new(slot), slot, hash, root, slot * 100); + cache.insert_seen_builder(&verified); + cache.insert_highest_bid(verified); + } + + cache.prune(Slot::new(8)); + + // Slots 1-7 pruned from both maps. + for slot in [1, 2, 3, 7] { + assert!(cache.get_highest_bid(Slot::new(slot), hash, root).is_none()); + assert!(!cache.seen_builder_index(&Slot::new(slot), slot)); + } + // Slots 8-10 retained in both maps. + for slot in [8, 9, 10] { + assert!(cache.get_highest_bid(Slot::new(slot), hash, root).is_some()); + assert!(cache.seen_builder_index(&Slot::new(slot), slot)); + } + } +} diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs new file mode 100644 index 0000000000..bb59b16ffb --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -0,0 +1,748 @@ +use std::sync::Arc; + +use std::time::Duration; + +use bls::{Keypair, PublicKeyBytes, Signature}; +use ethereum_hashing::hash; +use fork_choice::ForkChoice; +use genesis::{generate_deterministic_keypairs, interop_genesis_state}; +use kzg::KzgCommitment; +use slot_clock::{SlotClock, TestingSlotClock}; +use ssz::Encode; +use ssz_types::VariableList; +use store::{HotColdDB, StoreConfig}; +use types::{ + Address, BeaconBlock, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, + ExecutionPayloadBid, Hash256, MinimalEthSpec, ProposerPreferences, SignedBeaconBlock, + SignedExecutionPayloadBid, SignedProposerPreferences, SignedRoot, Slot, +}; + +use proto_array::{Block as ProtoBlock, ExecutionStatus, PayloadStatus}; +use types::AttestationShufflingId; + +use crate::{ + beacon_fork_choice_store::BeaconForkChoiceStore, + beacon_snapshot::BeaconSnapshot, + canonical_head::CanonicalHead, + payload_bid_verification::{ + PayloadBidError, + gossip_verified_bid::{GossipVerificationContext, GossipVerifiedPayloadBid}, + payload_bid_cache::GossipVerifiedPayloadBidCache, + }, + proposer_preferences_verification::{ + gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences, + proposer_preference_cache::GossipVerifiedProposerPreferenceCache, + }, + test_utils::{EphemeralHarnessType, fork_name_from_env, test_spec}, +}; + +type E = MinimalEthSpec; +type T = EphemeralHarnessType; + +/// Number of regular validators (must be >= min_genesis_active_validator_count for MinimalEthSpec). +const NUM_VALIDATORS: usize = 64; +/// Number of builders to register. +const NUM_BUILDERS: usize = 4; +/// Balance given to each builder (min_deposit_amount + extra to cover bids in tests). +const BUILDER_BALANCE: u64 = 2_000_000_000; + +struct TestContext { + canonical_head: CanonicalHead, + bid_cache: GossipVerifiedPayloadBidCache, + preferences_cache: GossipVerifiedProposerPreferenceCache, + slot_clock: TestingSlotClock, + keypairs: Vec, + spec: ChainSpec, + genesis_block_root: Hash256, + inactive_builder_index: u64, +} + +fn builder_withdrawal_credentials(pubkey: &bls::PublicKey, spec: &ChainSpec) -> Hash256 { + let fake_execution_address = &hash(&pubkey.as_ssz_bytes())[0..20]; + let mut credentials = [0u8; 32]; + credentials[0] = spec.builder_withdrawal_prefix_byte; + credentials[12..].copy_from_slice(fake_execution_address); + Hash256::from_slice(&credentials) +} + +impl TestContext { + fn new() -> Self { + let spec = test_spec::(); + let store = Arc::new( + HotColdDB::open_ephemeral(StoreConfig::default(), Arc::new(spec.clone())) + .expect("should open ephemeral store"), + ); + + let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS); + + let mut state = + interop_genesis_state::(&keypairs, 0, Hash256::repeat_byte(0x42), None, &spec) + .expect("should build genesis state"); + + // Register builders in the builder registry. + for keypair in keypairs.iter().take(NUM_BUILDERS) { + let creds = builder_withdrawal_credentials(&keypair.pk, &spec); + state + .add_builder_to_registry( + PublicKeyBytes::from(keypair.pk.clone()), + creds, + BUILDER_BALANCE, + Slot::new(0), + &spec, + ) + .expect("should register builder"); + } + + // Bump finalized checkpoint epoch so builders are considered active + // (is_active_builder requires deposit_epoch < finalized_checkpoint.epoch). + *state.finalized_checkpoint_mut() = Checkpoint { + epoch: Epoch::new(1), + root: Hash256::ZERO, + }; + + let inactive_keypair = &keypairs[NUM_BUILDERS]; + let inactive_creds = builder_withdrawal_credentials(&inactive_keypair.pk, &spec); + let inactive_builder_index = state + .add_builder_to_registry( + PublicKeyBytes::from(inactive_keypair.pk.clone()), + inactive_creds, + BUILDER_BALANCE, + Slot::new(E::slots_per_epoch()), + &spec, + ) + .expect("should register inactive builder"); + + let mut genesis_block = BeaconBlock::empty(&spec); + *genesis_block.state_root_mut() = state + .update_tree_hash_cache() + .expect("should hash genesis state"); + let signed_block = SignedBeaconBlock::from_block(genesis_block, Signature::empty()); + let block_root = signed_block.canonical_root(); + + let snapshot = BeaconSnapshot::new( + Arc::new(signed_block.clone()), + None, + block_root, + state.clone(), + ); + + let fc_store = BeaconForkChoiceStore::get_forkchoice_store(store.clone(), snapshot.clone()) + .expect("should create fork choice store"); + let fork_choice = + ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec) + .expect("should create fork choice"); + + let canonical_head = + CanonicalHead::new(fork_choice, Arc::new(snapshot), PayloadStatus::Pending); + + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + + Self { + canonical_head, + bid_cache: GossipVerifiedPayloadBidCache::default(), + preferences_cache: GossipVerifiedProposerPreferenceCache::default(), + slot_clock, + keypairs, + spec, + genesis_block_root: block_root, + inactive_builder_index, + } + } + + fn sign_bid(&self, bid: ExecutionPayloadBid) -> Arc> { + let head = self.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let domain = self.spec.get_domain( + bid.slot.epoch(E::slots_per_epoch()), + Domain::BeaconBuilder, + &state.fork(), + state.genesis_validators_root(), + ); + let message = bid.signing_root(domain); + let signature = self.keypairs[bid.builder_index as usize].sk.sign(message); + Arc::new(SignedExecutionPayloadBid { + message: bid, + signature, + }) + } + + fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_payload_bid_cache: &self.bid_cache, + gossip_verified_proposer_preferences_cache: &self.preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + fn insert_non_canonical_block(&self) -> Hash256 { + let shuffling_id = AttestationShufflingId { + shuffling_epoch: Epoch::new(0), + shuffling_decision_block: self.genesis_block_root, + }; + let fork_block_root = Hash256::repeat_byte(0xab); + let mut fc = self.canonical_head.fork_choice_write_lock(); + fc.proto_array_mut() + .process_block::( + ProtoBlock { + slot: Slot::new(1), + root: fork_block_root, + parent_root: Some(self.genesis_block_root), + target_root: fork_block_root, + current_epoch_shuffling_id: shuffling_id.clone(), + next_epoch_shuffling_id: shuffling_id, + state_root: Hash256::ZERO, + justified_checkpoint: Checkpoint { + epoch: Epoch::new(0), + root: self.genesis_block_root, + }, + finalized_checkpoint: Checkpoint { + epoch: Epoch::new(0), + root: self.genesis_block_root, + }, + execution_status: ExecutionStatus::irrelevant(), + unrealized_justified_checkpoint: None, + unrealized_finalized_checkpoint: None, + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::repeat_byte(0xab)), + proposer_index: Some(0), + }, + Slot::new(1), + &self.spec, + Duration::from_secs(0), + ) + .expect("should insert fork block"); + fork_block_root + } +} + +fn make_signed_bid( + slot: Slot, + builder_index: u64, + fee_recipient: Address, + gas_limit: u64, + value: u64, + parent_block_root: Hash256, +) -> Arc> { + Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + builder_index, + fee_recipient, + gas_limit, + value, + parent_block_root, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }) +} + +fn make_signed_preferences( + proposal_slot: Slot, + validator_index: u64, + fee_recipient: Address, + gas_limit: u64, +) -> Arc { + Arc::new(SignedProposerPreferences { + message: ProposerPreferences { + proposal_slot, + validator_index, + fee_recipient, + gas_limit, + }, + signature: Signature::empty(), + }) +} + +fn seed_preferences(ctx: &TestContext, slot: Slot, fee_recipient: Address, gas_limit: u64) { + let prefs = GossipVerifiedProposerPreferences { + signed_preferences: make_signed_preferences(slot, 0, fee_recipient, gas_limit), + }; + ctx.preferences_cache.insert_preferences(prefs); +} + +#[test] +fn no_proposer_preferences_for_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let bid = make_signed_bid( + Slot::new(0), + 0, + Address::ZERO, + 30_000_000, + 100, + Hash256::ZERO, + ); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::NoProposerPreferences { .. }) + )); +} + +#[test] +fn builder_already_seen_for_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid(slot, 42, Address::ZERO, 30_000_000, 100, Hash256::ZERO); + let verified = GossipVerifiedPayloadBid { + signed_bid: bid.clone(), + }; + ctx.bid_cache.insert_seen_builder(&verified); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BuilderAlreadySeen { + builder_index: 42, + .. + }) + )); +} + +#[test] +fn bid_value_below_cached() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let high_bid = GossipVerifiedPayloadBid { + signed_bid: make_signed_bid(slot, 99, Address::ZERO, 30_000_000, 500, Hash256::ZERO), + }; + ctx.bid_cache.insert_highest_bid(high_bid); + + let low_bid = make_signed_bid(slot, 1, Address::ZERO, 30_000_000, 100, Hash256::ZERO); + let result = GossipVerifiedPayloadBid::new(low_bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BidValueBelowCached { .. }) + )); +} + +#[test] +fn invalid_bid_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(5); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBidSlot { .. }) + )); +} + +#[test] +fn fee_recipient_mismatch() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::repeat_byte(0xaa), 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!(result, Err(PayloadBidError::InvalidFeeRecipient))); +} + +#[test] +fn gas_limit_mismatch() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 50_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); +} + +#[test] +fn execution_payment_nonzero() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + gas_limit: 30_000_000, + execution_payment: 42, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::ExecutionPaymentNonZero { .. }) + )); +} + +#[test] +fn unknown_builder_index() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Use a builder_index that doesn't exist in the registry. + let bid = make_signed_bid( + slot, + 9999, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBuilder { + builder_index: 9999 + }) + )); +} + +#[test] +fn inactive_builder() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = make_signed_bid( + slot, + ctx.inactive_builder_index, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBuilder { .. }) + )); +} + +#[test] +fn builder_cant_cover_bid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Builder index 0 exists but bid value far exceeds their balance. + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + u64::MAX, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BuilderCantCoverBid { .. }) + )); +} + +#[test] +fn parent_block_root_unknown() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Parent block root not in fork choice. + let unknown_root = Hash256::repeat_byte(0xff); + let bid = make_signed_bid(slot, 0, Address::ZERO, 30_000_000, 0, unknown_root); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(result.is_err(), "expected error, got Ok"); + let err = result.unwrap_err(); + assert!( + matches!(err, PayloadBidError::ParentBlockRootUnknown { .. }), + "expected ParentBlockRootUnknown, got: {err:?}" + ); +} + +#[test] +fn parent_block_root_not_canonical() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let fork_root = ctx.insert_non_canonical_block(); + let bid = make_signed_bid(slot, 0, Address::ZERO, 30_000_000, 0, fork_root); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(result.is_err(), "expected error, got Ok"); + let err = result.unwrap_err(); + assert!( + matches!(err, PayloadBidError::ParentBlockRootNotCanonical { .. }), + "expected ParentBlockRootNotCanonical, got: {err:?}" + ); +} + +#[test] +fn invalid_blob_kzg_commitments() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let max_blobs = ctx + .spec + .max_blobs_per_block(slot.epoch(E::slots_per_epoch())) as usize; + let commitments: Vec = (0..=max_blobs) + .map(|_| KzgCommitment::empty_for_testing()) + .collect(); + + let bid = Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + blob_kzg_commitments: VariableList::new(commitments).unwrap(), + ..ExecutionPayloadBid::default() + }, + signature: Signature::empty(), + }); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::InvalidBlobKzgCommitments { .. }) + )); +} + +#[test] +fn bad_signature() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // All checks pass but signature is empty/invalid. + let bid = make_signed_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!(matches!(result, Err(PayloadBidError::BadSignature))); + assert!(!ctx.bid_cache.seen_builder_index(&slot, 0)); + assert!( + ctx.bid_cache + .get_highest_bid(slot, ExecutionBlockHash::zero(), ctx.genesis_block_root) + .is_none() + ); +} + +#[test] +fn valid_bid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn two_builders_coexist_in_cache() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + let bid_0 = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }); + let result_0 = GossipVerifiedPayloadBid::new(bid_0, &gossip); + assert!( + result_0.is_ok(), + "builder 0 should pass: {:?}", + result_0.unwrap_err() + ); + + // Builder 1 must bid strictly higher than builder 0's cached value. + let bid_1 = ctx.sign_bid(ExecutionPayloadBid { + slot, + builder_index: 1, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 1, + parent_block_root: ctx.genesis_block_root, + ..ExecutionPayloadBid::default() + }); + let result_1 = GossipVerifiedPayloadBid::new(bid_1, &gossip); + assert!( + result_1.is_ok(), + "builder 1 should pass: {:?}", + result_1.unwrap_err() + ); + + // Both builders should be seen. + assert!(ctx.bid_cache.seen_builder_index(&slot, 0)); + assert!(ctx.bid_cache.seen_builder_index(&slot, 1)); + + let highest = ctx + .bid_cache + .get_highest_bid(slot, ExecutionBlockHash::zero(), ctx.genesis_block_root) + .expect("should have highest bid"); + assert_eq!(highest.message.value, 1); + assert_eq!(highest.message.builder_index, 1); +} + +#[test] +fn bid_equal_to_cached_value_rejected() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(0); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // Seed a cached bid with value 100. + let high_bid = GossipVerifiedPayloadBid { + signed_bid: make_signed_bid( + slot, + 99, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ), + }; + ctx.bid_cache.insert_highest_bid(high_bid); + + // Submit a bid with exactly the same value — should be rejected. + let equal_bid = make_signed_bid( + slot, + 1, + Address::ZERO, + 30_000_000, + 100, + ctx.genesis_block_root, + ); + let result = GossipVerifiedPayloadBid::new(equal_bid, &gossip); + assert!(matches!( + result, + Err(PayloadBidError::BidValueBelowCached { + cached_value: 100, + incoming_value: 100, + }) + )); +} diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs index 4d40a29332..77b44a2af0 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs @@ -242,8 +242,8 @@ impl GossipVerifiedEnvelope { } impl BeaconChain { - /// Build a `GossipVerificationContext` from this `BeaconChain`. - pub fn gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { + /// Build a `GossipVerificationContext` from this `BeaconChain` for `GossipVerifiedEnvelope`. + pub fn payload_envelope_gossip_verification_context(&self) -> GossipVerificationContext<'_, T> { GossipVerificationContext { canonical_head: &self.canonical_head, store: &self.store, @@ -277,7 +277,7 @@ impl BeaconChain { let slot = envelope.slot(); let beacon_block_root = envelope.message.beacon_block_root; - let ctx = chain.gossip_verification_context(); + let ctx = chain.payload_envelope_gossip_verification_context(); match GossipVerifiedEnvelope::new(envelope, &ctx) { Ok(verified) => { debug!( diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs new file mode 100644 index 0000000000..8ea095743f --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs @@ -0,0 +1,223 @@ +use std::sync::Arc; + +use crate::{ + BeaconChain, BeaconChainTypes, CanonicalHead, + proposer_preferences_verification::{ + ProposerPreferencesError, proposer_preference_cache::GossipVerifiedProposerPreferenceCache, + }, +}; +use slot_clock::SlotClock; +use state_processing::signature_sets::{get_pubkey_from_state, proposer_preferences_signature_set}; +use tracing::debug; +use types::{ + BeaconState, ChainSpec, EthSpec, ProposerPreferences, SignedProposerPreferences, Slot, +}; + +/// Verify that proposer preferences are consistent with the current chain state +pub(crate) fn verify_preferences_consistency( + preferences: &ProposerPreferences, + current_slot: Slot, + head_state: &BeaconState, +) -> Result<(), ProposerPreferencesError> { + let proposal_slot = preferences.proposal_slot; + let validator_index = preferences.validator_index; + let current_epoch = current_slot.epoch(E::slots_per_epoch()); + let proposal_epoch = proposal_slot.epoch(E::slots_per_epoch()); + + if proposal_epoch < current_epoch || proposal_epoch > current_epoch.saturating_add(1u64) { + return Err(ProposerPreferencesError::InvalidProposalEpoch { proposal_epoch }); + } + + if proposal_slot <= current_slot { + return Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { + proposal_slot, + current_slot, + }); + } + + if !head_state.is_valid_proposal_slot(preferences)? { + return Err(ProposerPreferencesError::InvalidProposalSlot { + validator_index, + proposal_slot, + }); + } + + Ok(()) +} + +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + pub canonical_head: &'a CanonicalHead, + pub gossip_verified_proposer_preferences_cache: &'a GossipVerifiedProposerPreferenceCache, + pub slot_clock: &'a T::SlotClock, + pub spec: &'a ChainSpec, +} + +/// A wrapper around `SignedProposerPreferences` that has been verified for gossip propagation. +#[derive(Debug, Clone)] +pub struct GossipVerifiedProposerPreferences { + pub signed_preferences: Arc, +} + +impl GossipVerifiedProposerPreferences { + pub fn new( + signed_preferences: Arc, + ctx: &GossipVerificationContext<'_, T>, + ) -> Result { + let proposal_slot = signed_preferences.message.proposal_slot; + let validator_index = signed_preferences.message.validator_index; + let cached_head = ctx.canonical_head.cached_head(); + let current_slot = ctx + .slot_clock + .now() + .ok_or(ProposerPreferencesError::UnableToReadSlot)?; + let head_state = &cached_head.snapshot.beacon_state; + + if ctx + .gossip_verified_proposer_preferences_cache + .get_seen_validator(&proposal_slot, validator_index) + { + return Err(ProposerPreferencesError::AlreadySeen { + validator_index, + proposal_slot, + }); + } + + verify_preferences_consistency(&signed_preferences.message, current_slot, head_state)?; + + // Verify signature + proposer_preferences_signature_set( + head_state, + |i| get_pubkey_from_state(head_state, i), + &signed_preferences, + ctx.spec, + ) + .map_err(|_| ProposerPreferencesError::BadSignature)? + .verify() + .then_some(()) + .ok_or(ProposerPreferencesError::BadSignature)?; + + let gossip_verified = GossipVerifiedProposerPreferences { signed_preferences }; + + ctx.gossip_verified_proposer_preferences_cache + .insert_seen_validator(&gossip_verified); + + ctx.gossip_verified_proposer_preferences_cache + .insert_preferences(gossip_verified.clone()); + + Ok(gossip_verified) + } +} + +impl BeaconChain { + pub fn proposer_preferences_gossip_verification_context( + &self, + ) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_proposer_preferences_cache: &self + .gossip_verified_proposer_preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + pub fn verify_proposer_preferences_for_gossip( + &self, + signed_preferences: Arc, + ) -> Result { + let proposal_slot = signed_preferences.message.proposal_slot; + let validator_index = signed_preferences.message.validator_index; + + let ctx = self.proposer_preferences_gossip_verification_context(); + match GossipVerifiedProposerPreferences::new(signed_preferences, &ctx) { + Ok(verified) => { + debug!( + %proposal_slot, + %validator_index, + "Successfully verified gossip proposer preferences" + ); + Ok(verified) + } + Err(e) => { + debug!( + error = e.to_string(), + %proposal_slot, + %validator_index, + "Rejected gossip proposer preferences" + ); + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + use types::{Address, BeaconState, EthSpec, MinimalEthSpec, ProposerPreferences, Slot}; + + use super::verify_preferences_consistency; + use crate::proposer_preferences_verification::ProposerPreferencesError; + + type E = MinimalEthSpec; + + fn make_preferences(proposal_slot: Slot, validator_index: u64) -> ProposerPreferences { + ProposerPreferences { + proposal_slot, + validator_index, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + } + } + + fn state() -> BeaconState { + BeaconState::new(0, <_>::default(), &E::default_spec()) + } + + #[test] + fn test_invalid_epoch_too_old() { + let current_slot = Slot::new(2 * E::slots_per_epoch()); + let prefs = make_preferences(Slot::new(3), 0); + + let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) + )); + } + + #[test] + fn test_invalid_epoch_too_far_ahead() { + let current_slot = Slot::new(E::slots_per_epoch()); + let prefs = make_preferences(Slot::new(3 * E::slots_per_epoch() + 1), 0); + + let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) + )); + } + + #[test] + fn test_proposal_slot_already_passed() { + let current_slot = Slot::new(10); + let prefs = make_preferences(Slot::new(9), 0); + + let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) + )); + } + + #[test] + fn test_proposal_slot_equal_to_current() { + let current_slot = Slot::new(10); + let prefs = make_preferences(Slot::new(10), 0); + + let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + assert!(matches!( + result, + Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) + )); + } +} diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs new file mode 100644 index 0000000000..a2e96dfce1 --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs @@ -0,0 +1,70 @@ +//! Gossip verification for proposer preferences. +//! +//! A `SignedProposerPreferences` is verified and wrapped as a `GossipVerifiedProposerPreferences`, +//! which is then inserted into the `GossipVerifiedProposerPreferenceCache`. +//! +//! ```ignore +//! SignedProposerPreferences +//! | +//! ▼ +//! GossipVerifiedProposerPreferences -------> Insert into GossipVerifiedProposerPreferenceCache +//! ``` + +use std::sync::Arc; + +use types::{BeaconStateError, Epoch, Slot}; + +use crate::BeaconChainError; + +pub mod gossip_verified_proposer_preferences; +pub mod proposer_preference_cache; + +#[cfg(test)] +mod tests; + +#[derive(Debug)] +pub enum ProposerPreferencesError { + /// The proposal slot is not in the current or next epoch. + InvalidProposalEpoch { proposal_epoch: Epoch }, + /// The proposal slot has already passed. + ProposalSlotAlreadyPassed { + proposal_slot: Slot, + current_slot: Slot, + }, + /// The validator index does not match the proposer at the given slot. + InvalidProposalSlot { + validator_index: u64, + proposal_slot: Slot, + }, + /// The slot clock cannot be read. + UnableToReadSlot, + /// A valid message from this validator for this slot has already been seen. + AlreadySeen { + validator_index: u64, + proposal_slot: Slot, + }, + /// The signature is invalid. + BadSignature, + /// Some Beacon Chain Error + BeaconChainError(Arc), + /// Some Beacon State error + BeaconStateError(BeaconStateError), +} + +impl std::fmt::Display for ProposerPreferencesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for ProposerPreferencesError { + fn from(e: BeaconStateError) -> Self { + ProposerPreferencesError::BeaconStateError(e) + } +} + +impl From for ProposerPreferencesError { + fn from(e: BeaconChainError) -> Self { + ProposerPreferencesError::BeaconChainError(Arc::new(e)) + } +} diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs new file mode 100644 index 0000000000..69337f2a83 --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs @@ -0,0 +1,107 @@ +use std::{ + collections::{BTreeMap, HashSet}, + sync::Arc, +}; + +use crate::proposer_preferences_verification::gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences; +use parking_lot::RwLock; +use types::{SignedProposerPreferences, Slot}; + +pub struct GossipVerifiedProposerPreferenceCache { + preferences: RwLock>, + seen: RwLock>>, +} + +impl Default for GossipVerifiedProposerPreferenceCache { + fn default() -> Self { + Self { + preferences: RwLock::new(BTreeMap::new()), + seen: RwLock::new(BTreeMap::new()), + } + } +} + +impl GossipVerifiedProposerPreferenceCache { + pub fn get_preferences(&self, slot: &Slot) -> Option> { + self.preferences + .read() + .get(slot) + .map(|p| p.signed_preferences.clone()) + } + + pub fn insert_preferences(&self, preferences: GossipVerifiedProposerPreferences) { + let slot = preferences.signed_preferences.message.proposal_slot; + self.preferences.write().insert(slot, preferences); + } + + pub fn get_seen_validator(&self, slot: &Slot, validator_index: u64) -> bool { + self.seen + .read() + .get(slot) + .is_some_and(|seen| seen.contains(&validator_index)) + } + + pub fn insert_seen_validator(&self, preferences: &GossipVerifiedProposerPreferences) { + let slot = preferences.signed_preferences.message.proposal_slot; + let validator_index = preferences.signed_preferences.message.validator_index; + self.seen + .write() + .entry(slot) + .or_default() + .insert(validator_index); + } + + pub fn prune(&self, current_slot: Slot) { + self.preferences + .write() + .retain(|&slot, _| slot >= current_slot); + self.seen.write().retain(|&slot, _| slot >= current_slot); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bls::Signature; + use types::{Address, ProposerPreferences, SignedProposerPreferences, Slot}; + + use super::GossipVerifiedProposerPreferenceCache; + use crate::proposer_preferences_verification::gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences; + + fn make_gossip_verified(slot: Slot, validator_index: u64) -> GossipVerifiedProposerPreferences { + GossipVerifiedProposerPreferences { + signed_preferences: Arc::new(SignedProposerPreferences { + message: ProposerPreferences { + proposal_slot: slot, + validator_index, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + }, + signature: Signature::empty(), + }), + } + } + + #[test] + fn prune_removes_old_retains_current() { + let cache = GossipVerifiedProposerPreferenceCache::default(); + + for slot in [1, 2, 3, 7, 8, 9, 10] { + let verified = make_gossip_verified(Slot::new(slot), slot); + cache.insert_seen_validator(&verified); + cache.insert_preferences(verified); + } + + cache.prune(Slot::new(8)); + + for slot in [1, 2, 3, 7] { + assert!(cache.get_preferences(&Slot::new(slot)).is_none()); + assert!(!cache.get_seen_validator(&Slot::new(slot), slot)); + } + for slot in [8, 9, 10] { + assert!(cache.get_preferences(&Slot::new(slot)).is_some()); + assert!(cache.get_seen_validator(&Slot::new(slot), slot)); + } + } +} diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs new file mode 100644 index 0000000000..2f1b24fcbb --- /dev/null +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs @@ -0,0 +1,279 @@ +use std::sync::Arc; +use std::time::Duration; + +use bls::Signature; +use fork_choice::ForkChoice; +use genesis::{generate_deterministic_keypairs, interop_genesis_state}; +use proto_array::PayloadStatus; +use slot_clock::{SlotClock, TestingSlotClock}; +use store::{HotColdDB, StoreConfig}; +use types::{ + Address, BeaconBlock, ChainSpec, Checkpoint, Epoch, EthSpec, Hash256, MinimalEthSpec, + ProposerPreferences, SignedBeaconBlock, SignedProposerPreferences, Slot, +}; + +use crate::{ + beacon_fork_choice_store::BeaconForkChoiceStore, + beacon_snapshot::BeaconSnapshot, + canonical_head::CanonicalHead, + proposer_preferences_verification::{ + ProposerPreferencesError, + gossip_verified_proposer_preferences::{ + GossipVerificationContext, GossipVerifiedProposerPreferences, + }, + proposer_preference_cache::GossipVerifiedProposerPreferenceCache, + }, + test_utils::{EphemeralHarnessType, fork_name_from_env, test_spec}, +}; + +type E = MinimalEthSpec; +type T = EphemeralHarnessType; + +const NUM_VALIDATORS: usize = 64; + +struct TestContext { + canonical_head: CanonicalHead, + preferences_cache: GossipVerifiedProposerPreferenceCache, + slot_clock: TestingSlotClock, + spec: ChainSpec, +} + +impl TestContext { + fn new() -> Self { + let spec = test_spec::(); + let store = Arc::new( + HotColdDB::open_ephemeral(StoreConfig::default(), Arc::new(spec.clone())) + .expect("should open ephemeral store"), + ); + + let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS); + + let mut state = + interop_genesis_state::(&keypairs, 0, Hash256::repeat_byte(0x42), None, &spec) + .expect("should build genesis state"); + + *state.finalized_checkpoint_mut() = Checkpoint { + epoch: Epoch::new(1), + root: Hash256::ZERO, + }; + + let mut genesis_block = BeaconBlock::empty(&spec); + *genesis_block.state_root_mut() = state + .update_tree_hash_cache() + .expect("should hash genesis state"); + let signed_block = SignedBeaconBlock::from_block(genesis_block, Signature::empty()); + let block_root = signed_block.canonical_root(); + + let snapshot = BeaconSnapshot::new( + Arc::new(signed_block.clone()), + None, + block_root, + state.clone(), + ); + + let fc_store = BeaconForkChoiceStore::get_forkchoice_store(store.clone(), snapshot.clone()) + .expect("should create fork choice store"); + let fork_choice = + ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec) + .expect("should create fork choice"); + + let canonical_head = + CanonicalHead::new(fork_choice, Arc::new(snapshot), PayloadStatus::Pending); + + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + + Self { + canonical_head, + preferences_cache: GossipVerifiedProposerPreferenceCache::default(), + slot_clock, + spec, + } + } + + fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + canonical_head: &self.canonical_head, + gossip_verified_proposer_preferences_cache: &self.preferences_cache, + slot_clock: &self.slot_clock, + spec: &self.spec, + } + } + + fn proposer_at_slot(&self, slot: Slot) -> u64 { + let head = self.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let lookahead = state + .proposer_lookahead() + .expect("Gloas state has lookahead"); + let slot_in_epoch = slot.as_usize() % E::slots_per_epoch() as usize; + let epoch = slot.epoch(E::slots_per_epoch()); + let current_epoch = state.slot().epoch(E::slots_per_epoch()); + let index = if epoch == current_epoch.saturating_add(1u64) { + E::slots_per_epoch() as usize + slot_in_epoch + } else { + slot_in_epoch + }; + *lookahead.get(index).expect("index in range") + } +} + +fn make_signed_preferences( + proposal_slot: Slot, + validator_index: u64, +) -> Arc { + Arc::new(SignedProposerPreferences { + message: ProposerPreferences { + proposal_slot, + validator_index, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + }, + signature: Signature::empty(), + }) +} + +#[test] +fn already_seen_validator() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let verified = GossipVerifiedProposerPreferences { + signed_preferences: make_signed_preferences(slot, 42), + }; + ctx.preferences_cache.insert_seen_validator(&verified); + + let prefs = make_signed_preferences(slot, 42); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::AlreadySeen { + validator_index: 42, + .. + }) + )); +} + +#[test] +fn invalid_epoch_too_far_ahead() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let far_slot = Slot::new(3 * E::slots_per_epoch()); + let prefs = make_signed_preferences(far_slot, 0); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) + )); +} + +#[test] +fn proposal_slot_already_passed() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let prefs = make_signed_preferences(Slot::new(0), 0); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) + )); +} + +#[test] +fn wrong_proposer_for_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let actual_proposer = ctx.proposer_at_slot(slot); + let wrong_validator = if actual_proposer == 0 { 1 } else { 0 }; + + let prefs = make_signed_preferences(slot, wrong_validator); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalSlot { .. }) + )); +} + +#[test] +fn correct_proposer_bad_signature() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let actual_proposer = ctx.proposer_at_slot(slot); + let prefs = make_signed_preferences(slot, actual_proposer); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::BadSignature) + )); + assert!( + !ctx.preferences_cache + .get_seen_validator(&slot, actual_proposer) + ); + assert!(ctx.preferences_cache.get_preferences(&slot).is_none()); +} + +#[test] +fn validator_index_out_of_bounds() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let prefs = make_signed_preferences(slot, u64::MAX); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + assert!(matches!( + result, + Err(ProposerPreferencesError::InvalidProposalSlot { .. }) + )); +} + +// TODO(gloas) add successful proposer preferences check once we have proposer preferences signing logic + +#[test] +fn preferences_for_next_epoch_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + // Head is at slot 0 (epoch 0). Pick a slot in epoch 1. + let next_epoch_slot = Slot::new(E::slots_per_epoch() + 1); + let actual_proposer = ctx.proposer_at_slot(next_epoch_slot); + + let prefs = make_signed_preferences(next_epoch_slot, actual_proposer); + let result = GossipVerifiedProposerPreferences::new(prefs, &gossip); + // Should pass consistency checks but fail on signature (empty sig). + assert!( + matches!(result, Err(ProposerPreferencesError::BadSignature)), + "expected BadSignature for next-epoch slot, got: {:?}", + result + ); +} diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 1f55d9a878..c0aa30ffcc 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4,8 +4,6 @@ use crate::{ service::NetworkMessage, sync::SyncMessage, }; -use beacon_chain::block_verification_types::AsBlock; -use beacon_chain::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; use beacon_chain::store::Error; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChainError, BeaconChainTypes, BlockError, ForkChoiceError, @@ -24,6 +22,11 @@ use beacon_chain::{ EnvelopeError, gossip_verified_envelope::GossipVerifiedEnvelope, }, }; +use beacon_chain::{block_verification_types::AsBlock, payload_bid_verification::PayloadBidError}; +use beacon_chain::{ + data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}, + proposer_preferences_verification::ProposerPreferencesError, +}; use beacon_processor::{Work, WorkEvent}; use lighthouse_network::{Client, MessageAcceptance, MessageId, PeerAction, PeerId, ReportSource}; use logging::crit; @@ -3470,26 +3473,103 @@ impl NetworkBeaconProcessor { } } + #[instrument( + name = "lh_process_execution_payload_bid", + parent = None, + level = "debug", + skip_all, + fields(parent_block_hash = ?bid.message.parent_block_hash, parent_block_root = ?bid.message.parent_block_root), + )] pub fn process_gossip_execution_payload_bid( self: &Arc, message_id: MessageId, peer_id: PeerId, - payload_bid: SignedExecutionPayloadBid, + bid: Arc>, ) { - // TODO(EIP-7732): Implement proper payload bid gossip processing. - // This should integrate with a payload execution bid verification module once it's implemented. + let verification_result = self.chain.verify_payload_bid_for_gossip(bid.clone()); - trace!( - %peer_id, - slot = %payload_bid.message.slot, - value = %payload_bid.message.value, - "Processing execution payload bid" - ); - - // For now, ignore all payload bids since verification is not implemented - self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + match verification_result { + Ok(_) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Accept); + } + Err( + PayloadBidError::BadSignature + | PayloadBidError::InvalidBuilder { .. } + | PayloadBidError::InvalidFeeRecipient + | PayloadBidError::InvalidGasLimit + | PayloadBidError::ExecutionPaymentNonZero { .. } + | PayloadBidError::InvalidBlobKzgCommitments { .. }, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "invalid_gossip_payload_bid", + ); + } + Err( + PayloadBidError::NoProposerPreferences { .. } + | PayloadBidError::BuilderAlreadySeen { .. } + | PayloadBidError::BidValueBelowCached { .. } + | PayloadBidError::ParentBlockRootUnknown { .. } + | PayloadBidError::ParentBlockRootNotCanonical { .. } + | PayloadBidError::BuilderCantCoverBid { .. } + | PayloadBidError::BeaconStateError(_) + | PayloadBidError::InternalError(_) + | PayloadBidError::InvalidBidSlot { .. } + | PayloadBidError::UnableToReadSlot, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + } } + #[instrument( + name = "lh_process_proposer_preferences", + parent = None, + level = "debug", + skip_all, + fields(validator_index = ?proposer_preferences.message.validator_index, proposal_slot = ?proposer_preferences.message.proposal_slot), + )] + pub fn process_gossip_proposer_preferences( + self: &Arc, + message_id: MessageId, + peer_id: PeerId, + proposer_preferences: Arc, + ) { + let verification_result = self + .chain + .verify_proposer_preferences_for_gossip(proposer_preferences); + + match verification_result { + Ok(_) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Accept); + } + Err( + ProposerPreferencesError::AlreadySeen { .. } + | ProposerPreferencesError::InvalidProposalEpoch { .. } + | ProposerPreferencesError::ProposalSlotAlreadyPassed { .. } + | ProposerPreferencesError::BeaconChainError(_) + | ProposerPreferencesError::BeaconStateError(_) + | ProposerPreferencesError::UnableToReadSlot, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + Err( + ProposerPreferencesError::InvalidProposalSlot { .. } + | ProposerPreferencesError::BadSignature, + ) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "invalid_gossip_proposer_preferences", + ); + } + } + } + + // TODO(gloas) dont forget to add tracing instrumentation pub fn process_gossip_payload_attestation( self: &Arc, message_id: MessageId, @@ -3510,23 +3590,4 @@ impl NetworkBeaconProcessor { // For now, ignore all payload attestation messages since verification is not implemented self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); } - - pub fn process_gossip_proposer_preferences( - self: &Arc, - message_id: MessageId, - peer_id: PeerId, - proposer_preferences: SignedProposerPreferences, - ) { - // TODO(EIP-7732): Implement proper proposer preferences gossip processing. - - trace!( - %peer_id, - validator_index = proposer_preferences.message.validator_index, - slot = %proposer_preferences.message.proposal_slot, - "Processing proposer preferences" - ); - - // For now, ignore all proposer preferences since verification is not implemented - self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); - } } diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index b3d6874b8a..2b354aaa20 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -463,7 +463,7 @@ impl NetworkBeaconProcessor { processor.process_gossip_execution_payload_bid( message_id, peer_id, - *execution_payload_bid, + Arc::new(*execution_payload_bid), ) }; @@ -507,12 +507,12 @@ impl NetworkBeaconProcessor { processor.process_gossip_proposer_preferences( message_id, peer_id, - *proposer_preferences, + Arc::new(*proposer_preferences), ) }; self.try_send(BeaconWorkEvent { - drop_during_sync: false, + drop_during_sync: true, work: Work::GossipProposerPreferences(Box::new(process_fn)), }) } diff --git a/consensus/state_processing/src/per_block_processing.rs b/consensus/state_processing/src/per_block_processing.rs index 5aa610e98e..210e0437be 100644 --- a/consensus/state_processing/src/per_block_processing.rs +++ b/consensus/state_processing/src/per_block_processing.rs @@ -531,26 +531,6 @@ pub fn compute_timestamp_at_slot( .and_then(|since_genesis| state.genesis_time().safe_add(since_genesis)) } -pub fn can_builder_cover_bid( - state: &BeaconState, - builder_index: BuilderIndex, - builder: &Builder, - bid_amount: u64, - spec: &ChainSpec, -) -> Result { - let builder_balance = builder.balance; - let pending_withdrawals_amount = - state.get_pending_balance_to_withdraw_for_builder(builder_index)?; - let min_balance = spec - .min_deposit_amount - .safe_add(pending_withdrawals_amount)?; - if builder_balance < min_balance { - Ok(false) - } else { - Ok(builder_balance.safe_sub(min_balance)? >= bid_amount) - } -} - pub fn process_execution_payload_bid>( state: &mut BeaconState, block: BeaconBlockRef<'_, E, Payload>, @@ -579,13 +559,13 @@ pub fn process_execution_payload_bid // Verify that the builder is active block_verify!( - builder.is_active_at_finalized_epoch(state.finalized_checkpoint().epoch, spec), + state.is_active_builder(builder_index, spec)?, ExecutionPayloadBidInvalid::BuilderNotActive(builder_index).into() ); // Verify that the builder has funds to cover the bid block_verify!( - can_builder_cover_bid(state, builder_index, builder, amount, spec)?, + state.can_builder_cover_bid(builder_index, amount, spec)?, ExecutionPayloadBidInvalid::InsufficientBalance { builder_index, builder_balance: builder.balance, diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index ac64398655..f1de284fc8 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -556,8 +556,7 @@ fn process_builder_voluntary_exit( )))?; // Verify the builder is active - let finalized_epoch = state.finalized_checkpoint().epoch; - if !builder.is_active_at_finalized_epoch(finalized_epoch, spec) { + if !state.is_active_builder(builder_index, spec)? { return Err(BlockOperationError::invalid(ExitInvalid::NotActive( signed_exit.message.validator_index, ))); diff --git a/consensus/state_processing/src/per_block_processing/signature_sets.rs b/consensus/state_processing/src/per_block_processing/signature_sets.rs index 71ee1f8993..5c1767f227 100644 --- a/consensus/state_processing/src/per_block_processing/signature_sets.rs +++ b/consensus/state_processing/src/per_block_processing/signature_sets.rs @@ -12,9 +12,9 @@ use types::{ BuilderIndex, ChainSpec, DepositData, Domain, Epoch, EthSpec, Fork, Hash256, InconsistentFork, IndexedAttestation, IndexedAttestationRef, IndexedPayloadAttestation, ProposerSlashing, SignedAggregateAndProof, SignedBeaconBlock, SignedBeaconBlockHeader, - SignedBlsToExecutionChange, SignedContributionAndProof, SignedExecutionPayloadBid, SignedRoot, - SignedVoluntaryExit, SigningData, Slot, SyncAggregate, SyncAggregatorSelectionData, - consts::gloas::BUILDER_INDEX_SELF_BUILD, + SignedBlsToExecutionChange, SignedContributionAndProof, SignedExecutionPayloadBid, + SignedProposerPreferences, SignedRoot, SignedVoluntaryExit, SigningData, Slot, SyncAggregate, + SyncAggregatorSelectionData, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; pub type Result = std::result::Result; @@ -389,6 +389,37 @@ where Ok(SignatureSet::multiple_pubkeys(signature, pubkeys, message)) } +pub fn proposer_preferences_signature_set<'a, E, F>( + state: &'a BeaconState, + get_pubkey: F, + signed_proposer_preferences: &'a SignedProposerPreferences, + spec: &'a ChainSpec, +) -> Result> +where + E: EthSpec, + F: Fn(usize) -> Option>, +{ + let preferences = &signed_proposer_preferences.message; + let validator_index = preferences.validator_index as usize; + + let proposal_epoch = preferences.proposal_slot.epoch(E::slots_per_epoch()); + let proposal_fork = spec.fork_at_epoch(proposal_epoch); + let domain = spec.get_domain( + proposal_epoch, + Domain::ProposerPreferences, + &proposal_fork, + state.genesis_validators_root(), + ); + + let message = preferences.signing_root(domain); + + Ok(SignatureSet::single_pubkey( + &signed_proposer_preferences.signature, + get_pubkey(validator_index).ok_or(Error::ValidatorUnknown(validator_index as u64))?, + message, + )) +} + pub fn execution_payload_bid_signature_set<'a, E, F>( state: &'a BeaconState, get_builder_pubkey: F, @@ -407,10 +438,16 @@ where // See `process_execution_payload_bid`. return Ok(None); } + + let bid_epoch = signed_execution_payload_bid + .message + .slot + .epoch(E::slots_per_epoch()); + let bid_fork = spec.fork_at_epoch(bid_epoch); let domain = spec.get_domain( - state.current_epoch(), + bid_epoch, Domain::BeaconBuilder, - &state.fork(), + &bid_fork, state.genesis_validators_root(), ); diff --git a/consensus/types/src/builder/builder.rs b/consensus/types/src/builder/builder.rs index 7d494da3ee..2bd50f42cc 100644 --- a/consensus/types/src/builder/builder.rs +++ b/consensus/types/src/builder/builder.rs @@ -1,5 +1,5 @@ use crate::test_utils::TestRandom; -use crate::{Address, ChainSpec, Epoch, ForkName}; +use crate::{Address, Epoch, ForkName}; use bls::PublicKeyBytes; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; @@ -24,12 +24,3 @@ pub struct Builder { pub deposit_epoch: Epoch, pub withdrawable_epoch: Epoch, } - -impl Builder { - /// Check if a builder is active in a state with `finalized_epoch`. - /// - /// This implements `is_active_builder` from the spec. - pub fn is_active_at_finalized_epoch(&self, finalized_epoch: Epoch, spec: &ChainSpec) -> bool { - self.deposit_epoch < finalized_epoch && self.withdrawable_epoch == spec.far_future_epoch - } -} diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index a033272b9d..8bef8816e5 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -24,7 +24,7 @@ use tree_hash_derive::TreeHash; use typenum::Unsigned; use crate::{ - Address, ExecutionBlockHash, ExecutionPayloadBid, Withdrawal, + Address, ExecutionBlockHash, ExecutionPayloadBid, ProposerPreferences, Withdrawal, attestation::{ AttestationData, AttestationDuty, BeaconCommittee, Checkpoint, CommitteeIndex, PTC, ParticipationFlags, PendingAttestation, @@ -1349,6 +1349,43 @@ impl BeaconState { } } + /// Check if the validator is the proposer for the given slot in the current or next epoch. + pub fn is_valid_proposal_slot( + &self, + preferences: &ProposerPreferences, + ) -> Result { + let current_epoch = self.current_epoch(); + let proposal_epoch = preferences.proposal_slot.epoch(E::slots_per_epoch()); + + if proposal_epoch < current_epoch { + return Ok(false); + } + + let next_epoch = current_epoch.saturating_add(1u64); + if proposal_epoch > next_epoch { + return Ok(false); + } + + let epoch_offset = proposal_epoch.as_u64().safe_sub(current_epoch.as_u64())?; + + let slot_in_epoch = preferences + .proposal_slot + .as_u64() + .safe_rem(E::slots_per_epoch())?; + + let index = epoch_offset + .safe_mul(E::slots_per_epoch()) + .and_then(|v| v.safe_add(slot_in_epoch))?; + + let proposer_lookahead = self.proposer_lookahead()?; + + let proposer = proposer_lookahead + .get(index as usize) + .ok_or(BeaconStateError::ProposerLookaheadOutOfBounds { i: index as usize })?; + + Ok(*proposer == preferences.validator_index) + } + /// Returns the beacon proposer index for each `slot` in `epoch`. /// /// The returned `Vec` contains one proposer index for each slot in the epoch. @@ -3259,6 +3296,38 @@ impl BeaconState { Ok(effective_balance.safe_mul(MAX_RANDOM_VALUE)? >= max_effective_balance.safe_mul(random_value)?) } + + pub fn can_builder_cover_bid( + &self, + builder_index: BuilderIndex, + bid_amount: u64, + spec: &ChainSpec, + ) -> Result { + let builder = self.get_builder(builder_index)?; + + let builder_balance = builder.balance; + let pending_withdrawals_amount = + self.get_pending_balance_to_withdraw_for_builder(builder_index)?; + + let min_balance = spec + .min_deposit_amount + .safe_add(pending_withdrawals_amount)?; + if builder_balance < min_balance { + return Ok(false); + } + Ok(builder_balance.safe_sub(min_balance)? >= bid_amount) + } + + pub fn is_active_builder( + &self, + builder_index: BuilderIndex, + spec: &ChainSpec, + ) -> Result { + let builder = self.get_builder(builder_index)?; + + Ok(builder.deposit_epoch < self.finalized_checkpoint().epoch + && builder.withdrawable_epoch == spec.far_future_epoch) + } } impl ForkVersionDecode for BeaconState { From d3c13c4cf081746741af48b4189633f6cf42b844 Mon Sep 17 00:00:00 2001 From: YoungWoo Yang <119781151+0u-Y@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:41:56 +0900 Subject: [PATCH 118/189] Gloas: envelope peer penalties and REJECT/IGNORE mapping (#8981) Closes #8949 Implements peer penalties and REJECT/IGNORE message propagation for `SignedExecutionPayloadEnvelope` gossip handling, completing follow-up work from #8806. Feedback on the error classification would be appreciated. ### Key Implementation Details - Maps all 15 `EnvelopeError` variants to REJECT/IGNORE based on [Gloas p2p spec](https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/p2p-interface.md#execution_payload) - Follows `ExecutionPayloadError` handling pattern from block gossip (`penalize_peer()` method) - Uses explicit variant matching (rather than catch-all `_`) for type safety - Applies `LowToleranceError` penalty for protocol violations (invalid signatures, mismatches, etc.) - Ignores without penalty for spec-defined cases (unknown block root, prior to finalization) and internal errors Co-Authored-By: 0u-Y Co-Authored-By: Eitan Seri-Levi --- .../gossip_methods.rs | 179 +++++++++++++----- 1 file changed, 129 insertions(+), 50 deletions(-) diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index c0aa30ffcc..2238cb2f17 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -3337,63 +3337,112 @@ impl NetworkBeaconProcessor { verified_envelope } + Err(e) => { + match e { + EnvelopeError::ExecutionPayloadError(ref epe) if !epe.penalize_peer() => { + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Ignore, + ); + } - Err(EnvelopeError::BlockRootUnknown { block_root }) => { - let envelope_slot = envelope.slot(); + EnvelopeError::BadSignature + | EnvelopeError::BuilderIndexMismatch { .. } + | EnvelopeError::SlotMismatch { .. } + | EnvelopeError::BlockHashMismatch { .. } + | EnvelopeError::UnknownValidator { .. } + | EnvelopeError::IncorrectBlockProposer { .. } + | EnvelopeError::ExecutionPayloadError(_) + | EnvelopeError::EnvelopeProcessingError(_) + | EnvelopeError::BlockError(_) => { + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Reject, + ); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_envelope_low", + ); + } - debug!( - ?block_root, - %envelope_slot, - "Envelope references unknown block, deferring to reprocess queue" - ); + EnvelopeError::BlockRootUnknown { block_root } => { + let envelope_slot = envelope.slot(); - self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + debug!( + ?block_root, + %envelope_slot, + "Envelope references unknown block, deferring to reprocess queue" + ); - let inner_self = self.clone(); - let chain = self.chain.clone(); - let process_fn = Box::pin(async move { - match chain.verify_envelope_for_gossip(envelope).await { - Ok(verified_envelope) => { - inner_self - .process_gossip_verified_execution_payload_envelope( - peer_id, - verified_envelope, - ) - .await; - } - Err(e) => { - debug!( - error = ?e, - "Deferred envelope failed verification" + self.propagate_validation_result( + message_id.clone(), + peer_id, + MessageAcceptance::Ignore, + ); + + let inner_self = self.clone(); + let chain = self.chain.clone(); + let process_fn = Box::pin(async move { + match chain.verify_envelope_for_gossip(envelope).await { + Ok(verified_envelope) => { + inner_self + .process_gossip_verified_execution_payload_envelope( + peer_id, + verified_envelope, + ) + .await; + } + Err(e) => { + debug!( + error = ?e, + "Deferred envelope failed verification" + ); + } + } + }); + + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess( + ReprocessQueueMessage::UnknownBlockForEnvelope( + QueuedGossipEnvelope { + beacon_block_slot: envelope_slot, + beacon_block_root: block_root, + process_fn, + }, + ), + ), + }) + .is_err() + { + error!( + %envelope_slot, + ?block_root, + "Failed to defer envelope import" ); } } - }); - if self - .beacon_processor_send - .try_send(WorkEvent { - drop_during_sync: false, - work: Work::Reprocess(ReprocessQueueMessage::UnknownBlockForEnvelope( - QueuedGossipEnvelope { - beacon_block_slot: envelope_slot, - beacon_block_root: block_root, - process_fn, - }, - )), - }) - .is_err() - { - error!( - %envelope_slot, - ?block_root, - "Failed to defer envelope import" - ); + EnvelopeError::PriorToFinalization { .. } + | EnvelopeError::OptimisticSyncNotSupported { .. } + | EnvelopeError::BeaconChainError(_) + | EnvelopeError::BeaconStateError(_) + | EnvelopeError::BlockProcessingError(_) + | EnvelopeError::InternalError(_) => { + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Ignore, + ); + } } return None; } - // TODO(gloas) penalize peers accordingly - Err(_) => return None, }; let envelope_slot = verified_envelope.signed_envelope.slot(); @@ -3441,7 +3490,7 @@ impl NetworkBeaconProcessor { async fn process_gossip_verified_execution_payload_envelope( self: Arc, - _peer_id: PeerId, + peer_id: PeerId, verified_envelope: GossipVerifiedEnvelope, ) { let _processing_start_time = Instant::now(); @@ -3467,9 +3516,39 @@ impl NetworkBeaconProcessor { | Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => { // Nothing to do } - Err(_) => { - // TODO(gloas) implement peer penalties - } + Err(e) => match e { + EnvelopeError::ExecutionPayloadError(epe) if !epe.penalize_peer() => {} + EnvelopeError::BadSignature + | EnvelopeError::BuilderIndexMismatch { .. } + | EnvelopeError::SlotMismatch { .. } + | EnvelopeError::BlockHashMismatch { .. } + | EnvelopeError::UnknownValidator { .. } + | EnvelopeError::IncorrectBlockProposer { .. } + | EnvelopeError::ExecutionPayloadError(_) => { + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_envelope_processing_low", + ); + } + + EnvelopeError::EnvelopeProcessingError(_) + | EnvelopeError::BlockError(_) + | EnvelopeError::BlockRootUnknown { .. } => { + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_envelope_processing_error", + ); + } + + EnvelopeError::PriorToFinalization { .. } + | EnvelopeError::OptimisticSyncNotSupported { .. } + | EnvelopeError::BeaconChainError(_) + | EnvelopeError::BeaconStateError(_) + | EnvelopeError::BlockProcessingError(_) + | EnvelopeError::InternalError(_) => {} + }, } } From 30446b9f3a88679fbad14d84890f9950ceb1cd83 Mon Sep 17 00:00:00 2001 From: Mac L Date: Thu, 16 Apr 2026 03:07:54 +0300 Subject: [PATCH 119/189] Bump `rustls-webpki` to unblock CI (#9130) New audit failure from `RUSTSEC-2026-0098` Bump `rustls-webpki` to an unaffected version, add an ignore for the old version used by `warp` 0.3 Co-Authored-By: Mac L Co-Authored-By: Pawan Dhananjay --- Cargo.lock | 8 ++++---- Makefile | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 726929e9ec..329518f647 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5266,7 +5266,7 @@ dependencies = [ "rcgen", "ring", "rustls 0.23.35", - "rustls-webpki 0.103.10", + "rustls-webpki 0.103.12", "thiserror 2.0.17", "x509-parser", "yasna", @@ -7678,7 +7678,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.10", + "rustls-webpki 0.103.12", "subtle", "zeroize", ] @@ -7727,9 +7727,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "ring", "rustls-pki-types", diff --git a/Makefile b/Makefile index 599c1a8791..033ad6cfc8 100644 --- a/Makefile +++ b/Makefile @@ -331,7 +331,7 @@ install-audit: cargo install --force cargo-audit audit-CI: - cargo audit --ignore RUSTSEC-2026-0049 + cargo audit --ignore RUSTSEC-2026-0049 --ignore RUSTSEC-2026-0098 --ignore RUSTSEC-2026-0099 # Runs cargo deny (check for banned crates, duplicate versions, and source restrictions) deny: install-deny deny-CI From e0922badbef399f6f82ec3a5343d3b39393a30e4 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Thu, 16 Apr 2026 10:07:58 +1000 Subject: [PATCH 120/189] Prefix VC root spans with `lh_` so they get exported to tracing backend (#9129) The tracing exporter uses a `PrefixBasedSampler` that only samples root spans whose name starts with `lh_`. Rename the VC root spans to include the prefix so their traces are exported. Thanks @lmnzx for pointing this out! Co-Authored-By: Jimmy Chen --- .../validator_services/src/attestation_service.rs | 4 ++-- validator_client/validator_services/src/block_service.rs | 2 +- .../validator_services/src/sync_committee_service.rs | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/validator_client/validator_services/src/attestation_service.rs b/validator_client/validator_services/src/attestation_service.rs index fe808efd88..dc5fc27a4f 100644 --- a/validator_client/validator_services/src/attestation_service.rs +++ b/validator_client/validator_services/src/attestation_service.rs @@ -439,7 +439,7 @@ impl AttestationService AttestationService BlockService { } #[instrument( - name = "block_proposal_duty_cycle", + name = "lh_block_proposal_duty_cycle", skip_all, fields(%slot, ?validator_pubkey) )] diff --git a/validator_client/validator_services/src/sync_committee_service.rs b/validator_client/validator_services/src/sync_committee_service.rs index 26ce052ea0..e34e7636dd 100644 --- a/validator_client/validator_services/src/sync_committee_service.rs +++ b/validator_client/validator_services/src/sync_committee_service.rs @@ -214,7 +214,7 @@ impl SyncCommitteeService SyncCommitteeService SyncCommitteeService Date: Thu, 16 Apr 2026 01:19:57 -0500 Subject: [PATCH 121/189] Add mixed V17/V29 execution payload invalidation test (#9089) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- .../gloas_payload.rs | 76 +++++++++++++++++++ consensus/proto_array/src/proto_array.rs | 1 - 2 files changed, 76 insertions(+), 1 deletion(-) 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 ea37780795..2e792028e5 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 @@ -890,4 +890,80 @@ mod tests { let test = get_gloas_payload_received_interleaving_test_definition(); test.run(); } + + /// Test that execution payload invalidation propagates across the V17→V29 fork + /// boundary: after invalidating a V17 parent, head must not select any descendant. + /// + /// genesis(V17) -> block_1(V17, slot 31) -> block_2(V29, slot 32) + #[test] + fn mixed_v17_v29_invalidation() { + let balances = vec![1]; + let mut ops = vec![]; + + // V17 block at slot 31 (pre-Gloas). + 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, + }); + + // V29 block at slot 32 (first Gloas slot), child of block 1. + 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)), + }); + + // Vote for block 2 (V29) so both blocks have weight. + ops.push(Operation::ProcessAttestation { + validator_index: 0, + block_root: get_root(2), + attestation_slot: Slot::new(32), + }); + + // FindHead triggers apply_score_changes which materializes the vote. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: balances.clone(), + expected_head: get_root(2), + current_slot: Slot::new(32), + expected_payload_status: None, + }); + + // Invalidate block 1 (V17). filter_block_tree excludes the entire branch. + ops.push(Operation::InvalidatePayload { + head_block_root: get_root(1), + latest_valid_ancestor_root: Some(get_hash(0)), + }); + + // Head falls back to genesis — the invalid branch is no longer selectable. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: balances.clone(), + expected_head: get_root(0), + current_slot: Slot::new(32), + expected_payload_status: 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 1f7291b260..4946631f73 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -864,7 +864,6 @@ impl ProtoArray { /// Invalidate zero or more blocks, as specified by the `InvalidationOperation`. /// /// See the documentation of `InvalidationOperation` for usage. - // TODO(gloas): this needs some tests for the mixed Gloas/pre-Gloas case. pub fn propagate_execution_payload_invalidation( &mut self, op: &InvalidationOperation, From 794718e96b46cfc67c5d653a30e9b2caecd19519 Mon Sep 17 00:00:00 2001 From: Shane K Moore <41407272+shane-moore@users.noreply.github.com> Date: Thu, 16 Apr 2026 03:23:18 -0700 Subject: [PATCH 122/189] Gloas vc ptc duty (#8338) Co-Authored-By: shane-moore Co-Authored-By: Eitan Seri- Levi --- beacon_node/http_api/tests/tests.rs | 1 + common/eth2/src/lib.rs | 30 ++ common/eth2/src/types.rs | 8 + testing/simulator/src/checks.rs | 2 + validator_client/http_metrics/src/lib.rs | 10 + validator_client/validator_metrics/src/lib.rs | 12 + .../validator_services/src/duties_service.rs | 314 +++++++++++++++++- .../src/notifier_service.rs | 3 + 8 files changed, 379 insertions(+), 1 deletion(-) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index b28816302c..60e65e0049 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -3473,6 +3473,7 @@ impl ApiTester { self } + // TODO(EIP-7732): Add test_get_validator_duties_ptc function to test PTC duties endpoint pub async fn test_get_validator_duties_proposer_v2(self) -> Self { let current_epoch = self.chain.epoch().unwrap(); diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index d5140a3878..87b4125c0e 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -73,6 +73,8 @@ const HTTP_PROPOSER_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_SYNC_COMMITTEE_CONTRIBUTION_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_SYNC_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_SYNC_AGGREGATOR_TIMEOUT_QUOTIENT: u32 = 24; // For DVT involving middleware only +// TODO(EIP-7732): Determine what this quotient should be +const HTTP_PTC_DUTIES_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT: u32 = 4; const HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT: u32 = 4; @@ -93,6 +95,7 @@ pub struct Timeouts { pub sync_committee_contribution: Duration, pub sync_duties: Duration, pub sync_aggregators: Duration, + pub ptc_duties: Duration, pub get_beacon_blocks_ssz: Duration, pub get_debug_beacon_states: Duration, pub get_deposit_snapshot: Duration, @@ -113,6 +116,7 @@ impl Timeouts { sync_committee_contribution: timeout, sync_duties: timeout, sync_aggregators: timeout, + ptc_duties: timeout, get_beacon_blocks_ssz: timeout, get_debug_beacon_states: timeout, get_deposit_snapshot: timeout, @@ -135,6 +139,7 @@ impl Timeouts { / HTTP_SYNC_COMMITTEE_CONTRIBUTION_TIMEOUT_QUOTIENT, sync_duties: base_timeout / HTTP_SYNC_DUTIES_TIMEOUT_QUOTIENT, sync_aggregators: base_timeout / HTTP_SYNC_AGGREGATOR_TIMEOUT_QUOTIENT, + ptc_duties: base_timeout / HTTP_PTC_DUTIES_TIMEOUT_QUOTIENT, get_beacon_blocks_ssz: base_timeout / HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT, get_debug_beacon_states: base_timeout / HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT, get_deposit_snapshot: base_timeout / HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT, @@ -3274,4 +3279,29 @@ impl BeaconNodeHttpClient { self.post_with_timeout_and_response(path, &selections, self.timeouts.sync_aggregators) .await } + + // TODO(EIP-7732): Create corresponding beacon node response endpoint per spec + // https://github.com/ethereum/beacon-APIs/pull/552 + /// `POST validator/duties/ptc/{epoch}` + pub async fn post_validator_duties_ptc( + &self, + epoch: Epoch, + indices: &[u64], + ) -> Result>, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("duties") + .push("ptc") + .push(&epoch.to_string()); + + self.post_with_timeout_and_response( + path, + &ValidatorIndexDataRef(indices), + self.timeouts.ptc_duties, + ) + .await + } } diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index e85565c580..dd16f46c55 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -778,6 +778,14 @@ pub enum GraffitiPolicy { AppendClientVersions, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PtcDuty { + pub pubkey: PublicKeyBytes, + #[serde(with = "serde_utils::quoted_u64")] + pub validator_index: u64, + pub slot: Slot, +} + #[derive(Clone, Deserialize)] pub struct ValidatorBlocksQuery { pub randao_reveal: SignatureBytes, diff --git a/testing/simulator/src/checks.rs b/testing/simulator/src/checks.rs index de202e5812..a2e9ae96b2 100644 --- a/testing/simulator/src/checks.rs +++ b/testing/simulator/src/checks.rs @@ -220,6 +220,8 @@ pub async fn verify_full_sync_aggregates_up_to( Ok(()) } +// TODO(EIP-7732): Add verify_ptc_duties_executed function to verify that PTC duties are being fetched and executed correctly when Gloas fork is enabled + /// Verify that the first merged PoS block got finalized. pub async fn verify_transition_block_finalized( network: LocalNetwork, diff --git a/validator_client/http_metrics/src/lib.rs b/validator_client/http_metrics/src/lib.rs index 70b447a493..a6624b4f44 100644 --- a/validator_client/http_metrics/src/lib.rs +++ b/validator_client/http_metrics/src/lib.rs @@ -197,6 +197,16 @@ pub fn gather_prometheus_metrics( &[NEXT_EPOCH], duties_service.attester_count(next_epoch) as i64, ); + set_int_gauge( + &PTC_COUNT, + &[CURRENT_EPOCH], + duties_service.ptc_count(current_epoch) as i64, + ); + set_int_gauge( + &PTC_COUNT, + &[NEXT_EPOCH], + duties_service.ptc_count(next_epoch) as i64, + ); } } diff --git a/validator_client/validator_metrics/src/lib.rs b/validator_client/validator_metrics/src/lib.rs index 060d8a4edd..46a86381f9 100644 --- a/validator_client/validator_metrics/src/lib.rs +++ b/validator_client/validator_metrics/src/lib.rs @@ -22,7 +22,12 @@ pub const UPDATE_ATTESTERS_CURRENT_EPOCH: &str = "update_attesters_current_epoch pub const UPDATE_ATTESTERS_NEXT_EPOCH: &str = "update_attesters_next_epoch"; pub const UPDATE_ATTESTERS_FETCH: &str = "update_attesters_fetch"; pub const UPDATE_ATTESTERS_STORE: &str = "update_attesters_store"; +pub const UPDATE_PTC_CURRENT_EPOCH: &str = "update_ptc_current_epoch"; +pub const UPDATE_PTC_NEXT_EPOCH: &str = "update_ptc_next_epoch"; +pub const UPDATE_PTC_FETCH: &str = "update_ptc_fetch"; +pub const UPDATE_PTC_STORE: &str = "update_ptc_store"; pub const ATTESTER_DUTIES_HTTP_POST: &str = "attester_duties_http_post"; +pub const PTC_DUTIES_HTTP_POST: &str = "ptc_duties_http_post"; pub const PROPOSER_DUTIES_HTTP_GET: &str = "proposer_duties_http_get"; pub const VALIDATOR_DUTIES_SYNC_HTTP_POST: &str = "validator_duties_sync_http_post"; pub const VALIDATOR_ID_HTTP_GET: &str = "validator_id_http_get"; @@ -162,6 +167,13 @@ pub static ATTESTER_COUNT: LazyLock> = LazyLock::new(|| { &["task"], ) }); +pub static PTC_COUNT: LazyLock> = LazyLock::new(|| { + try_create_int_gauge_vec( + "vc_beacon_ptc_count", + "Number of PTC (Payload Timeliness Committee) validators on this host", + &["task"], + ) +}); pub static PROPOSAL_CHANGED: LazyLock> = LazyLock::new(|| { try_create_int_counter( "vc_beacon_block_proposal_changed", diff --git a/validator_client/validator_services/src/duties_service.rs b/validator_client/validator_services/src/duties_service.rs index f467db92a1..9f51694f34 100644 --- a/validator_client/validator_services/src/duties_service.rs +++ b/validator_client/validator_services/src/duties_service.rs @@ -13,7 +13,7 @@ use beacon_node_fallback::{ApiTopic, BeaconNodeFallback}; use bls::PublicKeyBytes; use eth2::types::{ AttesterData, BeaconCommitteeSelection, BeaconCommitteeSubscription, DutiesResponse, - ProposerData, StateId, ValidatorId, + ProposerData, PtcDuty, StateId, ValidatorId, }; use futures::{ StreamExt, @@ -46,6 +46,7 @@ const VALIDATOR_METRICS_MIN_COUNT: usize = 64; /// The initial request is used to determine if further requests are required, so that it /// reduces the amount of data that needs to be transferred. const INITIAL_DUTIES_QUERY_SIZE: usize = 1; +const INITIAL_PTC_DUTIES_QUERY_SIZE: usize = 1; /// Offsets from the attestation duty slot at which a subscription should be sent. const ATTESTATION_SUBSCRIPTION_OFFSETS: [u64; 8] = [3, 4, 5, 6, 7, 8, 16, 32]; @@ -83,6 +84,7 @@ const _: () = assert!(ATTESTATION_SUBSCRIPTION_OFFSETS[0] > MIN_ATTESTATION_SUBS pub enum Error { UnableToReadSlotClock, FailedToDownloadAttesters(#[allow(dead_code)] String), + FailedToDownloadPtc(#[allow(dead_code)] String), FailedToProduceSelectionProof(#[allow(dead_code)] ValidatorStoreError), InvalidModulo(#[allow(dead_code)] ArithError), Arith(#[allow(dead_code)] ArithError), @@ -283,6 +285,7 @@ type DependentRoot = Hash256; type AttesterMap = HashMap>; type ProposerMap = HashMap)>; +type PtcMap = HashMap)>; pub struct DutiesServiceBuilder { /// Provides the canonical list of locally-managed validators. @@ -384,6 +387,7 @@ impl DutiesServiceBuilder { attesters: Default::default(), proposers: Default::default(), sync_duties: SyncDutiesMap::new(self.sync_selection_proof_config), + ptc_duties: Default::default(), validator_store: self .validator_store .ok_or("Cannot build DutiesService without validator_store")?, @@ -414,6 +418,8 @@ pub struct DutiesService { pub proposers: RwLock, /// Map from validator index to sync committee duties. pub sync_duties: SyncDutiesMap, + /// Maps an epoch to PTC duties for locally-managed validators. + pub ptc_duties: RwLock, /// Provides the canonical list of locally-managed validators. pub validator_store: Arc, /// Maps unknown validator pubkeys to the next slot time when a poll should be conducted again. @@ -472,6 +478,15 @@ impl DutiesService { .count() } + /// Returns the total number of validators that have PTC duties in the given epoch. + pub fn ptc_count(&self, epoch: Epoch) -> usize { + self.ptc_duties + .read() + .get(&epoch) + .map(|(_, duties)| duties.len()) + .unwrap_or(0) + } + /// Returns the total number of validators that are in a doppelganger detection period. pub fn doppelganger_detecting_count(&self) -> usize { self.validator_store @@ -534,6 +549,25 @@ impl DutiesService { self.enable_high_validator_count_metrics || self.total_validator_count() <= VALIDATOR_METRICS_MIN_COUNT } + + /// Get PTC duties for a specific slot. + /// + /// Returns duties for local validators who have PTC assignments at the given slot. + pub fn get_ptc_duties_for_slot(&self, slot: Slot) -> Vec { + let epoch = slot.epoch(S::E::slots_per_epoch()); + + self.ptc_duties + .read() + .get(&epoch) + .map(|(_, ptc_duties)| { + ptc_duties + .iter() + .filter(|ptc_duty| ptc_duty.slot == slot) + .cloned() + .collect() + }) + .unwrap_or_default() + } } /// Start the service that periodically polls the beacon node for validator duties. This will start @@ -662,6 +696,61 @@ pub fn start_update_service }, "duties_service_sync_committee", ); + + // Spawn the task which keeps track of local PTC duties. + // Only start PTC duties service if Gloas fork is scheduled. + if core_duties_service.spec.is_gloas_scheduled() { + let duties_service = core_duties_service.clone(); + core_duties_service.executor.spawn( + async move { + loop { + // Check if we've reached the Gloas fork epoch before polling + let Some(current_slot) = duties_service.slot_clock.now() else { + // Unable to read slot clock, sleep and try again + sleep(duties_service.slot_clock.slot_duration()).await; + continue; + }; + + let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); + let Some(gloas_fork_epoch) = duties_service.spec.gloas_fork_epoch else { + // Gloas fork epoch not configured, should not reach here + break; + }; + + if current_epoch + 1 < gloas_fork_epoch { + // Wait until the next slot and check again + if let Some(duration) = duties_service.slot_clock.duration_to_next_slot() { + sleep(duration).await; + } else { + sleep(duties_service.slot_clock.slot_duration()).await; + } + continue; + } + + if let Err(e) = poll_beacon_ptc_attesters(&duties_service).await { + error!( + error = ?e, + "Failed to poll PTC duties" + ); + } + + // Wait until the next slot before polling again. + // This doesn't mean that the beacon node will get polled every slot + // as the PTC duties service will return early if it deems it already has + // enough information. + if let Some(duration) = duties_service.slot_clock.duration_to_next_slot() { + sleep(duration).await; + } else { + // Just sleep for one slot if we are unable to read the system clock, this gives + // us an opportunity for the clock to eventually come good. + sleep(duties_service.slot_clock.slot_duration()).await; + continue; + } + } + }, + "duties_service_ptc", + ); + } } /// Iterate through all the voting pubkeys in the `ValidatorStore` and attempt to learn any unknown @@ -1282,6 +1371,26 @@ fn process_duty_and_proof( } } +async fn post_validator_duties_ptc( + duties_service: &Arc>, + epoch: Epoch, + validator_indices: &[u64], +) -> Result>, Error> { + duties_service + .beacon_nodes + .first_success(|beacon_node| async move { + let _timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::PTC_DUTIES_HTTP_POST], + ); + beacon_node + .post_validator_duties_ptc(epoch, validator_indices) + .await + }) + .await + .map_err(|e| Error::FailedToDownloadPtc(e.to_string())) +} + /// Compute the attestation selection proofs for the `duties` and add them to the `attesters` map. /// /// Duties are computed in batches each slot. If a re-org is detected then the process will @@ -1641,6 +1750,209 @@ async fn poll_beacon_proposers( Ok(()) } +/// Query the beacon node for ptc duties for any known validators. +async fn poll_beacon_ptc_attesters( + duties_service: &Arc>, +) -> Result<(), Error> { + let current_epoch_timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::UPDATE_PTC_CURRENT_EPOCH], + ); + + let current_slot = duties_service + .slot_clock + .now() + .ok_or(Error::UnableToReadSlotClock)?; + let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); + + // Collect *all* pubkeys, even those undergoing doppelganger protection. + let local_pubkeys: HashSet<_> = duties_service + .validator_store + .voting_pubkeys(DoppelgangerStatus::ignored); + + let local_indices = { + let mut local_indices = Vec::with_capacity(local_pubkeys.len()); + + for &pubkey in &local_pubkeys { + if let Some(validator_index) = duties_service.validator_store.validator_index(&pubkey) { + local_indices.push(validator_index) + } + } + local_indices + }; + + // Poll for current epoch + if let Err(e) = poll_beacon_ptc_attesters_for_epoch( + duties_service, + current_epoch, + &local_indices, + &local_pubkeys, + ) + .await + { + error!( + %current_epoch, + request_epoch = %current_epoch, + err = ?e, + "Failed to download PTC duties" + ); + } + drop(current_epoch_timer); + let next_epoch_timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::UPDATE_PTC_NEXT_EPOCH], + ); + + // Poll for next epoch + let next_epoch = current_epoch + 1; + if let Err(e) = poll_beacon_ptc_attesters_for_epoch( + duties_service, + next_epoch, + &local_indices, + &local_pubkeys, + ) + .await + { + error!( + %current_epoch, + request_epoch = %next_epoch, + err = ?e, + "Failed to download PTC duties" + ); + } + drop(next_epoch_timer); + + // Prune old duties. + duties_service + .ptc_duties + .write() + .retain(|&epoch, _| epoch + HISTORICAL_DUTIES_EPOCHS >= current_epoch); + + Ok(()) +} + +/// For the given `local_indices` and `local_pubkeys`, download the PTC duties for the given `epoch` and +/// store them in `duties_service.ptc_duties` using bandwidth optimization. +async fn poll_beacon_ptc_attesters_for_epoch< + S: ValidatorStore + 'static, + T: SlotClock + 'static, +>( + duties_service: &Arc>, + epoch: Epoch, + local_indices: &[u64], + local_pubkeys: &HashSet, +) -> Result<(), Error> { + // No need to bother the BN if we don't have any validators. + if local_indices.is_empty() { + debug!( + %epoch, + "No validators, not downloading PTC duties" + ); + return Ok(()); + } + + let fetch_timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::UPDATE_PTC_FETCH], + ); + + // TODO(gloas) Unlike attester duties which use `get_uninitialized_validators` to detect + // newly-added validators, PTC duties only check dependent_root changes. Validators added + // mid-epoch won't get PTC duties until the next epoch boundary. We should probably fix this. + let initial_indices_to_request = + &local_indices[0..min(INITIAL_PTC_DUTIES_QUERY_SIZE, local_indices.len())]; + + let response = + post_validator_duties_ptc(duties_service, epoch, initial_indices_to_request).await?; + let dependent_root = response.dependent_root; + + // Check if we need to update duties for this epoch and collect validators to update. + // We update if we have no epoch data OR if the dependent_root changed. + let validators_to_update = { + // Avoid holding the read-lock for any longer than required. + let ptc_duties = duties_service.ptc_duties.read(); + let needs_update = ptc_duties.get(&epoch).is_none_or(|(prior_root, _duties)| { + // Update if dependent_root changed + *prior_root != dependent_root + }); + + if needs_update { + local_pubkeys.iter().collect::>() + } else { + Vec::new() + } + }; + + if validators_to_update.is_empty() { + // No validators have conflicting (epoch, dependent_root) values for this epoch. + return Ok(()); + } + + // Make a request for all indices that require updating which we have not already made a request for. + let indices_to_request = validators_to_update + .iter() + .filter_map(|pubkey| duties_service.validator_store.validator_index(pubkey)) + .filter(|validator_index| !initial_indices_to_request.contains(validator_index)) + .collect::>(); + + // Filter the initial duties by their relevance so that we don't hit warnings about + // overwriting duties. + let new_initial_duties = response + .data + .into_iter() + .filter(|duty| validators_to_update.contains(&&duty.pubkey)); + + let mut new_duties = if !indices_to_request.is_empty() { + post_validator_duties_ptc(duties_service, epoch, indices_to_request.as_slice()) + .await? + .data + } else { + vec![] + }; + new_duties.extend(new_initial_duties); + + drop(fetch_timer); + + let _store_timer = validator_metrics::start_timer_vec( + &validator_metrics::DUTIES_SERVICE_TIMES, + &[validator_metrics::UPDATE_PTC_STORE], + ); + + debug!( + %dependent_root, + num_new_duties = new_duties.len(), + "Downloaded PTC duties" + ); + + // Update duties - we only reach here if dependent_root changed or epoch is missing + let mut ptc_duties = duties_service.ptc_duties.write(); + + match ptc_duties.entry(epoch) { + hash_map::Entry::Occupied(mut entry) => { + // Dependent root must have changed, so we do complete replacement. + // We cannot support partial updates for the same dependent_root. + // The beacon node may return incomplete duty lists and we cannot distinguish between "no duties" and + // "duties not included in this response". We could query all local validators in each + // `post_validator_duties_ptc` call regardless of dependent_root changes, but the bandwidth + // cost is likely not justified since PTC assignments are sparse. + let (existing_root, _existing_duties) = entry.get(); + debug!( + old_root = %existing_root, + new_root = %dependent_root, + "PTC dependent root changed, replacing all duties" + ); + + *entry.get_mut() = (dependent_root, new_duties); + } + hash_map::Entry::Vacant(entry) => { + // No existing duties for this epoch + entry.insert((dependent_root, new_duties)); + } + } + + Ok(()) +} + /// Notify the block service if it should produce a block. async fn notify_block_production_service( current_slot: Slot, diff --git a/validator_client/validator_services/src/notifier_service.rs b/validator_client/validator_services/src/notifier_service.rs index a8f73490c7..e6e7a67864 100644 --- a/validator_client/validator_services/src/notifier_service.rs +++ b/validator_client/validator_services/src/notifier_service.rs @@ -109,6 +109,7 @@ pub async fn notify( let total_validators = duties_service.total_validator_count(); let proposing_validators = duties_service.proposer_count(epoch); let attesting_validators = duties_service.attester_count(epoch); + let ptc_validators = duties_service.ptc_count(epoch); let doppelganger_detecting_validators = duties_service.doppelganger_detecting_count(); if doppelganger_detecting_validators > 0 { @@ -126,6 +127,7 @@ pub async fn notify( } else if total_validators == attesting_validators { info!( current_epoch_proposers = proposing_validators, + current_epoch_ptc = ptc_validators, active_validators = attesting_validators, total_validators = total_validators, %epoch, @@ -135,6 +137,7 @@ pub async fn notify( } else if attesting_validators > 0 { info!( current_epoch_proposers = proposing_validators, + current_epoch_ptc = ptc_validators, active_validators = attesting_validators, total_validators = total_validators, %epoch, From 4cb3ffed8dc2c1aaad1350601b306fe2e1a3822c Mon Sep 17 00:00:00 2001 From: Pawan Dhananjay Date: Fri, 17 Apr 2026 05:20:20 +0530 Subject: [PATCH 123/189] Rust 1.95 lints (#9142) N/A Adds lints for rust 1.95. Mostly cosmetic. 1. .zip(a.into_iter()) -> .zip(a) . Also a few more places where into_iter is not required 2. replace sort_by with sort_by_key 3. move if statements inside match block. 4. use checked_div instead of if statements. I think this is debatable in terms of being better, happy to remove it if others also feel its unnecessary Co-Authored-By: Pawan Dhananjay --- .../beacon_chain/src/attestation_rewards.rs | 2 +- beacon_node/beacon_chain/src/test_utils.rs | 7 +- beacon_node/http_api/src/ui.rs | 28 +-- beacon_node/http_api/tests/tests.rs | 6 +- .../lighthouse_network/tests/rpc_tests.rs | 227 ++++++++---------- .../gossip_methods.rs | 4 +- .../network/src/sync/network_context.rs | 4 +- beacon_node/operation_pool/src/lib.rs | 2 +- consensus/types/src/core/chain_spec.rs | 2 +- .../generate_random_block_and_blobs.rs | 7 +- lighthouse/environment/src/lib.rs | 2 +- testing/ef_tests/src/cases/fork_choice.rs | 7 +- validator_client/http_api/src/keystores.rs | 6 +- .../lighthouse_validator_store/src/lib.rs | 2 +- .../validator_services/src/duties_service.rs | 12 +- 15 files changed, 142 insertions(+), 176 deletions(-) diff --git a/beacon_node/beacon_chain/src/attestation_rewards.rs b/beacon_node/beacon_chain/src/attestation_rewards.rs index 554cd431b3..b25dd1f154 100644 --- a/beacon_node/beacon_chain/src/attestation_rewards.rs +++ b/beacon_node/beacon_chain/src/attestation_rewards.rs @@ -320,7 +320,7 @@ impl BeaconChain { ) .into_values() .collect::>(); - ideal_rewards.sort_by(|a, b| a.effective_balance.cmp(&b.effective_balance)); + ideal_rewards.sort_by_key(|a| a.effective_balance); Ok(StandardAttestationRewards { ideal_rewards, diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 13dcf22108..1b03b6e10b 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -3694,11 +3694,8 @@ pub fn generate_rand_block_and_blobs( blobs, } = bundle; - for (index, ((blob, kzg_commitment), kzg_proof)) in blobs - .into_iter() - .zip(commitments.into_iter()) - .zip(proofs.into_iter()) - .enumerate() + for (index, ((blob, kzg_commitment), kzg_proof)) in + blobs.into_iter().zip(commitments).zip(proofs).enumerate() { blob_sidecars.push(BlobSidecar { index: index as u64, diff --git a/beacon_node/http_api/src/ui.rs b/beacon_node/http_api/src/ui.rs index 1538215a0b..75ef2c63cb 100644 --- a/beacon_node/http_api/src/ui.rs +++ b/beacon_node/http_api/src/ui.rs @@ -215,24 +215,22 @@ pub fn post_validator_monitor_metrics( drop(val_metrics); let attestations = attestation_hits + attestation_misses; - let attestation_hit_percentage: f64 = if attestations == 0 { - 0.0 - } else { - (100 * attestation_hits / attestations) as f64 - }; + let attestation_hit_percentage: f64 = (100 * attestation_hits) + .checked_div(attestations) + .map(|f| f as f64) + .unwrap_or(0.0); + let head_attestations = attestation_head_hits + attestation_head_misses; - let attestation_head_hit_percentage: f64 = if head_attestations == 0 { - 0.0 - } else { - (100 * attestation_head_hits / head_attestations) as f64 - }; + let attestation_head_hit_percentage: f64 = (100 * attestation_head_hits) + .checked_div(head_attestations) + .map(|f| f as f64) + .unwrap_or(0.0); let target_attestations = attestation_target_hits + attestation_target_misses; - let attestation_target_hit_percentage: f64 = if target_attestations == 0 { - 0.0 - } else { - (100 * attestation_target_hits / target_attestations) as f64 - }; + let attestation_target_hit_percentage: f64 = (100 * attestation_target_hits) + .checked_div(target_attestations) + .map(|f| f as f64) + .unwrap_or(0.0); let metrics = ValidatorMetrics { attestation_hits, diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 60e65e0049..99fe0567b8 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4746,7 +4746,7 @@ impl ApiTester { .beacon_state .validators() .into_iter() - .zip(fee_recipients.into_iter()) + .zip(fee_recipients) .enumerate() { let actual_fee_recipient = self @@ -4803,7 +4803,7 @@ impl ApiTester { .beacon_state .validators() .into_iter() - .zip(fee_recipients.into_iter()) + .zip(fee_recipients) .enumerate() { let actual = self @@ -4842,7 +4842,7 @@ impl ApiTester { .beacon_state .validators() .into_iter() - .zip(fee_recipients.into_iter()) + .zip(fee_recipients) .enumerate() { let actual_fee_recipient = self diff --git a/beacon_node/lighthouse_network/tests/rpc_tests.rs b/beacon_node/lighthouse_network/tests/rpc_tests.rs index d3f47c88bd..65b03189d4 100644 --- a/beacon_node/lighthouse_network/tests/rpc_tests.rs +++ b/beacon_node/lighthouse_network/tests/rpc_tests.rs @@ -139,16 +139,10 @@ fn test_tcp_status_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - debug!("Receiver Received"); - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - } + } if request_type == rpc_request => { + // send the response + debug!("Receiver Received"); + receiver.send_response(peer_id, inbound_request_id, rpc_response.clone()); } _ => {} // Ignore other events } @@ -269,34 +263,33 @@ fn test_tcp_blocks_by_range_chunked_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); - for i in 0..messages_to_send { - // Send first third of responses as base blocks, - // second as altair and third as bellatrix. - let rpc_response = if i < 2 { - rpc_response_base.clone() - } else if i < 4 { - rpc_response_altair.clone() - } else { - rpc_response_bellatrix_small.clone() - }; - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - } - // send the stream termination + } if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); + for i in 0..messages_to_send { + // Send first third of responses as base blocks, + // second as altair and third as bellatrix. + let rpc_response = if i < 2 { + rpc_response_base.clone() + } else if i < 4 { + rpc_response_altair.clone() + } else { + rpc_response_bellatrix_small.clone() + }; receiver.send_response( peer_id, inbound_request_id, - Response::BlocksByRange(None), + rpc_response.clone(), ); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::BlocksByRange(None), + ); } + _ => {} // Ignore other events } } @@ -404,26 +397,24 @@ fn test_blobs_by_range_chunked_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); - for _ in 0..messages_to_send { - // Send first third of responses as base blocks, - // second as altair and third as bellatrix. - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - } - // send the stream termination + } if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); + for _ in 0..messages_to_send { + // Send first third of responses as base blocks, + // second as altair and third as bellatrix. receiver.send_response( peer_id, inbound_request_id, - Response::BlobsByRange(None), + rpc_response.clone(), ); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::BlobsByRange(None), + ); } _ => {} // Ignore other events } @@ -512,25 +503,23 @@ fn test_tcp_blocks_by_range_over_limit() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); - for _ in 0..messages_to_send { - let rpc_response = rpc_response_bellatrix_large.clone(); - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - } - // send the stream termination + } if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); + for _ in 0..messages_to_send { + let rpc_response = rpc_response_bellatrix_large.clone(); receiver.send_response( peer_id, inbound_request_id, - Response::BlocksByRange(None), + rpc_response.clone(), ); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::BlocksByRange(None), + ); } _ => {} // Ignore other events } @@ -650,12 +639,10 @@ fn test_tcp_blocks_by_range_chunked_rpc_terminates_correctly() { request_type, }, _, - )) => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); - message_info = Some((peer_id, inbound_request_id)); - } + )) if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); + message_info = Some((peer_id, inbound_request_id)); } futures::future::Either::Right((_, _)) => {} // The timeout hit, send messages if required _ => continue, @@ -770,25 +757,23 @@ fn test_tcp_blocks_by_range_single_empty_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); + } if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); - for _ in 1..=messages_to_send { - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - } - // send the stream termination + for _ in 1..=messages_to_send { receiver.send_response( peer_id, inbound_request_id, - Response::BlocksByRange(None), + rpc_response.clone(), ); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::BlocksByRange(None), + ); } _ => {} // Ignore other events } @@ -917,31 +902,29 @@ fn test_tcp_blocks_by_root_chunked_rpc() { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - debug!("Receiver got request"); + } if request_type == rpc_request => { + // send the response + debug!("Receiver got request"); - for i in 0..messages_to_send { - // Send equal base, altair and bellatrix blocks - let rpc_response = if i < 2 { - rpc_response_base.clone() - } else if i < 4 { - rpc_response_altair.clone() - } else { - rpc_response_bellatrix_small.clone() - }; - receiver.send_response(peer_id, inbound_request_id, rpc_response); - debug!("Sending message"); - } - // send the stream termination - receiver.send_response( - peer_id, - inbound_request_id, - Response::BlocksByRange(None), - ); - debug!("Send stream term"); + for i in 0..messages_to_send { + // Send equal base, altair and bellatrix blocks + let rpc_response = if i < 2 { + rpc_response_base.clone() + } else if i < 4 { + rpc_response_altair.clone() + } else { + rpc_response_bellatrix_small.clone() + }; + receiver.send_response(peer_id, inbound_request_id, rpc_response); + debug!("Sending message"); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::BlocksByRange(None), + ); + debug!("Send stream term"); } _ => {} // Ignore other events } @@ -1099,27 +1082,25 @@ fn test_tcp_columns_by_root_chunked_rpc_for_fork(fork_name: ForkName) { peer_id, inbound_request_id, request_type, - } => { - if request_type == rpc_request { - // send the response - info!("Receiver got request"); + } if request_type == rpc_request => { + // send the response + info!("Receiver got request"); - for _ in 0..messages_to_send { - receiver.send_response( - peer_id, - inbound_request_id, - rpc_response.clone(), - ); - info!("Sending message"); - } - // send the stream termination + for _ in 0..messages_to_send { receiver.send_response( peer_id, inbound_request_id, - Response::DataColumnsByRoot(None), + rpc_response.clone(), ); - info!("Send stream term"); + info!("Sending message"); } + // send the stream termination + receiver.send_response( + peer_id, + inbound_request_id, + Response::DataColumnsByRoot(None), + ); + info!("Send stream term"); } e => { info!(?e, "Got event"); @@ -1425,12 +1406,10 @@ fn test_tcp_blocks_by_root_chunked_rpc_terminates_correctly() { request_type, }, _, - )) => { - if request_type == rpc_request { - // send the response - warn!("Receiver got request"); - message_info = Some((peer_id, inbound_request_id)); - } + )) if request_type == rpc_request => { + // send the response + warn!("Receiver got request"); + message_info = Some((peer_id, inbound_request_id)); } futures::future::Either::Right((_, _)) => {} // The timeout hit, send messages if required _ => continue, diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 2238cb2f17..2fe5aec347 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -289,7 +289,7 @@ impl NetworkBeaconProcessor { }) .collect::>(); - for (result, package) in results.into_iter().zip(packages.into_iter()) { + for (result, package) in results.into_iter().zip(packages) { let result = match result { Ok((indexed_attestation, attestation)) => Ok(VerifiedUnaggregate { indexed_attestation, @@ -495,7 +495,7 @@ impl NetworkBeaconProcessor { .map(|result| result.map(|verified| verified.into_indexed_attestation())) .collect::>(); - for (result, package) in results.into_iter().zip(packages.into_iter()) { + for (result, package) in results.into_iter().zip(packages) { let result = match result { Ok(indexed_attestation) => Ok(VerifiedAggregate { indexed_attestation, diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index ff630bb470..b1ba87c75d 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -1702,8 +1702,8 @@ impl SyncNetworkContext { }; let result = columns_by_range_peers_to_request - .iter() - .filter_map(|(peer_id, _)| { + .keys() + .filter_map(|peer_id| { self.send_data_columns_by_range_request( *peer_id, request.clone(), diff --git a/beacon_node/operation_pool/src/lib.rs b/beacon_node/operation_pool/src/lib.rs index b3bd091691..4b815704d9 100644 --- a/beacon_node/operation_pool/src/lib.rs +++ b/beacon_node/operation_pool/src/lib.rs @@ -1148,7 +1148,7 @@ mod release_tests { }) .collect::>(); - for att in aggs1.into_iter().chain(aggs2.into_iter()) { + for att in aggs1.into_iter().chain(aggs2) { let attesting_indices = get_attesting_indices_from_state(&state, att.to_ref()).unwrap(); op_pool.insert_attestation(att, attesting_indices).unwrap(); diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index d06e5083c8..516ca2288e 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -1789,7 +1789,7 @@ impl<'de> Deserialize<'de> for BlobSchedule { impl BlobSchedule { pub fn new(mut vec: Vec) -> Self { // reverse sort by epoch - vec.sort_by(|a, b| b.epoch.cmp(&a.epoch)); + vec.sort_by_key(|b| std::cmp::Reverse(b.epoch)); Self { schedule: vec, skip_serializing: false, diff --git a/consensus/types/src/test_utils/generate_random_block_and_blobs.rs b/consensus/types/src/test_utils/generate_random_block_and_blobs.rs index cf7b5df891..4e875341a0 100644 --- a/consensus/types/src/test_utils/generate_random_block_and_blobs.rs +++ b/consensus/types/src/test_utils/generate_random_block_and_blobs.rs @@ -34,11 +34,8 @@ pub fn generate_rand_block_and_blobs( .blob_kzg_commitments_mut() .expect("kzg commitment expected from Deneb") = commitments.clone(); - for (index, ((blob, kzg_commitment), kzg_proof)) in blobs - .into_iter() - .zip(commitments.into_iter()) - .zip(proofs.into_iter()) - .enumerate() + for (index, ((blob, kzg_commitment), kzg_proof)) in + blobs.into_iter().zip(commitments).zip(proofs).enumerate() { blob_sidecars.push(BlobSidecar { index: index as u64, diff --git a/lighthouse/environment/src/lib.rs b/lighthouse/environment/src/lib.rs index 6694c673ed..1431b03f45 100644 --- a/lighthouse/environment/src/lib.rs +++ b/lighthouse/environment/src/lib.rs @@ -388,7 +388,7 @@ impl Environment { Err(e) => error!(error = ?e, "Could not register SIGHUP handler"), } - future::select(inner_shutdown, future::select_all(handles.into_iter())).await + future::select(inner_shutdown, future::select_all(handles)).await }; match self.runtime().block_on(register_handlers) { diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 06f204ab01..5e9dc001c7 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -660,11 +660,8 @@ impl Tester { // Zipping will stop when any of the zipped lists runs out, which is what we want. Some // of the tests don't provide enough proofs/blobs, and should fail the availability // check. - for (i, ((blob, kzg_proof), kzg_commitment)) in blobs - .into_iter() - .zip(proofs) - .zip(commitments.into_iter()) - .enumerate() + for (i, ((blob, kzg_proof), kzg_commitment)) in + blobs.into_iter().zip(proofs).zip(commitments).enumerate() { let blob_sidecar = Arc::new(BlobSidecar { index: i as u64, diff --git a/validator_client/http_api/src/keystores.rs b/validator_client/http_api/src/keystores.rs index 18accf0d5a..9004bcbd62 100644 --- a/validator_client/http_api/src/keystores.rs +++ b/validator_client/http_api/src/keystores.rs @@ -102,10 +102,8 @@ pub fn import( // Import each keystore. Some keystores may fail to be imported, so we record a status for each. let mut statuses = Vec::with_capacity(request.keystores.len()); - for (KeystoreJsonStr(keystore), password) in request - .keystores - .into_iter() - .zip(request.passwords.into_iter()) + for (KeystoreJsonStr(keystore), password) in + request.keystores.into_iter().zip(request.passwords) { let pubkey_str = keystore.pubkey().to_string(); diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index e8c1cfbc43..76f7a86aab 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -1030,7 +1030,7 @@ impl ValidatorStore for LighthouseValidatorS // Collect successfully signed attestations and log errors. let mut signed_attestations = Vec::with_capacity(attestations.len()); - for (result, att) in results.into_iter().zip(attestations.into_iter()) { + for (result, att) in results.into_iter().zip(attestations) { match result { Ok(()) => { signed_attestations.push(( diff --git a/validator_client/validator_services/src/duties_service.rs b/validator_client/validator_services/src/duties_service.rs index 9f51694f34..2a371abf62 100644 --- a/validator_client/validator_services/src/duties_service.rs +++ b/validator_client/validator_services/src/duties_service.rs @@ -471,8 +471,8 @@ impl DutiesService { .voting_pubkeys(DoppelgangerStatus::only_safe); self.attesters .read() - .iter() - .filter_map(|(_, map)| map.get(&epoch)) + .values() + .filter_map(|map| map.get(&epoch)) .map(|(_, duty_and_proof)| duty_and_proof) .filter(|duty_and_proof| signing_pubkeys.contains(&duty_and_proof.duty.pubkey)) .count() @@ -533,8 +533,8 @@ impl DutiesService { self.attesters .read() - .iter() - .filter_map(|(_, map)| map.get(&epoch)) + .values() + .filter_map(|map| map.get(&epoch)) .map(|(_, duty_and_proof)| duty_and_proof) .filter(|duty_and_proof| { duty_and_proof.duty.slot == slot @@ -983,8 +983,8 @@ async fn poll_beacon_attesters Date: Fri, 17 Apr 2026 07:01:25 -0700 Subject: [PATCH 124/189] Gloas - add get_payload_attestation_endpoint (#8497) Co-Authored-By: shane-moore Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Jimmy Chen --- beacon_node/beacon_chain/src/beacon_chain.rs | 44 ++++++++ beacon_node/beacon_chain/src/errors.rs | 1 + beacon_node/beacon_chain/src/metrics.rs | 11 ++ beacon_node/http_api/src/lib.rs | 9 ++ beacon_node/http_api/src/validator/mod.rs | 100 +++++++++++++++++++ beacon_node/http_api/tests/tests.rs | 74 +++++++++++++- common/eth2/src/lib.rs | 45 +++++++++ 7 files changed, 283 insertions(+), 1 deletion(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index acf7ad9c4c..4e4ff341fe 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -2097,6 +2097,50 @@ impl BeaconChain { )?) } + /// Produce a `PayloadAttestationData` for a PTC validator to sign. + /// + /// This is used by PTC (Payload Timeliness Committee) validators to attest to the + /// presence/absence of an execution payload and blobs for a given slot. + pub fn produce_payload_attestation_data( + &self, + request_slot: Slot, + ) -> Result { + let _timer = metrics::start_timer(&metrics::PAYLOAD_ATTESTATION_PRODUCTION_SECONDS); + + // Payload attestations are only valid for the current slot + let current_slot = self.slot()?; + if request_slot != current_slot { + return Err(Error::InvalidSlot(request_slot)); + } + + // Check if we've seen a block for this slot from the canonical head + let head = self.head_snapshot(); + if head.beacon_block.slot() != request_slot { + return Err(Error::NoBlockForSlot(request_slot)); + } + + let beacon_block_root = head.beacon_block_root; + + // TODO(gloas) do we want to use a dedicated envelope cache instead? + // Maybe the new gloas DA cache? (Or should the gloas DA cache use + // the envelopes_times_cache internally?) + let payload_present = self + .envelope_times_cache + .read() + .cache + .contains_key(&beacon_block_root); + + // TODO(EIP-7732): Check blob data availability. For now, default to true. + let blob_data_available = true; + + Ok(PayloadAttestationData { + beacon_block_root, + slot: head.beacon_block.slot(), + payload_present, + blob_data_available, + }) + } + /// Performs the same validation as `Self::verify_unaggregated_attestation_for_gossip`, but for /// multiple attestations using batch BLS verification. Batch verification can provide /// significant CPU-time savings compared to individual verification. diff --git a/beacon_node/beacon_chain/src/errors.rs b/beacon_node/beacon_chain/src/errors.rs index 210c4a4482..d5ff12e33b 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -54,6 +54,7 @@ pub enum BeaconChainError { }, SlotClockDidNotStart, NoStateForSlot(Slot), + NoBlockForSlot(Slot), BeaconStateError(BeaconStateError), EpochCacheError(EpochCacheError), DBInconsistent(String), diff --git a/beacon_node/beacon_chain/src/metrics.rs b/beacon_node/beacon_chain/src/metrics.rs index 786daa09da..5485f0a9e3 100644 --- a/beacon_node/beacon_chain/src/metrics.rs +++ b/beacon_node/beacon_chain/src/metrics.rs @@ -511,6 +511,17 @@ pub static ATTESTATION_PRODUCTION_HEAD_SCRAPE_SECONDS: LazyLock> = + LazyLock::new(|| { + try_create_histogram( + "beacon_payload_attestation_production_seconds", + "Full runtime of payload attestation production", + ) + }); + /* * Fork Choice */ diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 0bb04888b7..0be631c057 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -2536,6 +2536,14 @@ pub fn serve( task_spawner_filter.clone(), ); + // GET validator/payload_attestation_data/{slot} + let get_validator_payload_attestation_data = get_validator_payload_attestation_data( + eth_v1.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + // GET validator/aggregate_attestation?attestation_data_root,slot let get_validator_aggregate_attestation = get_validator_aggregate_attestation( any_version.clone(), @@ -3347,6 +3355,7 @@ pub fn serve( .uor(get_validator_blinded_blocks) .uor(get_validator_execution_payload_envelope) .uor(get_validator_attestation_data) + .uor(get_validator_payload_attestation_data) .uor(get_validator_aggregate_attestation) .uor(get_validator_sync_committee_contribution) .uor(get_lighthouse_health) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 7533510277..7349aa4db0 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -248,6 +248,106 @@ pub fn get_validator_attestation_data( .boxed() } +// GET validator/payload_attestation_data/{slot} +pub fn get_validator_payload_attestation_data( + eth_v1: EthV1Filter, + chain_filter: ChainFilter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + use eth2::beacon_response::{EmptyMetadata, ForkVersionedResponse}; + use ssz::Encode; + use warp::http::Response; + + eth_v1 + .and(warp::path("validator")) + .and(warp::path("payload_attestation_data")) + .and(warp::path::param::().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid slot".to_string(), + )) + })) + .and(warp::path::end()) + .and(warp::header::optional::("accept")) + .and(not_while_syncing_filter) + .and(task_spawner_filter) + .and(chain_filter) + .then( + |slot: Slot, + accept_header: Option, + not_synced_filter: Result<(), Rejection>, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.blocking_response_task(Priority::P0, move || { + not_synced_filter?; + + let fork_name = chain.spec.fork_name_at_slot::(slot); + + // Payload attestations are only valid for Gloas and later forks + if !fork_name.gloas_enabled() { + return Err(warp_utils::reject::custom_bad_request(format!( + "Payload attestations are not supported for fork: {fork_name}" + ))); + } + + let payload_attestation_data = chain + .produce_payload_attestation_data(slot) + .map_err(|e| match e { + BeaconChainError::InvalidSlot(_) + | BeaconChainError::NoBlockForSlot(_) => { + warp_utils::reject::custom_bad_request(format!( + "Unable to produce payload attestation data: {e:?}" + )) + } + _ => warp_utils::reject::custom_server_error(format!( + "Unable to produce payload attestation data: {e:?}" + )), + })?; + + match accept_header { + Some(Accept::Ssz) => Response::builder() + .status(200) + .header("Content-Type", "application/octet-stream") + .header("Eth-Consensus-Version", fork_name.to_string()) + .body(payload_attestation_data.as_ssz_bytes().into()) + .map(|res: Response| res) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to build SSZ response: {e}" + )) + }), + _ => { + let json_response = ForkVersionedResponse { + version: fork_name, + metadata: EmptyMetadata {}, + data: payload_attestation_data, + }; + Response::builder() + .status(200) + .header("Content-Type", "application/json") + .header("Eth-Consensus-Version", fork_name.to_string()) + .body( + serde_json::to_string(&json_response) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to serialize response: {e}" + )) + })? + .into(), + ) + .map_err(|e| { + warp_utils::reject::custom_server_error(format!( + "Failed to build JSON response: {e}" + )) + }) + } + } + }) + }, + ) + .boxed() +} + // GET validator/blinded_blocks/{slot} pub fn get_validator_blinded_blocks( eth_v1: EthV1Filter, diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 99fe0567b8..bf8443929c 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -3,7 +3,8 @@ use beacon_chain::test_utils::RelativeSyncCommittee; use beacon_chain::{ BeaconChain, ChainConfig, StateSkipConfig, WhenSlotSkipped, test_utils::{ - AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, test_spec, + AttestationStrategy, BeaconChainHarness, BlockStrategy, EphemeralHarnessType, + fork_name_from_env, test_spec, }, }; use bls::{AggregateSignature, Keypair, PublicKeyBytes, SecretKey, Signature, SignatureBytes}; @@ -4434,6 +4435,53 @@ impl ApiTester { self } + pub async fn test_get_validator_payload_attestation_data(self) -> Self { + let slot = self.chain.slot().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::(slot); + + let response = self + .client + .get_validator_payload_attestation_data(slot) + .await + .unwrap(); + + assert_eq!(response.version(), Some(fork_name)); + + let result = response.into_data(); + let expected = self.chain.produce_payload_attestation_data(slot).unwrap(); + + assert_eq!(result.beacon_block_root, expected.beacon_block_root); + assert_eq!(result.slot, expected.slot); + assert_eq!(result.payload_present, expected.payload_present); + assert_eq!(result.blob_data_available, expected.blob_data_available); + + let ssz_result = self + .client + .get_validator_payload_attestation_data_ssz(slot) + .await + .unwrap(); + + assert_eq!(ssz_result, expected); + + self + } + + pub async fn test_get_validator_payload_attestation_data_pre_gloas(self) -> Self { + let slot = self.chain.slot().unwrap(); + + // The endpoint should return a 400 error for pre-Gloas forks + match self + .client + .get_validator_payload_attestation_data(slot) + .await + { + Ok(result) => panic!("query for pre-Gloas slot should fail, got: {result:?}"), + Err(e) => assert_eq!(e.status().unwrap(), 400), + } + + self + } + #[allow(clippy::await_holding_lock)] // This is a test, so it should be fine. pub async fn test_get_validator_aggregate_attestation_v1(self) -> Self { let attestation = self @@ -8057,6 +8105,30 @@ async fn get_validator_attestation_data_with_skip_slots() { .await; } +// TODO(EIP-7732): Remove `#[ignore]` once gloas beacon chain harness is implemented +#[ignore] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_payload_attestation_data() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new() + .await + .test_get_validator_payload_attestation_data() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_payload_attestation_data_pre_gloas() { + if fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new() + .await + .test_get_validator_payload_attestation_data_pre_gloas() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_validator_aggregate_attestation_v1() { ApiTester::new() diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 87b4125c0e..4ec75468a2 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -46,6 +46,7 @@ use ssz::{Decode, Encode}; use std::fmt; use std::future::Future; use std::time::Duration; +use types::PayloadAttestationData; pub const V1: EndpointVersion = EndpointVersion(1); pub const V2: EndpointVersion = EndpointVersion(2); @@ -79,6 +80,7 @@ const HTTP_GET_BEACON_BLOCK_SSZ_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT: u32 = 4; const HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT: u32 = 4; const HTTP_GET_VALIDATOR_BLOCK_TIMEOUT_QUOTIENT: u32 = 4; +const HTTP_PAYLOAD_ATTESTATION_TIMEOUT_QUOTIENT: u32 = 4; const HTTP_DEFAULT_TIMEOUT_QUOTIENT: u32 = 4; /// A struct to define a variety of different timeouts for different validator tasks to ensure @@ -100,6 +102,7 @@ pub struct Timeouts { pub get_debug_beacon_states: Duration, pub get_deposit_snapshot: Duration, pub get_validator_block: Duration, + pub payload_attestation: Duration, pub default: Duration, } @@ -121,6 +124,7 @@ impl Timeouts { get_debug_beacon_states: timeout, get_deposit_snapshot: timeout, get_validator_block: timeout, + payload_attestation: timeout, default: timeout, } } @@ -144,6 +148,7 @@ impl Timeouts { get_debug_beacon_states: base_timeout / HTTP_GET_DEBUG_BEACON_STATE_QUOTIENT, get_deposit_snapshot: base_timeout / HTTP_GET_DEPOSIT_SNAPSHOT_QUOTIENT, get_validator_block: base_timeout / HTTP_GET_VALIDATOR_BLOCK_TIMEOUT_QUOTIENT, + payload_attestation: base_timeout / HTTP_PAYLOAD_ATTESTATION_TIMEOUT_QUOTIENT, default: base_timeout / HTTP_DEFAULT_TIMEOUT_QUOTIENT, } } @@ -2942,6 +2947,46 @@ impl BeaconNodeHttpClient { self.get_with_timeout(path, self.timeouts.attestation).await } + /// `GET validator/payload_attestation_data/{slot}` + pub async fn get_validator_payload_attestation_data( + &self, + slot: Slot, + ) -> Result, Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("payload_attestation_data") + .push(&slot.to_string()); + + self.get_with_timeout(path, self.timeouts.payload_attestation) + .await + .map(BeaconResponse::ForkVersioned) + } + + /// `GET validator/payload_attestation_data/{slot}` in SSZ format + pub async fn get_validator_payload_attestation_data_ssz( + &self, + slot: Slot, + ) -> Result { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("payload_attestation_data") + .push(&slot.to_string()); + + let opt_response = self + .get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.payload_attestation) + .await?; + + let response_bytes = opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND))?; + + PayloadAttestationData::from_ssz_bytes(&response_bytes).map_err(Error::InvalidSsz) + } + /// `GET v1/validator/aggregate_attestation?slot,attestation_data_root` pub async fn get_validator_aggregate_attestation_v1( &self, From 9b08e1ad630f02cb653452dd623d1ce6e245c8b5 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 20 Apr 2026 10:59:39 +1000 Subject: [PATCH 125/189] Fix total_effective_balance=0 in `PreEpochCache` (#9106) Fix a **consensus fault** in `PreEpochCache` :scream_cat: Fortunately it's only reachable on a network with `total_active_balance=0`, i.e. a network that's already completely dead. As such this PR is not time-sensitive in any way. Add the floor on `total_effective_balance` when converting from `PreEpochCache` to `EpochCache`. An alternative would be to add the floor inside `PreEpochCache::get_total_active_balance`, however that would be redundant, as the only place this function is called outside this file is in single-pass epoch processing: https://github.com/sigp/lighthouse/blob/176cce585c1ba979a6210ed79b6b6528596cdb8c/consensus/state_processing/src/per_epoch_processing/single_pass.rs#L461-L462 The `set_total_active_balance` call already handles the floor. A regression test is included. Co-Authored-By: Michael Sproul --- consensus/state_processing/src/epoch_cache.rs | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/consensus/state_processing/src/epoch_cache.rs b/consensus/state_processing/src/epoch_cache.rs index b890694a7e..92863ccdb5 100644 --- a/consensus/state_processing/src/epoch_cache.rs +++ b/consensus/state_processing/src/epoch_cache.rs @@ -74,6 +74,8 @@ impl PreEpochCache { } } + /// Note: the spec-mandated floor (max with EFFECTIVE_BALANCE_INCREMENT) is applied in + /// `into_epoch_cache` and `set_total_active_balance`. This returns the raw sum. pub fn get_total_active_balance(&self) -> u64 { self.total_active_balance } @@ -84,7 +86,12 @@ impl PreEpochCache { spec: &ChainSpec, ) -> Result { let epoch = self.epoch_key.epoch; - let total_active_balance = self.total_active_balance; + // Apply the spec-mandated floor from `get_total_balance`: + // max(EFFECTIVE_BALANCE_INCREMENT, sum(...)) + // This prevents division by zero in base reward calculation when all + // validators have zero effective balance. + let total_active_balance = + std::cmp::max(self.total_active_balance, spec.effective_balance_increment); let sqrt_total_active_balance = SqrtTotalActiveBalance::new(total_active_balance); let base_reward_per_increment = BaseRewardPerIncrement::new(total_active_balance, spec)?; @@ -176,3 +183,40 @@ pub fn initialize_epoch_cache( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use types::Epoch; + + /// Regression test for division-by-zero when all validators have zero effective balance. + /// + /// When `process_effective_balance_updates` drops all effective balances to 0, the + /// `PreEpochCache` accumulates `total_active_balance = 0`. Without the spec-mandated floor + /// of `max(EFFECTIVE_BALANCE_INCREMENT, sum)`, `BaseRewardPerIncrement::new()` would divide + /// by `integer_sqrt(0) = 0`. + #[test] + fn into_epoch_cache_zero_total_active_balance() { + let spec = ChainSpec::minimal(); + + let cache = PreEpochCache { + epoch_key: EpochCacheKey { + epoch: Epoch::new(1), + decision_block_root: Hash256::zero(), + }, + effective_balances: vec![0, 0, 0, 0], + total_active_balance: 0, + }; + + // Verify the raw total is zero. + assert_eq!(cache.get_total_active_balance(), 0); + + // This should succeed, not panic with division by zero. + let epoch_cache = cache + .into_epoch_cache(ActivationQueue::default(), &spec) + .expect("into_epoch_cache should not fail with zero total_active_balance"); + + // Base reward for validator index 0 should be 0. + assert_eq!(epoch_cache.get_base_reward(0).unwrap(), 0); + } +} From c028bac28d8e706c5a7d1492be2ce6e5fbd90ad6 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 20 Apr 2026 10:59:42 +1000 Subject: [PATCH 126/189] Fix slasher OOM (#9141) Fix a vulnerability in the slasher whereby it would OOM upon processing an invalid attestation with an artificially high `validator_index`. This fix has already been made available to affected users on the `slasher-fix` branch. - Prevent attestations from being passed to the slasher prior to signature verification. This was unnecessary, as they would later be passed on successful validation as well. - Add a defensive cap on the maximum validator index processable by the slasher. The cap is high enough that it shouldn't be reached for several years, and will quickly result in warning logs if forgotten. - Add a regression test that confirms that the issue is fixed. Co-Authored-By: Michael Sproul --- .../src/attestation_verification.rs | 10 ---- .../tests/attestation_verification.rs | 57 +++++++++++++++++++ slasher/src/attestation_queue.rs | 17 ++++++ slasher/src/slasher.rs | 5 ++ 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/beacon_node/beacon_chain/src/attestation_verification.rs b/beacon_node/beacon_chain/src/attestation_verification.rs index 667bafe445..f35de59e1f 100644 --- a/beacon_node/beacon_chain/src/attestation_verification.rs +++ b/beacon_node/beacon_chain/src/attestation_verification.rs @@ -514,11 +514,6 @@ impl<'a, T: BeaconChainTypes> IndexedAggregatedAttestation<'a, T> { chain: &BeaconChain, ) -> Result { Self::verify_slashable(signed_aggregate, chain) - .inspect(|verified_aggregate| { - if let Some(slasher) = chain.slasher.as_ref() { - slasher.accept_attestation(verified_aggregate.indexed_attestation.clone()); - } - }) .map_err(|slash_info| process_slash_info(slash_info, chain)) } @@ -971,11 +966,6 @@ impl<'a, T: BeaconChainTypes> IndexedUnaggregatedAttestation<'a, T> { chain: &BeaconChain, ) -> Result { Self::verify_slashable(attestation, subnet_id, chain) - .inspect(|verified_unaggregated| { - if let Some(slasher) = chain.slasher.as_ref() { - slasher.accept_attestation(verified_unaggregated.indexed_attestation.clone()); - } - }) .map_err(|slash_info| process_slash_info(slash_info, chain)) } diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index acf326430b..91bc8e249a 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -19,8 +19,10 @@ use execution_layer::test_utils::generate_genesis_header; use fixed_bytes::FixedBytesExtended; use genesis::{DEFAULT_ETH1_BLOCK_HASH, interop_genesis_state}; use int_to_bytes::int_to_bytes32; +use slasher::{Config as SlasherConfig, Slasher}; use state_processing::per_slot_processing; use std::sync::{Arc, LazyLock}; +use tempfile::tempdir; use tree_hash::TreeHash; use typenum::Unsigned; use types::{ @@ -1958,3 +1960,58 @@ async fn gloas_aggregated_attestation_same_slot_index_must_be_zero() { result.err() ); } + +/// Regression test: a SingleAttestation with a huge bogus attester_index must not be forwarded to +/// the slasher. Previously the slasher received the IndexedAttestation before committee-membership +/// validation, causing an OOM when the slasher tried to allocate based on the untrusted index. +#[tokio::test] +async fn unaggregated_attestation_bogus_attester_index_not_sent_to_slasher() { + let slasher_dir = tempdir().unwrap(); + let spec = Arc::new(test_spec::()); + let slasher = Arc::new( + Slasher::::open(SlasherConfig::new(slasher_dir.path().into()), spec.clone()).unwrap(), + ); + + let inner_slasher = slasher.clone(); + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .spec(spec) + .keypairs(KEYPAIRS[0..VALIDATOR_COUNT].to_vec()) + .fresh_ephemeral_store() + .initial_mutator(Box::new(move |builder| builder.slasher(inner_slasher))) + .mock_execution_layer() + .build(); + harness.advance_slot(); + harness + .extend_chain( + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + harness.advance_slot(); + + // Build a valid SingleAttestation, then replace the attester_index with a huge value. + let (mut bogus_attestation, _, _) = get_valid_unaggregated_attestation(&harness.chain); + bogus_attestation.attester_index = 1 << 40; // ~2^40, would OOM the slasher + + // Drain any attestations already queued from block production. + slasher + .process_queued(harness.get_current_slot().epoch(E::slots_per_epoch())) + .unwrap(); + let queue_len_before = slasher.attestation_queue_len(); + assert_eq!(queue_len_before, 0); + + let result = harness + .chain + .verify_unaggregated_attestation_for_gossip(&bogus_attestation, None); + assert!( + result.is_err(), + "attestation with bogus index should fail verification" + ); + + assert_eq!( + slasher.attestation_queue_len(), + 0, + "slasher queue length must not change — bogus attestation must not be forwarded" + ); +} diff --git a/slasher/src/attestation_queue.rs b/slasher/src/attestation_queue.rs index 62a1bb0945..e99a3708ad 100644 --- a/slasher/src/attestation_queue.rs +++ b/slasher/src/attestation_queue.rs @@ -2,8 +2,17 @@ use crate::{AttesterRecord, Config, IndexedAttesterRecord}; use parking_lot::Mutex; use std::collections::BTreeMap; use std::sync::{Arc, Weak}; +use tracing::warn; use types::{EthSpec, Hash256, IndexedAttestation}; +/// Hard cap on validator indices accepted by the slasher. +/// +/// Any attestation referencing a validator index above this limit is silently dropped during +/// grouping. This is a defence-in-depth measure to prevent pathological memory allocation if an +/// attestation with a bogus index somehow reaches the slasher. The value (2^23 = 8,388,608) +/// provides generous headroom above the current mainnet validator set (~2M). +const MAX_VALIDATOR_INDEX: u64 = 8_388_608; + /// Staging area for attestations received from the network. /// /// Attestations are not grouped by validator index at this stage so that they can be easily @@ -72,6 +81,14 @@ impl AttestationBatch { let mut grouped_attestations = GroupedAttestations { subqueues: vec![] }; for ((validator_index, _), indexed_record) in self.attesters { + if validator_index >= MAX_VALIDATOR_INDEX { + warn!( + validator_index, + "Dropping slasher attestation with out-of-range validator index" + ); + break; + } + let subqueue_id = config.validator_chunk_index(validator_index); if subqueue_id >= grouped_attestations.subqueues.len() { diff --git a/slasher/src/slasher.rs b/slasher/src/slasher.rs index 5d26c5a6da..8d34a34f3e 100644 --- a/slasher/src/slasher.rs +++ b/slasher/src/slasher.rs @@ -74,6 +74,11 @@ impl Slasher { &self.config } + /// Return the number of attestations in the queue. + pub fn attestation_queue_len(&self) -> usize { + self.attestation_queue.len() + } + /// Accept an attestation from the network and queue it for processing. pub fn accept_attestation(&self, attestation: IndexedAttestation) { self.attestation_queue.queue(attestation); From cf3d5e285e9109def686d24b543d5b44cb233347 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 21 Apr 2026 16:29:15 +1000 Subject: [PATCH 127/189] Gloas spec v1.7.0-alpha.5 and beacon_chain tests (#8998) Fix database pruning post-Gloas - Fix DB pruning logic (and state summaries DAG) - Get the `beacon_chain` tests running with `FORK_NAME=gloas` :tada: Co-Authored-By: Michael Sproul Co-Authored-By: Jimmy Chen Co-Authored-By: Eitan Seri- Levi Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Eitan Seri-Levi --- .github/forbidden-files.txt | 1 + Makefile | 5 +- .../beacon_chain/src/beacon_block_streamer.rs | 1 + beacon_node/beacon_chain/src/beacon_chain.rs | 68 +-- .../beacon_chain/src/beacon_snapshot.rs | 9 +- .../beacon_chain/src/blob_verification.rs | 8 +- .../src/block_production/gloas.rs | 109 +++- .../beacon_chain/src/block_production/mod.rs | 81 +-- .../beacon_chain/src/block_verification.rs | 43 +- beacon_node/beacon_chain/src/builder.rs | 46 +- .../beacon_chain/src/canonical_head.rs | 97 ++-- .../src/data_column_verification.rs | 9 +- beacon_node/beacon_chain/src/errors.rs | 6 +- .../beacon_chain/src/execution_payload.rs | 8 + beacon_node/beacon_chain/src/migrate.rs | 2 +- .../src/payload_bid_verification/tests.rs | 9 +- .../src/payload_envelope_streamer/tests.rs | 3 +- .../execution_pending_envelope.rs | 18 +- .../gossip_verified_envelope.rs | 15 +- .../payload_envelope_verification/import.rs | 11 +- .../src/payload_envelope_verification/mod.rs | 10 +- .../src/pending_payload_envelopes.rs | 7 +- .../beacon_chain/src/state_advance_timer.rs | 14 +- beacon_node/beacon_chain/src/test_utils.rs | 66 ++- .../src/validator_pubkey_cache.rs | 18 +- .../tests/attestation_production.rs | 2 +- .../tests/attestation_verification.rs | 15 +- .../beacon_chain/tests/block_verification.rs | 94 +++- .../beacon_chain/tests/column_verification.rs | 7 +- beacon_node/beacon_chain/tests/events.rs | 5 +- .../tests/payload_invalidation.rs | 43 +- beacon_node/beacon_chain/tests/rewards.rs | 3 +- beacon_node/beacon_chain/tests/store_tests.rs | 467 ++++++++---------- beacon_node/beacon_chain/tests/tests.rs | 18 +- .../beacon_chain/tests/validator_monitor.rs | 3 +- beacon_node/execution_layer/src/engine_api.rs | 64 ++- .../execution_layer/src/engine_api/http.rs | 34 ++ .../src/engine_api/json_structures.rs | 35 +- .../test_utils/execution_block_generator.rs | 5 + .../src/test_utils/handle_rpc.rs | 18 +- .../src/test_utils/mock_builder.rs | 26 +- .../src/test_utils/mock_execution_layer.rs | 30 +- .../execution_layer/src/test_utils/mod.rs | 1 + .../src/beacon/execution_payload_envelope.rs | 6 +- .../http_api/src/sync_committee_rewards.rs | 3 +- beacon_node/http_api/tests/tests.rs | 5 +- .../src/network_beacon_processor/tests.rs | 14 +- beacon_node/store/src/hot_cold_store.rs | 280 +---------- beacon_node/store/src/reconstruct.rs | 1 - beacon_node/store/src/state_cache.rs | 43 +- common/eth2/src/types.rs | 2 - consensus/fork_choice/src/fork_choice.rs | 29 +- .../gloas_payload.rs | 106 ++-- consensus/proto_array/src/proto_array.rs | 39 +- .../src/proto_array_fork_choice.rs | 43 +- .../state_processing/src/block_replayer.rs | 137 +---- .../src/envelope_processing.rs | 153 ++---- consensus/state_processing/src/genesis.rs | 40 +- .../src/per_block_processing.rs | 140 +++++- .../src/per_block_processing/errors.rs | 7 + .../src/per_block_processing/tests.rs | 2 +- .../src/per_block_processing/withdrawals.rs | 10 +- .../state_processing/src/upgrade/gloas.rs | 5 +- consensus/types/src/block/beacon_block.rs | 1 + .../types/src/block/beacon_block_body.rs | 6 + .../types/src/block/signed_beacon_block.rs | 6 +- .../types/src/execution/execution_payload.rs | 8 +- .../src/execution/execution_payload_bid.rs | 1 + .../execution/execution_payload_envelope.rs | 8 +- consensus/types/src/execution/mod.rs | 2 - .../signed_execution_payload_envelope.rs | 2 +- .../src/execution/state_payload_status.rs | 18 - consensus/types/src/state/beacon_state.rs | 42 +- testing/ef_tests/Makefile | 2 +- testing/ef_tests/check_all_files_accessed.py | 6 +- testing/ef_tests/src/cases/fork_choice.rs | 109 +++- testing/ef_tests/src/cases/operations.rs | 54 +- testing/ef_tests/src/handler.rs | 8 +- testing/ef_tests/src/lib.rs | 8 +- testing/ef_tests/tests/tests.rs | 18 +- .../src/test_rig.rs | 4 + .../lighthouse_validator_store/src/lib.rs | 2 +- 82 files changed, 1513 insertions(+), 1391 deletions(-) delete mode 100644 consensus/types/src/execution/state_payload_status.rs diff --git a/.github/forbidden-files.txt b/.github/forbidden-files.txt index b070067350..8649fbb574 100644 --- a/.github/forbidden-files.txt +++ b/.github/forbidden-files.txt @@ -12,3 +12,4 @@ beacon_node/http_api/src/block_rewards.rs common/eth2/src/lighthouse/attestation_performance.rs common/eth2/src/lighthouse/block_packing_efficiency.rs common/eth2/src/lighthouse/block_rewards.rs +consensus/types/src/execution/state_payload_status.rs diff --git a/Makefile b/Makefile index 033ad6cfc8..280e74d1d9 100644 --- a/Makefile +++ b/Makefile @@ -207,11 +207,10 @@ run-ef-tests: ./$(EF_TESTS)/check_all_files_accessed.py $(EF_TESTS)/.accessed_file_log.txt $(EF_TESTS)/consensus-spec-tests # Run the tests in the `beacon_chain` crate for all known forks. -# TODO(EIP-7732) Extend to support gloas by using RECENT_FORKS instead -test-beacon-chain: $(patsubst %,test-beacon-chain-%,$(RECENT_FORKS_BEFORE_GLOAS)) +test-beacon-chain: $(patsubst %,test-beacon-chain-%,$(RECENT_FORKS)) test-beacon-chain-%: - env FORK_NAME=$* cargo nextest run --release --features "fork_from_env,slasher/lmdb,$(TEST_FEATURES)" -p beacon_chain + env FORK_NAME=$* cargo nextest run --release --features "fork_from_env,slasher/lmdb,$(TEST_FEATURES)" -p beacon_chain --no-fail-fast # Run the tests in the `http_api` crate for recent forks. test-http-api: $(patsubst %,test-http-api-%,$(RECENT_FORKS_BEFORE_GLOAS)) diff --git a/beacon_node/beacon_chain/src/beacon_block_streamer.rs b/beacon_node/beacon_chain/src/beacon_block_streamer.rs index 9ddc50a9f7..ed74022c3d 100644 --- a/beacon_node/beacon_chain/src/beacon_block_streamer.rs +++ b/beacon_node/beacon_chain/src/beacon_block_streamer.rs @@ -733,6 +733,7 @@ mod tests { spec.deneb_fork_epoch = Some(Epoch::new(deneb_fork_epoch as u64)); spec.electra_fork_epoch = Some(Epoch::new(electra_fork_epoch as u64)); spec.fulu_fork_epoch = Some(Epoch::new(fulu_fork_epoch as u64)); + spec.gloas_fork_epoch = None; let spec = Arc::new(spec); let harness = get_harness(VALIDATOR_COUNT, spec.clone()); diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 4e4ff341fe..e14c7c047f 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -2058,12 +2058,7 @@ impl BeaconChain { // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root let (advanced_state_root, mut state) = self .store - .get_advanced_hot_state( - beacon_block_root, - StatePayloadStatus::Pending, - request_slot, - beacon_state_root, - )? + .get_advanced_hot_state(beacon_block_root, request_slot, beacon_state_root)? .ok_or(Error::MissingBeaconState(beacon_state_root))?; if state.current_epoch() < request_epoch { partial_state_advance( @@ -4564,7 +4559,7 @@ impl BeaconChain { // // Load the parent state from disk. let chain = self.clone(); - let (state, state_root_opt) = self + let block_production_state = self .task_executor .spawn_blocking_handle( move || chain.load_state_for_block_production(slot), @@ -4573,6 +4568,10 @@ impl BeaconChain { .ok_or(BlockProductionError::ShuttingDown)? .await .map_err(BlockProductionError::TokioJoin)??; + let (state, state_root_opt) = ( + block_production_state.state, + block_production_state.state_root, + ); // Part 2/2 (async, with some blocking components) // @@ -4722,12 +4721,7 @@ impl BeaconChain { .ok_or(Error::MissingBeaconBlock(parent_block_root))?; let (state_root, state) = self .store - .get_advanced_hot_state( - parent_block_root, - StatePayloadStatus::Pending, - proposal_slot, - block.state_root(), - )? + .get_advanced_hot_state(parent_block_root, proposal_slot, block.state_root())? .ok_or(Error::MissingBeaconState(block.state_root()))?; (Cow::Owned(state), state_root) }; @@ -6019,6 +6013,12 @@ impl BeaconChain { None }; + let slot_number = if prepare_slot_fork.gloas_enabled() { + Some(prepare_slot.as_u64()) + } else { + None + }; + let payload_attributes = PayloadAttributes::new( self.slot_clock .start_of(prepare_slot) @@ -6028,6 +6028,7 @@ impl BeaconChain { execution_layer.get_suggested_fee_recipient(proposer).await, withdrawals.map(Into::into), parent_beacon_block_root, + slot_number, ); execution_layer @@ -6663,12 +6664,7 @@ impl BeaconChain { // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root let (state_root, state) = self .store - .get_advanced_hot_state( - head_block_root, - StatePayloadStatus::Pending, - target_slot, - head_block.state_root, - )? + .get_advanced_hot_state(head_block_root, target_slot, head_block.state_root)? .ok_or(Error::MissingBeaconState(head_block.state_root))?; (state, state_root) }; @@ -6756,10 +6752,10 @@ impl BeaconChain { blocks.push((beacon_block_root, Arc::new(beacon_block))); } - // Collect states, using the next blocks to determine if states are full (have Gloas - // payloads). + // Collect envelopes, using the next blocks to determine if payloads are canonical + // (the parent block was full). for (i, (block_root, block)) in blocks.iter().enumerate() { - let (opt_envelope, state_root) = if block.fork_name_unchecked().gloas_enabled() { + let opt_envelope = if block.fork_name_unchecked().gloas_enabled() { let opt_envelope = self.store.get_payload_envelope(block_root)?.map(Arc::new); if let Some((_, next_block)) = blocks.get(i + 1) { @@ -6768,22 +6764,30 @@ impl BeaconChain { let envelope = opt_envelope.ok_or_else(|| { Error::DBInconsistent(format!("Missing envelope {block_root:?}")) })?; - let state_root = envelope.message.state_root; - (Some(envelope), state_root) + Some(envelope) } else { - (None, block.state_root()) + None } } else { - // TODO(gloas): should use fork choice/cached head for last block in sequence - opt_envelope - .as_ref() - .map_or((None, block.state_root()), |envelope| { - (Some(envelope.clone()), envelope.message.state_root) - }) + // Last block in the sequence: use canonical head to determine + // whether the payload is canonical. + let head = self.canonical_head.cached_head(); + assert_eq!(head.head_block_root(), *block_root); + let payload_received = + head.head_payload_status() == fork_choice::PayloadStatus::Full; + if payload_received { + let envelope = opt_envelope.ok_or_else(|| { + Error::DBInconsistent(format!("Missing envelope {block_root:?}")) + })?; + Some(envelope) + } else { + None + } } } else { - (None, block.state_root()) + None }; + let state_root = block.state_root(); let mut beacon_state = self .store diff --git a/beacon_node/beacon_chain/src/beacon_snapshot.rs b/beacon_node/beacon_chain/src/beacon_snapshot.rs index 566713e3f3..996a964386 100644 --- a/beacon_node/beacon_chain/src/beacon_snapshot.rs +++ b/beacon_node/beacon_chain/src/beacon_snapshot.rs @@ -44,18 +44,13 @@ impl> BeaconSnapshot { } } - /// Returns the state root from `self.beacon_block` or `self.execution_envelope` as - /// appropriate. + /// Returns the state root from `self.beacon_block`. /// /// ## Caution /// /// It is not strictly enforced that `root(self.beacon_state) == self.beacon_state_root()`. pub fn beacon_state_root(&self) -> Hash256 { - if let Some(ref envelope) = self.execution_envelope { - envelope.message.state_root - } else { - self.beacon_block.message().state_root() - } + self.beacon_block.message().state_root() } /// Update all fields of the checkpoint. diff --git a/beacon_node/beacon_chain/src/blob_verification.rs b/beacon_node/beacon_chain/src/blob_verification.rs index 86b385d818..e557a24369 100644 --- a/beacon_node/beacon_chain/src/blob_verification.rs +++ b/beacon_node/beacon_chain/src/blob_verification.rs @@ -20,7 +20,6 @@ use tree_hash::TreeHash; use types::data::BlobIdentifier; use types::{ BeaconStateError, BlobSidecar, Epoch, EthSpec, Hash256, SignedBeaconBlockHeader, Slot, - StatePayloadStatus, }; /// An error occurred while validating a gossip blob. @@ -513,12 +512,7 @@ pub fn validate_blob_sidecar_for_gossip BeaconChain { // // Load the parent state from disk. let chain = self.clone(); - let (state, state_root_opt) = self + let block_production_state = self .task_executor .spawn_blocking_handle( move || chain.load_state_for_block_production(slot), @@ -96,6 +99,12 @@ impl BeaconChain { .ok_or(BlockProductionError::ShuttingDown)? .await .map_err(BlockProductionError::TokioJoin)??; + let BlockProductionState { + state, + state_root: state_root_opt, + parent_payload_status, + parent_envelope, + } = block_production_state; // Part 2/2 (async, with some blocking components) // @@ -103,6 +112,8 @@ impl BeaconChain { self.produce_block_on_state_gloas( state, state_root_opt, + parent_payload_status, + parent_envelope, slot, randao_reveal, graffiti_settings, @@ -113,10 +124,13 @@ impl BeaconChain { // TODO(gloas) need to implement builder boost factor logic #[instrument(level = "debug", skip_all)] + #[allow(clippy::too_many_arguments)] pub async fn produce_block_on_state_gloas( self: &Arc, state: BeaconState, state_root_opt: Option, + parent_payload_status: PayloadStatus, + parent_envelope: Option>>, produce_at_slot: Slot, randao_reveal: Signature, graffiti_settings: GraffitiSettings, @@ -148,6 +162,16 @@ impl BeaconChain { .await .map_err(BlockProductionError::TokioJoin)??; + // Extract the parent's execution requests from the envelope (if parent was full). + let parent_execution_requests = if parent_payload_status == PayloadStatus::Full { + parent_envelope + .as_ref() + .map(|env| env.message.execution_requests.clone()) + .ok_or(BlockProductionError::MissingParentExecutionPayload)? + } else { + ExecutionRequests::default() + }; + // Part 2/3 (async) // // Produce the execution payload bid. @@ -157,6 +181,8 @@ impl BeaconChain { .clone() .produce_execution_payload_bid( state, + parent_payload_status, + parent_envelope, produce_at_slot, BID_VALUE_SELF_BUILD, BUILDER_INDEX_SELF_BUILD, @@ -173,6 +199,7 @@ impl BeaconChain { chain.complete_partial_beacon_block_gloas( partial_beacon_block, execution_payload_bid, + parent_execution_requests, payload_data, state, verification, @@ -427,6 +454,7 @@ impl BeaconChain { &self, partial_beacon_block: PartialBeaconBlock, signed_execution_payload_bid: SignedExecutionPayloadBid, + parent_execution_requests: ExecutionRequests, payload_data: Option>, mut state: BeaconState, verification: ProduceBlockVerification, @@ -488,6 +516,7 @@ impl BeaconChain { bls_to_execution_changes: bls_to_execution_changes .try_into() .map_err(BlockProductionError::SszTypesError)?, + parent_execution_requests, signed_execution_payload_bid, payload_attestations: payload_attestations .try_into() @@ -558,29 +587,23 @@ impl BeaconChain { execution_requests: payload_data.execution_requests, builder_index: payload_data.builder_index, beacon_block_root, - slot: payload_data.slot, - state_root: Hash256::ZERO, }; - let mut signed_envelope = SignedExecutionPayloadEnvelope { + let signed_envelope = SignedExecutionPayloadEnvelope { message: execution_payload_envelope, signature: Signature::empty(), }; - // We skip state root verification here because the relevant state root - // cant be calculated until after the new block has been constructed. - process_execution_payload_envelope( - &mut state, - None, + // Verify the envelope against the state. This performs no state mutation. + verify_execution_payload_envelope( + &state, &signed_envelope, VerifySignatures::False, - VerifyStateRoot::False, + state_root, &self.spec, ) .map_err(BlockProductionError::EnvelopeProcessingError)?; - signed_envelope.message.state_root = state.update_tree_hash_cache()?; - // Cache the envelope for later retrieval by the validator for signing and publishing. let envelope_slot = payload_data.slot; // TODO(gloas) might be safer to cache by root instead of by slot. @@ -622,7 +645,9 @@ impl BeaconChain { #[instrument(level = "debug", skip_all)] pub async fn produce_execution_payload_bid( self: Arc, - mut state: BeaconState, + state: BeaconState, + parent_payload_status: PayloadStatus, + parent_envelope: Option>>, produce_at_slot: Slot, bid_value: u64, builder_index: BuilderIndex, @@ -665,6 +690,17 @@ impl BeaconChain { .map_err(|e| BlockProductionError::BeaconChain(Box::new(e)))?, }; + let parent_bid = state.latest_execution_payload_bid()?; + + // TODO(gloas): need should_extend_payload check here as well + let parent_block_hash = if parent_payload_status == PayloadStatus::Full { + // Build on parent bid's payload. + parent_bid.block_hash + } else { + // Skip parent bid's payload. For genesis this is the EL genesis hash. + parent_bid.parent_block_hash + }; + // TODO(gloas) this should be BlockProductionVersion::V4 // V3 is okay for now as long as we're not connected to a builder // TODO(gloas) add builder boost factor @@ -672,6 +708,8 @@ impl BeaconChain { self.clone(), &state, parent_root, + parent_block_hash, + parent_envelope, proposer_index, builder_params, )?; @@ -689,13 +727,11 @@ impl BeaconChain { blobs_and_proofs: _, } = block_proposal_contents; - let state_root = state.update_tree_hash_cache()?; - // TODO(gloas) since we are defaulting to local building, execution payment is 0 // execution payment should only be set to > 0 for trusted building. let bid = ExecutionPayloadBid:: { - parent_block_hash: state.latest_block_hash()?.to_owned(), - parent_block_root: state.get_latest_block_root(state_root), + parent_block_hash, + parent_block_root: parent_root, block_hash: payload.block_hash, prev_randao: payload.prev_randao, fee_recipient: Address::ZERO, @@ -705,6 +741,7 @@ impl BeaconChain { value: bid_value, execution_payment: EXECUTION_PAYMENT_TRUSTLESS_BUILD, blob_kzg_commitments, + execution_requests_root: execution_requests.tree_hash_root(), }; // Store payload data for envelope construction after block is created @@ -740,6 +777,8 @@ fn get_execution_payload_gloas( chain: Arc>, state: &BeaconState, parent_beacon_block_root: Hash256, + parent_block_hash: ExecutionBlockHash, + parent_envelope: Option>>, proposer_index: u64, builder_params: BuilderParams, ) -> Result, BlockProductionError> { @@ -751,11 +790,28 @@ fn get_execution_payload_gloas( compute_timestamp_at_slot(state, state.slot(), spec).map_err(BeaconStateError::from)?; let random = *state.get_randao_mix(current_epoch)?; - let latest_execution_block_hash = *state.latest_block_hash()?; - let latest_gas_limit = state.latest_execution_payload_bid()?.gas_limit; + // TODO(gloas): this gas limit calc is not necessarily right + let parent_bid = state.latest_execution_payload_bid()?; + let latest_gas_limit = parent_bid.gas_limit; - let withdrawals = if state.is_parent_block_full() { - Withdrawals::::from(get_expected_withdrawals(state, spec)?).into() + let is_parent_block_full = parent_block_hash == parent_bid.block_hash; + + let withdrawals = if is_parent_block_full { + if let Some(envelope) = parent_envelope { + let mut withdrawals_state = state.clone(); + apply_parent_execution_payload( + &mut withdrawals_state, + parent_bid, + &envelope.message.execution_requests, + spec, + )?; + Withdrawals::::from(get_expected_withdrawals(&withdrawals_state, spec)?) + .into() + } else { + // No envelope available (e.g. genesis). The parent had no execution requests, + // so compute withdrawals directly from the current state. + Withdrawals::::from(get_expected_withdrawals(state, spec)?).into() + } } else { // If the previous payload was missed, carry forward the withdrawals from the state. state.payload_expected_withdrawals()?.to_vec() @@ -773,7 +829,7 @@ fn get_execution_payload_gloas( timestamp, random, proposer_index, - latest_execution_block_hash, + parent_block_hash, latest_gas_limit, builder_params, withdrawals, @@ -839,12 +895,15 @@ where let suggested_fee_recipient = execution_layer .get_suggested_fee_recipient(proposer_index) .await; + let slot_number = Some(builder_params.slot.as_u64()); + let payload_attributes = PayloadAttributes::new( timestamp, random, suggested_fee_recipient, Some(withdrawals), Some(parent_beacon_block_root), + slot_number, ); let target_gas_limit = execution_layer.get_proposer_gas_limit(proposer_index).await; diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index bf42923cbe..fd5e381023 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -1,9 +1,10 @@ use std::{sync::Arc, time::Duration}; +use fork_choice::PayloadStatus; use proto_array::ProposerHeadError; use slot_clock::SlotClock; use tracing::{debug, error, info, instrument, warn}; -use types::{BeaconState, Hash256, Slot, StatePayloadStatus}; +use types::{BeaconState, Hash256, SignedExecutionPayloadEnvelope, Slot}; use crate::{ BeaconChain, BeaconChainTypes, BlockProductionError, StateSkipConfig, @@ -12,14 +13,24 @@ use crate::{ mod gloas; +/// State loaded from the database for block production. +pub(crate) struct BlockProductionState { + pub state: BeaconState, + pub state_root: Option, + pub parent_payload_status: PayloadStatus, + pub parent_envelope: Option>>, +} + impl BeaconChain { /// Load a beacon state from the database for block production. This is a long-running process /// that should not be performed in an `async` context. + /// + /// The returned `PayloadStatus` is the payload status of the parent block to be built upon. #[instrument(skip_all, level = "debug")] pub(crate) fn load_state_for_block_production( self: &Arc, slot: Slot, - ) -> Result<(BeaconState, Option), BlockProductionError> { + ) -> Result, BlockProductionError> { let fork_choice_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_FORK_CHOICE_TIMES); self.wait_for_fork_choice_before_block_production(slot)?; drop(fork_choice_timer); @@ -27,16 +38,19 @@ impl BeaconChain { let state_load_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_STATE_LOAD_TIMES); // Atomically read some values from the head whilst avoiding holding cached head `Arc` any - // longer than necessary. - let (head_slot, head_block_root, head_state_root) = { + // longer than necessary. If the head has a payload envelope (Gloas full head), cheaply + // clone the `Arc` so we can pass it to block production without a DB load. + let (head_slot, head_block_root, head_state_root, head_payload_status, head_envelope) = { let head = self.canonical_head.cached_head(); ( head.head_slot(), head.head_block_root(), head.head_state_root(), + head.head_payload_status(), + head.snapshot.execution_envelope.clone(), ) }; - let (state, state_root_opt) = if head_slot < slot { + let result = if head_slot < slot { // Attempt an aggressive re-org if configured and the conditions are right. // TODO(gloas): re-enable reorgs let gloas_enabled = self @@ -52,37 +66,29 @@ impl BeaconChain { head_to_reorg = %head_block_root, "Proposing block to re-org current head" ); - (re_org_state, Some(re_org_state_root)) + // TODO(gloas): ensure we use a sensible payload status when we enable reorgs + // for Gloas + BlockProductionState { + state: re_org_state, + state_root: Some(re_org_state_root), + parent_payload_status: PayloadStatus::Pending, + parent_envelope: None, + } } else { // Fetch the head state advanced through to `slot`, which should be present in the // state cache thanks to the state advance timer. - // TODO(gloas): need to fix this once fork choice understands payloads - // for now we just use the existence of the head's payload envelope to determine - // whether we should build atop it - let (payload_status, parent_state_root) = if gloas_enabled - && let Ok(Some(envelope)) = self.store.get_payload_envelope(&head_block_root) - { - debug!( - %slot, - parent_state_root = ?envelope.message.state_root, - parent_block_root = ?head_block_root, - "Building Gloas block on full state" - ); - (StatePayloadStatus::Full, envelope.message.state_root) - } else { - (StatePayloadStatus::Pending, head_state_root) - }; + let parent_state_root = head_state_root; let (state_root, state) = self .store - .get_advanced_hot_state( - head_block_root, - payload_status, - slot, - parent_state_root, - ) + .get_advanced_hot_state(head_block_root, slot, parent_state_root) .map_err(BlockProductionError::FailedToLoadState)? .ok_or(BlockProductionError::UnableToProduceAtSlot(slot))?; - (state, Some(state_root)) + BlockProductionState { + state, + state_root: Some(state_root), + parent_payload_status: head_payload_status, + parent_envelope: head_envelope, + } } } else { warn!( @@ -94,12 +100,19 @@ impl BeaconChain { .state_at_slot(slot - 1, StateSkipConfig::WithStateRoots) .map_err(|_| BlockProductionError::UnableToProduceAtSlot(slot))?; - (state, None) + // TODO(gloas): update this to read payload canonicity from fork choice once ready + let parent_payload_status = PayloadStatus::Pending; + BlockProductionState { + state, + state_root: None, + parent_payload_status, + parent_envelope: None, + } }; drop(state_load_timer); - Ok((state, state_root_opt)) + Ok(result) } /// If configured, wait for the fork choice run at the start of the slot to complete. @@ -232,11 +245,7 @@ impl BeaconChain { let (state_root, state) = self .store - .get_advanced_hot_state_from_cache( - re_org_parent_block, - StatePayloadStatus::Pending, - slot, - ) + .get_advanced_hot_state_from_cache(re_org_parent_block, slot) .or_else(|| { warn!(reason = "no state in cache", "Not attempting re-org"); None diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 1ce1137f1e..9a43147233 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -99,8 +99,7 @@ use tracing::{Instrument, Span, debug, debug_span, error, info_span, instrument} use types::{ BeaconBlockRef, BeaconState, BeaconStateError, BlobsList, ChainSpec, DataColumnSidecarList, Epoch, EthSpec, FullPayload, Hash256, InconsistentFork, KzgProofs, RelativeEpoch, - SignedBeaconBlock, SignedBeaconBlockHeader, Slot, StatePayloadStatus, - data::DataColumnSidecarError, + SignedBeaconBlock, SignedBeaconBlockHeader, Slot, data::DataColumnSidecarError, }; /// Maximum block slot number. Block with slots bigger than this constant will NOT be processed. @@ -1509,11 +1508,7 @@ impl ExecutionPendingBlock { let distance = block.slot().as_u64().saturating_sub(state.slot().as_u64()); for _ in 0..distance { - // TODO(gloas): could do a similar optimisation here for Full blocks if we have access - // to the parent envelope and its `state_root`. - let state_root = if parent.beacon_block.slot() == state.slot() - && state.payload_status() == StatePayloadStatus::Pending - { + let state_root = if parent.beacon_block.slot() == state.slot() { // If it happens that `pre_state` has *not* already been advanced forward a single // slot, then there is no need to compute the state root for this // `per_slot_processing` call since that state root is already stored in the parent @@ -1957,37 +1952,9 @@ fn load_parent>( // particularly important if `block` descends from the finalized/split block, but at a slot // prior to the finalized slot (which is invalid and inaccessible in our DB schema). // - // Post-Gloas we must also fetch a state with the correct payload status. If the current - // block builds upon the payload of its parent block, then we know the parent block is FULL - // and we need to load the full state. - let (payload_status, parent_state_root) = if parent_block.slot() == chain.spec.genesis_slot - { - // Genesis state is always pending, there is no such thing as a "genesis envelope". - // See: https://github.com/ethereum/consensus-specs/issues/5043 - (StatePayloadStatus::Pending, parent_block.state_root()) - } else if !block.as_block().fork_name_unchecked().gloas_enabled() { - // All pre-Gloas parent states are pending. - (StatePayloadStatus::Pending, parent_block.state_root()) - } else if let Ok(parent_bid_block_hash) = parent_block.payload_bid_block_hash() - && block.as_block().is_parent_block_full(parent_bid_block_hash) - { - // Post-Gloas Full block case. - // TODO(gloas): loading the envelope here is not very efficient - let Some(envelope) = chain.store.get_payload_envelope(&root)? else { - return Err(BeaconChainError::DBInconsistent(format!( - "Missing envelope for parent block {root:?}", - )) - .into()); - }; - let state_root = envelope.message.state_root; - (StatePayloadStatus::Full, state_root) - } else { - // Post-Gloas empty block case (also covers the Gloas fork transition). - (StatePayloadStatus::Pending, parent_block.state_root()) - }; let (parent_state_root, state) = chain .store - .get_advanced_hot_state(root, payload_status, block.slot(), parent_state_root)? + .get_advanced_hot_state(root, block.slot(), parent_block.state_root())? .ok_or_else(|| { BeaconChainError::DBInconsistent( format!("Missing state for parent block {root:?}",), @@ -2010,9 +1977,7 @@ fn load_parent>( ); } - let beacon_state_root = if state.slot() == parent_block.slot() - && let StatePayloadStatus::Pending = payload_status - { + let beacon_state_root = if state.slot() == parent_block.slot() { // Sanity check. if parent_state_root != parent_block.state_root() { return Err(BeaconChainError::DBInconsistent(format!( diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index b963f7c342..74141dc64a 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -23,7 +23,7 @@ use crate::{ use bls::Signature; use execution_layer::ExecutionLayer; use fixed_bytes::FixedBytesExtended; -use fork_choice::{ForkChoice, ResetPayloadStatuses}; +use fork_choice::{ForkChoice, PayloadStatus, ResetPayloadStatuses}; use futures::channel::mpsc::Sender; use kzg::Kzg; use logging::crit; @@ -34,7 +34,9 @@ use rand::RngCore; use rayon::prelude::*; use slasher::Slasher; use slot_clock::{SlotClock, TestingSlotClock}; -use state_processing::{AllCaches, per_slot_processing}; +use state_processing::AllCaches; +use state_processing::genesis::genesis_block; +use state_processing::per_slot_processing; use std::marker::PhantomData; use std::sync::Arc; use std::time::Duration; @@ -44,8 +46,8 @@ use tracing::{debug, error, info, warn}; use tree_hash::TreeHash; use types::data::CustodyIndex; use types::{ - BeaconBlock, BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, - Epoch, EthSpec, Hash256, SignedBeaconBlock, Slot, + BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, Epoch, EthSpec, + Hash256, SignedBeaconBlock, Slot, }; /// An empty struct used to "witness" all the `BeaconChainTypes` traits. It has no user-facing @@ -321,7 +323,7 @@ where .clone() .ok_or("set_genesis_state requires a store")?; - let beacon_block = genesis_block(&mut beacon_state, &self.spec)?; + let beacon_block = make_genesis_block(&mut beacon_state, &self.spec)?; beacon_state .build_caches(&self.spec) @@ -374,7 +376,7 @@ where // Since v4.4.0 we will set the anchor with a dummy state upper limit in order to prevent // historic states from being retained (unless `--archive` is set). let retain_historic_states = self.chain_config.archive; - let genesis_beacon_block = genesis_block(&mut beacon_state, &self.spec)?; + let genesis_beacon_block = make_genesis_block(&mut beacon_state, &self.spec)?; self.pending_io_batch.push( store .init_anchor_info( @@ -617,7 +619,6 @@ where .map_err(|e| format!("Failed to initialize data column info: {:?}", e))?, ); - // TODO(gloas): add check that checkpoint state is Pending let snapshot = BeaconSnapshot { beacon_block_root: weak_subj_block_root, execution_envelope: None, @@ -786,23 +787,26 @@ where .map_err(|e| descriptive_db_error("head block", &e))? .ok_or("Head block not found in store")?; - let state_payload_status = head_payload_status.as_state_payload_status(); - let (_head_state_root, head_state) = store - .get_advanced_hot_state( - head_block_root, - state_payload_status, - current_slot, - head_block.state_root(), - ) + .get_advanced_hot_state(head_block_root, current_slot, head_block.state_root()) .map_err(|e| descriptive_db_error("head state", &e))? .ok_or("Head state not found in store")?; let head_shuffling_ids = BlockShufflingIds::try_from_head(head_block_root, &head_state)?; + // Load the execution envelope from the store if the head has a Full payload. + let execution_envelope = if head_payload_status == PayloadStatus::Full { + store + .get_payload_envelope(&head_block_root) + .map_err(|e| format!("Error loading head execution envelope: {:?}", e))? + .map(Arc::new) + } else { + None + }; + let mut head_snapshot = BeaconSnapshot { beacon_block_root: head_block_root, - execution_envelope: None, + execution_envelope, beacon_block: Arc::new(head_block), beacon_state: head_state, }; @@ -1166,17 +1170,19 @@ where } } -fn genesis_block( +fn make_genesis_block( genesis_state: &mut BeaconState, spec: &ChainSpec, ) -> Result, String> { - let mut genesis_block = BeaconBlock::empty(spec); - *genesis_block.state_root_mut() = genesis_state + let mut block = genesis_block(genesis_state, spec) + .map_err(|e| format!("Error building genesis block: {:?}", e))?; + + *block.state_root_mut() = genesis_state .update_tree_hash_cache() .map_err(|e| format!("Error hashing genesis state: {:?}", e))?; Ok(SignedBeaconBlock::from_block( - genesis_block, + block, // Empty signature, which should NEVER be read. This isn't to-spec, but makes the genesis // block consistent with every other block. Signature::empty(), diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index cd53d0ef7c..1e5e1300ab 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -43,8 +43,8 @@ use crate::{ }; use eth2::types::{EventKind, SseChainReorg, SseFinalizedCheckpoint, SseLateHead}; use fork_choice::{ - ExecutionStatus, ForkChoiceStore, ForkChoiceView, ForkchoiceUpdateParameters, ProtoBlock, - ResetPayloadStatuses, + ExecutionStatus, ForkChoiceStore, ForkChoiceView, ForkchoiceUpdateParameters, PayloadStatus, + ProtoBlock, ResetPayloadStatuses, }; use itertools::process_results; @@ -315,20 +315,22 @@ impl CanonicalHead { .ok_or(Error::MissingBeaconBlock(beacon_block_root))?; let current_slot = fork_choice.fc_store().get_current_slot(); - // TODO(gloas): pass a better payload status once fork choice is implemented - let payload_status = StatePayloadStatus::Pending; let (_, beacon_state) = store - .get_advanced_hot_state( - beacon_block_root, - payload_status, - current_slot, - beacon_block.state_root(), - )? + .get_advanced_hot_state(beacon_block_root, current_slot, beacon_block.state_root())? .ok_or(Error::MissingBeaconState(beacon_block.state_root()))?; + // Load the execution envelope from the store if the head has a Full payload. + let execution_envelope = if head_payload_status == PayloadStatus::Full { + store + .get_payload_envelope(&beacon_block_root)? + .map(Arc::new) + } else { + None + }; + let snapshot = BeaconSnapshot { beacon_block_root, - execution_envelope: None, + execution_envelope, beacon_block: Arc::new(beacon_block), beacon_state, }; @@ -683,30 +685,42 @@ impl BeaconChain { drop(fork_choice_read_lock); // If the head has changed, update `self.canonical_head`. - let new_cached_head = if new_view.head_block_root != old_view.head_block_root { + let new_cached_head = if new_view.head_block_root != old_view.head_block_root + || new_payload_status != old_payload_status + { metrics::inc_counter(&metrics::FORK_CHOICE_CHANGED_HEAD); + // TODO(gloas): could optimise this to reuse state and rest of snapshot if just the + // payload status has changed. let mut new_snapshot = { let beacon_block = self .store .get_full_block(&new_view.head_block_root)? .ok_or(Error::MissingBeaconBlock(new_view.head_block_root))?; - // TODO(gloas): update once we have fork choice - let payload_status = StatePayloadStatus::Pending; + // Load the execution envelope from the store if the head has a Full payload. + let state_root = beacon_block.state_root(); + let execution_envelope = if new_payload_status == PayloadStatus::Full { + let envelope = self + .store + .get_payload_envelope(&new_view.head_block_root)? + .map(Arc::new) + .ok_or(Error::MissingExecutionPayloadEnvelope( + new_view.head_block_root, + ))?; + + Some(envelope) + } else { + None + }; let (_, beacon_state) = self .store - .get_advanced_hot_state( - new_view.head_block_root, - payload_status, - current_slot, - beacon_block.state_root(), - )? - .ok_or(Error::MissingBeaconState(beacon_block.state_root()))?; + .get_advanced_hot_state(new_view.head_block_root, current_slot, state_root)? + .ok_or(Error::MissingBeaconState(state_root))?; BeaconSnapshot { beacon_block: Arc::new(beacon_block), - execution_envelope: None, + execution_envelope, beacon_block_root: new_view.head_block_root, beacon_state, } @@ -770,7 +784,8 @@ impl BeaconChain { let old_snapshot = &old_cached_head.snapshot; // If the head changed, perform some updates. - if new_snapshot.beacon_block_root != old_snapshot.beacon_block_root + if (new_snapshot.beacon_block_root != old_snapshot.beacon_block_root + || new_payload_status != old_payload_status) && let Err(e) = self.after_new_head(&old_cached_head, &new_cached_head, new_head_proto_block) { @@ -974,26 +989,30 @@ impl BeaconChain { // The store migration task and op pool pruning require the *state at the first slot of the // finalized epoch*, rather than the state of the latest finalized block. These two values // will only differ when the first slot of the finalized epoch is a skip slot. - // - // Use the `StateRootsIterator` directly rather than `BeaconChain::state_root_at_slot` - // to ensure we use the same state that we just set as the head. let new_finalized_slot = new_view .finalized_checkpoint .epoch .start_slot(T::EthSpec::slots_per_epoch()); - let new_finalized_state_root = process_results( - StateRootsIterator::new(&self.store, &new_snapshot.beacon_state), - |mut iter| { - iter.find_map(|(state_root, slot)| { - if slot == new_finalized_slot { - Some(state_root) - } else { - None - } - }) - }, - )? - .ok_or(Error::MissingFinalizedStateRoot(new_finalized_slot))?; + let new_finalized_state_root = if new_finalized_slot == finalized_proto_block.slot { + // Fast-path for the common case where the finalized state is not at a skipped slot. + finalized_proto_block.state_root + } else { + // Use the `StateRootsIterator` directly rather than `BeaconChain::state_root_at_slot` + // to ensure we use the same state that we just set as the head. + process_results( + StateRootsIterator::new(&self.store, &new_snapshot.beacon_state), + |mut iter| { + iter.find_map(|(state_root, slot)| { + if slot == new_finalized_slot { + Some(state_root) + } else { + None + } + }) + }, + )? + .ok_or(Error::MissingFinalizedStateRoot(new_finalized_slot))? + }; let update_cache = true; let new_finalized_state = self diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index f2cec0980f..a24dbd8942 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -21,7 +21,7 @@ use tracing::{debug, instrument}; use types::data::ColumnIndex; use types::{ BeaconStateError, ChainSpec, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSubnetId, - EthSpec, Hash256, Slot, StatePayloadStatus, + EthSpec, Hash256, Slot, }; /// An error occurred while validating a gossip data column. @@ -743,12 +743,7 @@ fn verify_proposer_and_signature( // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root chain .store - .get_advanced_hot_state( - block_parent_root, - StatePayloadStatus::Pending, - column_slot, - parent_block.state_root, - ) + .get_advanced_hot_state(block_parent_root, column_slot, parent_block.state_root) .map_err(|e| GossipDataColumnError::BeaconChainError(Box::new(e.into())))? .ok_or_else(|| { GossipDataColumnError::BeaconChainError(Box::new( diff --git a/beacon_node/beacon_chain/src/errors.rs b/beacon_node/beacon_chain/src/errors.rs index d5ff12e33b..9802f091e0 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -63,6 +63,7 @@ pub enum BeaconChainError { ForkChoiceStoreError(ForkChoiceStoreError), MissingBeaconBlock(Hash256), MissingBeaconState(Hash256), + MissingExecutionPayloadEnvelope(Hash256), MissingHotStateSummary(Hash256), SlotProcessingError(SlotProcessingError), EpochProcessingError(EpochProcessingError), @@ -294,9 +295,6 @@ pub enum BlockProductionError { BeaconStateError(BeaconStateError), StateAdvanceError(StateAdvanceError), OpPoolError(OpPoolError), - /// The `BeaconChain` was explicitly configured _without_ a connection to eth1, therefore it - /// cannot produce blocks. - NoEth1ChainConnection, StateSlotTooHigh { produce_at_slot: Slot, state_slot: Slot, @@ -324,6 +322,8 @@ pub enum BlockProductionError { SszTypesError(ssz_types::Error), EnvelopeProcessingError(EnvelopeProcessingError), BlsError(bls::Error), + MissingParentExecutionPayload, + MissingExecutionPayloadEnvelope(Hash256), // TODO(gloas): Remove this once Gloas is implemented GloasNotImplemented(String), } diff --git a/beacon_node/beacon_chain/src/execution_payload.rs b/beacon_node/beacon_chain/src/execution_payload.rs index 2b03a095f1..16542eea2d 100644 --- a/beacon_node/beacon_chain/src/execution_payload.rs +++ b/beacon_node/beacon_chain/src/execution_payload.rs @@ -402,12 +402,20 @@ where let suggested_fee_recipient = execution_layer .get_suggested_fee_recipient(proposer_index) .await; + + let slot_number = if fork.gloas_enabled() { + Some(builder_params.slot.as_u64()) + } else { + None + }; + let payload_attributes = PayloadAttributes::new( timestamp, random, suggested_fee_recipient, withdrawals, parent_beacon_block_root, + slot_number, ); let target_gas_limit = execution_layer.get_proposer_gas_limit(proposer_index).await; diff --git a/beacon_node/beacon_chain/src/migrate.rs b/beacon_node/beacon_chain/src/migrate.rs index 24258d2d31..3c17c1ebba 100644 --- a/beacon_node/beacon_chain/src/migrate.rs +++ b/beacon_node/beacon_chain/src/migrate.rs @@ -330,7 +330,7 @@ impl, Cold: ItemStore> BackgroundMigrator state, other => { error!( diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index bb59b16ffb..98863a49d5 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -10,9 +10,10 @@ use kzg::KzgCommitment; use slot_clock::{SlotClock, TestingSlotClock}; use ssz::Encode; use ssz_types::VariableList; +use state_processing::genesis::genesis_block; use store::{HotColdDB, StoreConfig}; use types::{ - Address, BeaconBlock, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, + Address, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, Hash256, MinimalEthSpec, ProposerPreferences, SignedBeaconBlock, SignedExecutionPayloadBid, SignedProposerPreferences, SignedRoot, Slot, }; @@ -112,11 +113,11 @@ impl TestContext { ) .expect("should register inactive builder"); - let mut genesis_block = BeaconBlock::empty(&spec); - *genesis_block.state_root_mut() = state + let mut block = genesis_block(&state, &spec).expect("should build genesis block"); + *block.state_root_mut() = state .update_tree_hash_cache() .expect("should hash genesis state"); - let signed_block = SignedBeaconBlock::from_block(genesis_block, Signature::empty()); + let signed_block = SignedBeaconBlock::from_block(block, Signature::empty()); let block_root = signed_block.canonical_root(); let snapshot = BeaconSnapshot::new( diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs index 9e869a59b8..0db6d57ed6 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs @@ -65,13 +65,12 @@ fn build_chain( message: ExecutionPayloadEnvelope { payload: ExecutionPayloadGloas { block_hash, + slot_number: slot, ..Default::default() }, execution_requests: Default::default(), builder_index: 0, beacon_block_root: block_root, - slot, - state_root: Hash256::zero(), }, signature: Signature::empty(), }) diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs index 86f9293c8f..4b8e7347cc 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs @@ -1,10 +1,7 @@ use std::sync::Arc; use slot_clock::SlotClock; -use state_processing::{ - VerifySignatures, - envelope_processing::{VerifyStateRoot, process_execution_payload_envelope}, -}; +use state_processing::{VerifySignatures, envelope_processing::verify_execution_payload_envelope}; use types::EthSpec; use crate::{ @@ -77,16 +74,15 @@ impl GossipVerifiedEnvelope { } else { load_snapshot_from_state_root::(block_root, self.block.state_root(), &chain.store)? }; - let mut state = snapshot.pre_state; + let state = snapshot.pre_state; - // All the state modifications are done in envelope_processing - process_execution_payload_envelope( - &mut state, - Some(snapshot.state_root), + // Verify the envelope against the state (no state mutation). + verify_execution_payload_envelope( + &state, &signed_envelope, // verify signature already done for GossipVerifiedEnvelope VerifySignatures::False, - VerifyStateRoot::True, + snapshot.state_root, &chain.spec, )?; @@ -97,7 +93,7 @@ impl GossipVerifiedEnvelope { }, import_data: EnvelopeImportData { block_root, - post_state: Box::new(state), + _phantom: Default::default(), }, payload_verification_handle, }) diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs index 77b44a2af0..80724e2b00 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs @@ -42,18 +42,18 @@ pub(crate) fn verify_envelope_consistency( ) -> Result<(), EnvelopeError> { // Check that the envelope's slot isn't from a slot prior // to the latest finalized slot. - if envelope.slot < latest_finalized_slot { + if envelope.slot() < latest_finalized_slot { return Err(EnvelopeError::PriorToFinalization { - payload_slot: envelope.slot, + payload_slot: envelope.slot(), latest_finalized_slot, }); } // Check that the slot of the envelope matches the slot of the block. - if envelope.slot != block.slot() { + if envelope.slot() != block.slot() { return Err(EnvelopeError::SlotMismatch { block: block.slot(), - envelope: envelope.slot, + envelope: envelope.slot(), }); } @@ -144,7 +144,7 @@ impl GossipVerifiedEnvelope { // validator pubkey cache for the proposer's pubkey, avoiding a state load from disk. // For external builder envelopes, we must load the state to access the builder registry. let builder_index = envelope.builder_index; - let block_slot = envelope.slot; + let block_slot = envelope.slot(); let envelope_epoch = block_slot.epoch(T::EthSpec::slots_per_epoch()); // Since the payload's block is already guaranteed to be imported, the associated `proto_block.current_epoch_shuffling_id` // already carries the correct `shuffling_decision_block`. @@ -224,7 +224,6 @@ impl GossipVerifiedEnvelope { builder_index, block_hash: signed_envelope.message.payload.block_hash, block_root: beacon_block_root, - state_root: signed_envelope.message.state_root, }, )); } @@ -334,13 +333,12 @@ mod tests { ExecutionPayloadEnvelope { payload: ExecutionPayloadGloas { block_hash, + slot_number: slot, ..ExecutionPayloadGloas::default() }, execution_requests: ExecutionRequests::default(), builder_index, beacon_block_root: Hash256::ZERO, - slot, - state_root: Hash256::ZERO, } } @@ -365,6 +363,7 @@ mod tests { voluntary_exits: VariableList::empty(), sync_aggregate: SyncAggregate::empty(), bls_to_execution_changes: VariableList::empty(), + parent_execution_requests: ExecutionRequests::default(), signed_execution_payload_bid: SignedExecutionPayloadBid::empty(), payload_attestations: VariableList::empty(), _phantom: PhantomData, diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs index 7e79799310..5a6d3a1b7d 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -6,7 +6,7 @@ use fork_choice::PayloadVerificationStatus; use slot_clock::SlotClock; use store::StoreOp; use tracing::{debug, error, info, info_span, instrument, warn}; -use types::{BeaconState, BlockImportSource, Hash256, SignedExecutionPayloadEnvelope}; +use types::{BlockImportSource, Hash256, SignedExecutionPayloadEnvelope}; use super::{ AvailableEnvelope, AvailableExecutedEnvelope, EnvelopeError, EnvelopeImportData, @@ -198,7 +198,7 @@ impl BeaconChain { let EnvelopeImportData { block_root, - post_state, + _phantom, } = import_data; let block_root = { @@ -208,7 +208,6 @@ impl BeaconChain { chain.import_execution_payload_envelope( envelope, block_root, - *post_state, payload_verification_outcome.payload_verification_status, ) }, @@ -231,7 +230,6 @@ impl BeaconChain { &self, signed_envelope: AvailableEnvelope, block_root: Hash256, - state: BeaconState, payload_verification_status: PayloadVerificationStatus, ) -> Result { // Everything in this initial section is on the hot path for processing the envelope. @@ -285,10 +283,6 @@ impl BeaconChain { block_root, signed_envelope.clone(), )); - ops.push(StoreOp::PutState( - signed_envelope.message.state_root, - &state, - )); let db_span = info_span!("persist_payloads_and_blobs").entered(); @@ -365,7 +359,6 @@ impl BeaconChain { builder_index: signed_envelope.message.builder_index, block_hash: signed_envelope.block_hash(), block_root, - state_root: signed_envelope.message.state_root, execution_optimistic: payload_verification_status.is_optimistic(), })); } diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs index 225d5a9892..51fc3f235d 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs @@ -18,11 +18,11 @@ //! //! ``` +use std::marker::PhantomData; use std::sync::Arc; -use store::Error as DBError; - use state_processing::{BlockProcessingError, envelope_processing::EnvelopeProcessingError}; +use store::Error as DBError; use tracing::instrument; use types::{ BeaconState, BeaconStateError, ChainSpec, DataColumnSidecarList, EthSpec, ExecutionBlockHash, @@ -41,10 +41,11 @@ mod payload_notifier; pub use execution_pending_envelope::ExecutionPendingEnvelope; +// TODO(gloas): could remove this type completely, or remove the generic #[derive(PartialEq)] pub struct EnvelopeImportData { pub block_root: Hash256, - pub post_state: Box>, + _phantom: PhantomData, } #[derive(Debug)] @@ -249,9 +250,6 @@ impl From for EnvelopeError { committed_bid, envelope, }, - EnvelopeProcessingError::BlockProcessingError(e) => { - EnvelopeError::BlockProcessingError(e) - } e => EnvelopeError::EnvelopeProcessingError(e), } } diff --git a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs index 336ab5323f..351783832d 100644 --- a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs +++ b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs @@ -87,12 +87,13 @@ mod tests { fn make_envelope(slot: Slot) -> ExecutionPayloadEnvelope { ExecutionPayloadEnvelope { - payload: ExecutionPayloadGloas::default(), + payload: ExecutionPayloadGloas { + slot_number: slot, + ..ExecutionPayloadGloas::default() + }, execution_requests: ExecutionRequests::default(), builder_index: 0, beacon_block_root: Hash256::ZERO, - slot, - state_root: Hash256::ZERO, } } diff --git a/beacon_node/beacon_chain/src/state_advance_timer.rs b/beacon_node/beacon_chain/src/state_advance_timer.rs index 4c070e7ecc..cb916cb514 100644 --- a/beacon_node/beacon_chain/src/state_advance_timer.rs +++ b/beacon_node/beacon_chain/src/state_advance_timer.rs @@ -26,10 +26,7 @@ use std::sync::{ use task_executor::TaskExecutor; use tokio::time::{Instant, sleep, sleep_until}; use tracing::{Instrument, debug, debug_span, error, instrument, warn}; -use types::{ - AttestationShufflingId, BeaconStateError, EthSpec, Hash256, RelativeEpoch, Slot, - StatePayloadStatus, -}; +use types::{AttestationShufflingId, BeaconStateError, EthSpec, Hash256, RelativeEpoch, Slot}; /// If the head slot is more than `MAX_ADVANCE_DISTANCE` from the current slot, then don't perform /// the state advancement. @@ -280,16 +277,9 @@ fn advance_head(beacon_chain: &Arc>) -> Resu (snapshot.beacon_block_root, snapshot.beacon_state_root()) }; - // TODO(gloas): do better once we have fork choice - let payload_status = StatePayloadStatus::Pending; let (head_state_root, mut state) = beacon_chain .store - .get_advanced_hot_state( - head_block_root, - payload_status, - current_slot, - head_block_state_root, - )? + .get_advanced_hot_state(head_block_root, current_slot, head_block_state_root)? .ok_or(Error::HeadMissingFromSnapshotCache(head_block_root))?; let initial_slot = state.slot(); diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 1b03b6e10b..e84f9ad983 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1043,6 +1043,13 @@ where assert_ne!(slot, 0, "can't produce a block at slot 0"); assert!(slot >= state.slot()); + // For Gloas forks, delegate to make_block_with_envelope and discard the envelope. + if self.spec.fork_name_at_slot::(slot).gloas_enabled() { + let (block_contents, _envelope, state) = + Box::pin(self.make_block_with_envelope(state, slot)).await; + return (block_contents, state); + } + complete_state_advance(&mut state, None, slot, &self.spec) .expect("should be able to advance state to slot"); @@ -1124,11 +1131,24 @@ where GraffitiSettings::new(Some(graffiti), Some(GraffitiPolicy::PreserveUserGraffiti)); let randao_reveal = self.sign_randao_reveal(&state, proposer_index, slot); + // Load the parent's payload envelope and status from the cached head. + // TODO(gloas): we may want to pass these as arguments to support cases where we build + // on alternate chains to the head. + let (parent_payload_status, parent_envelope) = { + let head = self.chain.canonical_head.cached_head(); + ( + head.head_payload_status(), + head.snapshot.execution_envelope.clone(), + ) + }; + let (block, pending_state, _consensus_block_value) = self .chain .produce_block_on_state_gloas( state, None, + parent_payload_status, + parent_envelope, slot, randao_reveal, graffiti_settings, @@ -2681,32 +2701,27 @@ where Ok(block_hash) } - /// Process an execution payload envelope for a Gloas block. + /// Verify and process (with fork choice) an execution payload envelope for a Gloas block. pub async fn process_envelope( &self, block_root: Hash256, signed_envelope: SignedExecutionPayloadEnvelope, - pending_state: &mut BeaconState, - ) -> Hash256 { - let state_root = signed_envelope.message.state_root; + state: &BeaconState, + block_state_root: Hash256, + ) { debug!( - slot = %signed_envelope.message.slot, - ?state_root, + slot = %signed_envelope.slot(), "Processing execution payload envelope" ); - let block_state_root = pending_state - .update_tree_hash_cache() - .expect("should compute pending state root"); - state_processing::envelope_processing::process_execution_payload_envelope( - pending_state, - Some(block_state_root), + state_processing::envelope_processing::verify_execution_payload_envelope( + state, &signed_envelope, state_processing::VerifySignatures::True, - state_processing::envelope_processing::VerifyStateRoot::True, + block_state_root, &self.spec, ) - .expect("should process envelope"); + .expect("should verify envelope"); // Notify the EL of the new payload so forkchoiceUpdated can reference it. let block = self @@ -2747,16 +2762,18 @@ where // Store the envelope. self.chain .store - .put_payload_envelope(&block_root, signed_envelope) + .put_payload_envelope(&block_root, &signed_envelope) .expect("should store envelope"); - // Store the Full state. + // Update fork choice so it knows the payload was received. self.chain - .store - .put_state(&state_root, pending_state) - .expect("should store full state"); + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(block_root) + .expect("should update fork choice with envelope"); - state_root + // Run fork choice because the envelope could become the head. + self.chain.recompute_head_at_current_slot().await; } /// Builds a `RangeSyncBlock` from a `SignedBeaconBlock` and blobs or data columns retrieved from @@ -2970,7 +2987,8 @@ where BlockError, > { self.set_current_slot(slot); - let (block_contents, new_state) = self.make_block(state, slot).await; + let (block_contents, opt_envelope, new_state) = + self.make_block_with_envelope(state, slot).await; let block_hash = self .process_block( @@ -2979,6 +2997,12 @@ where block_contents.clone(), ) .await?; + + if let Some(envelope) = opt_envelope { + let block_state_root = block_contents.0.state_root(); + self.process_envelope(block_hash.into(), envelope, &new_state, block_state_root) + .await; + } Ok((block_hash, block_contents, new_state)) } diff --git a/beacon_node/beacon_chain/src/validator_pubkey_cache.rs b/beacon_node/beacon_chain/src/validator_pubkey_cache.rs index 26ac02d91b..36bf5c7113 100644 --- a/beacon_node/beacon_chain/src/validator_pubkey_cache.rs +++ b/beacon_node/beacon_chain/src/validator_pubkey_cache.rs @@ -302,7 +302,8 @@ mod test { #[test] fn basic_operation() { - let (state, keypairs) = get_state(8); + // >= 32 validators required for Gloas genesis with MainnetEthSpec (32 slots/epoch). + let (state, keypairs) = get_state(32); let store = get_store(); @@ -311,21 +312,14 @@ mod test { check_cache_get(&cache, &keypairs[..]); // Try adding a state with the same number of keypairs. - let (state, keypairs) = get_state(8); - cache - .import_new_pubkeys(&state) - .expect("should import pubkeys"); - check_cache_get(&cache, &keypairs[..]); - - // Try adding a state with less keypairs. - let (state, _) = get_state(1); + let (state, keypairs) = get_state(32); cache .import_new_pubkeys(&state) .expect("should import pubkeys"); check_cache_get(&cache, &keypairs[..]); // Try adding a state with more keypairs. - let (state, keypairs) = get_state(12); + let (state, keypairs) = get_state(48); cache .import_new_pubkeys(&state) .expect("should import pubkeys"); @@ -334,7 +328,7 @@ mod test { #[test] fn persistence() { - let (state, keypairs) = get_state(8); + let (state, keypairs) = get_state(32); let store = get_store(); @@ -349,7 +343,7 @@ mod test { check_cache_get(&cache, &keypairs[..]); // Add some more keypairs. - let (state, keypairs) = get_state(12); + let (state, keypairs) = get_state(48); let ops = cache .import_new_pubkeys(&state) .expect("should import pubkeys"); diff --git a/beacon_node/beacon_chain/tests/attestation_production.rs b/beacon_node/beacon_chain/tests/attestation_production.rs index bca60d27cd..a3ab959d12 100644 --- a/beacon_node/beacon_chain/tests/attestation_production.rs +++ b/beacon_node/beacon_chain/tests/attestation_production.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, LazyLock}; use tree_hash::TreeHash; use types::{Attestation, EthSpec, MainnetEthSpec, RelativeEpoch, Slot}; -pub const VALIDATOR_COUNT: usize = 16; +pub const VALIDATOR_COUNT: usize = 32; /// A cached set of keys. static KEYPAIRS: LazyLock> = diff --git a/beacon_node/beacon_chain/tests/attestation_verification.rs b/beacon_node/beacon_chain/tests/attestation_verification.rs index 91bc8e249a..da7f380e36 100644 --- a/beacon_node/beacon_chain/tests/attestation_verification.rs +++ b/beacon_node/beacon_chain/tests/attestation_verification.rs @@ -1389,13 +1389,18 @@ async fn attestation_to_finalized_block() { let earlier_block_root = earlier_block.canonical_root(); assert_ne!(earlier_block_root, finalized_checkpoint.root); + // For Gloas, `block.state_root()` returns the pending state root, but the cold DB + // may store the full state root. Use `get_cold_state_root` to get the actual stored key. + let cold_state_root = harness + .chain + .store + .get_cold_state_root(earlier_slot) + .expect("should not error getting cold state root") + .expect("cold state root should be present for finalized slot in archive store"); + let mut state = harness .chain - .get_state( - &earlier_block.state_root(), - Some(earlier_slot), - CACHE_STATE_IN_TESTS, - ) + .get_state(&cold_state_root, Some(earlier_slot), CACHE_STATE_IN_TESTS) .expect("should not error getting state") .expect("should find state"); diff --git a/beacon_node/beacon_chain/tests/block_verification.rs b/beacon_node/beacon_chain/tests/block_verification.rs index 2bb60f111a..6646fe0b1e 100644 --- a/beacon_node/beacon_chain/tests/block_verification.rs +++ b/beacon_node/beacon_chain/tests/block_verification.rs @@ -31,8 +31,8 @@ use types::{test_utils::generate_deterministic_keypair, *}; type E = MainnetEthSpec; -// Should ideally be divisible by 3. -const VALIDATOR_COUNT: usize = 24; +// Gloas requires >= 1 validator per slot for PTC committee computation, so >= 32 for MainnetEthSpec. +const VALIDATOR_COUNT: usize = 32; const CHAIN_SEGMENT_LENGTH: usize = 64 * 5; const BLOCK_INDICES: &[usize] = &[0, 1, 32, 64, 68 + 1, 129, CHAIN_SEGMENT_LENGTH - 1]; @@ -77,10 +77,9 @@ async fn get_chain_segment() -> (Vec>, Vec], + harness: &BeaconChainHarness>, +) { + for snapshot in chain_segment { + if let Some(ref envelope) = snapshot.execution_envelope { + harness + .chain + .store + .put_payload_envelope(&snapshot.beacon_block_root, envelope) + .expect("should store envelope"); + } + } +} + +/// Update fork choice with envelope payload status for all blocks in the chain segment. +/// +/// Must be called after the blocks have been imported into fork choice. +fn update_fork_choice_with_envelopes( + chain_segment: &[BeaconSnapshot], + harness: &BeaconChainHarness>, +) { + for snapshot in chain_segment { + if snapshot.execution_envelope.is_some() { + // Call may fail if block was invalid (it will have no fork choice node). + let _ = harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(snapshot.beacon_block_root); + } + } +} + fn junk_signature() -> Signature { let kp = generate_deterministic_keypair(VALIDATOR_COUNT); let message = Hash256::from_slice(&[42; 32]); @@ -303,6 +343,7 @@ fn update_data_column_signed_header( async fn chain_segment_full_segment() { let harness = get_harness(VALIDATOR_COUNT, NodeCustodyType::Fullnode); let (chain_segment, chain_segment_blobs) = get_chain_segment().await; + store_envelopes_for_chain_segment(&chain_segment, &harness); let blocks: Vec> = chain_segment_blocks(&chain_segment, &chain_segment_blobs, harness.chain.clone()) .into_iter() @@ -328,6 +369,7 @@ async fn chain_segment_full_segment() { .into_block_error() .expect("should import chain segment"); + update_fork_choice_with_envelopes(&chain_segment, &harness); harness.chain.recompute_head_at_current_slot().await; assert_eq!( @@ -348,6 +390,7 @@ async fn chain_segment_varying_chunk_size() { for chunk_size in &[1, 2, 31, 32, 33] { let harness = get_harness(VALIDATOR_COUNT, NodeCustodyType::Fullnode); + store_envelopes_for_chain_segment(&chain_segment, &harness); harness .chain @@ -363,6 +406,7 @@ async fn chain_segment_varying_chunk_size() { .unwrap_or_else(|_| panic!("should import chain segment of len {}", chunk_size)); } + update_fork_choice_with_envelopes(&chain_segment, &harness); harness.chain.recompute_head_at_current_slot().await; assert_eq!( @@ -514,6 +558,7 @@ async fn assert_invalid_signature( snapshots: &[BeaconSnapshot], item: &str, ) { + store_envelopes_for_chain_segment(chain_segment, harness); let blocks: Vec> = snapshots .iter() .zip(chain_segment_blobs.iter()) @@ -540,10 +585,22 @@ async fn assert_invalid_signature( harness.chain.recompute_head_at_current_slot().await; // Ensure the block will be rejected if imported on its own (without gossip checking). - let ancestor_blocks = chain_segment + // Only include blocks that haven't been imported yet (after the finalized slot) to avoid + // `WouldRevertFinalizedSlot` errors when part 1 already imported and finalized some blocks. + // Use the fork choice finalized checkpoint directly, as the cached head may not reflect + // finalization that occurred during process_chain_segment. + let finalized_slot = harness + .chain + .canonical_head + .fork_choice_read_lock() + .finalized_checkpoint() + .epoch + .start_slot(E::slots_per_epoch()); + let ancestor_blocks: Vec> = chain_segment .iter() .take(block_index) .zip(chain_segment_blobs.iter()) + .filter(|(snapshot, _)| snapshot.beacon_block.slot() > finalized_slot) .map(|(snapshot, blobs)| { build_range_sync_block(snapshot.beacon_block.clone(), blobs, harness.chain.clone()) }) @@ -554,6 +611,7 @@ async fn assert_invalid_signature( .chain .process_chain_segment(ancestor_blocks, NotifyExecutionLayer::Yes) .await; + update_fork_choice_with_envelopes(chain_segment, harness); harness.chain.recompute_head_at_current_slot().await; let process_res = harness @@ -594,6 +652,7 @@ async fn get_invalid_sigs_harness( chain_segment: &[BeaconSnapshot], ) -> BeaconChainHarness> { let harness = get_harness(VALIDATOR_COUNT, NodeCustodyType::Fullnode); + store_envelopes_for_chain_segment(chain_segment, &harness); harness .chain .slot_clock @@ -1091,6 +1150,21 @@ async fn block_gossip_verification() { ) .await .expect("should import valid gossip verified block"); + // Post-Gloas, store the execution payload envelope so that subsequent blocks can look up + // the parent envelope. + if let Some(ref envelope) = snapshot.execution_envelope { + harness + .chain + .store + .put_payload_envelope(&snapshot.beacon_block_root, envelope) + .expect("should store envelope"); + harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(snapshot.beacon_block_root) + .expect("should update fork choice with envelope"); + } if let Some(data_sidecars) = blobs_opt { verify_and_process_gossip_data_sidecars(&harness, data_sidecars).await; } @@ -2040,7 +2114,10 @@ async fn range_sync_block_construction_fails_with_wrong_blob_count() { async fn range_sync_block_rejects_missing_custody_columns() { let spec = test_spec::(); - if !spec.fork_name_at_slot::(Slot::new(0)).fulu_enabled() { + // Gloas blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if !spec.fork_name_at_slot::(Slot::new(0)).fulu_enabled() + || spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() + { return; } @@ -2118,7 +2195,10 @@ async fn range_sync_block_rejects_missing_custody_columns() { async fn rpc_block_allows_construction_past_da_boundary() { let spec = test_spec::(); - if !spec.fork_name_at_slot::(Slot::new(0)).fulu_enabled() { + // Gloas blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if !spec.fork_name_at_slot::(Slot::new(0)).fulu_enabled() + || spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() + { return; } diff --git a/beacon_node/beacon_chain/tests/column_verification.rs b/beacon_node/beacon_chain/tests/column_verification.rs index 6114bd7f45..5846ccfd7e 100644 --- a/beacon_node/beacon_chain/tests/column_verification.rs +++ b/beacon_node/beacon_chain/tests/column_verification.rs @@ -16,8 +16,8 @@ use types::*; type E = MainnetEthSpec; -// Should ideally be divisible by 3. -const VALIDATOR_COUNT: usize = 24; +// >= 32 validators required for Gloas genesis with MainnetEthSpec (32 slots/epoch). +const VALIDATOR_COUNT: usize = 32; /// A cached set of keys. static KEYPAIRS: LazyLock> = @@ -52,7 +52,8 @@ async fn rpc_columns_with_invalid_header_signature() { let spec = Arc::new(test_spec::()); // Only run this test if columns are enabled. - if !spec.is_fulu_scheduled() { + // TODO(gloas): Gloas blocks don't have blob_kzg_commitments — blobs are in the envelope. + if !spec.is_fulu_scheduled() || spec.is_gloas_scheduled() { return; } diff --git a/beacon_node/beacon_chain/tests/events.rs b/beacon_node/beacon_chain/tests/events.rs index 121f8c255d..5305965f0f 100644 --- a/beacon_node/beacon_chain/tests/events.rs +++ b/beacon_node/beacon_chain/tests/events.rs @@ -170,7 +170,10 @@ async fn blob_sidecar_event_on_process_rpc_blobs() { #[tokio::test] async fn data_column_sidecar_event_on_process_rpc_columns() { - if fork_name_from_env().is_none_or(|f| !f.fulu_enabled()) { + // Gloas blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if fork_name_from_env().is_none_or(|f| !f.fulu_enabled()) + || fork_name_from_env().is_some_and(|f| f.gloas_enabled()) + { return; }; diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index 947024e8c2..38d4f4c47e 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -371,7 +371,7 @@ impl InvalidPayloadRig { /// Simple test of the different import types. #[tokio::test] async fn valid_invalid_syncing() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new(); @@ -388,7 +388,7 @@ async fn valid_invalid_syncing() { /// `latest_valid_hash`. #[tokio::test] async fn invalid_payload_invalidates_parent() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); @@ -445,7 +445,7 @@ async fn immediate_forkchoice_update_invalid_test( #[tokio::test] async fn immediate_forkchoice_update_payload_invalid() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } immediate_forkchoice_update_invalid_test(|latest_valid_hash| Payload::Invalid { @@ -456,7 +456,7 @@ async fn immediate_forkchoice_update_payload_invalid() { #[tokio::test] async fn immediate_forkchoice_update_payload_invalid_block_hash() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } immediate_forkchoice_update_invalid_test(|_| Payload::InvalidBlockHash).await @@ -464,7 +464,7 @@ async fn immediate_forkchoice_update_payload_invalid_block_hash() { #[tokio::test] async fn immediate_forkchoice_update_payload_invalid_terminal_block() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } immediate_forkchoice_update_invalid_test(|_| Payload::Invalid { @@ -476,7 +476,7 @@ async fn immediate_forkchoice_update_payload_invalid_terminal_block() { /// Ensure the client tries to exit when the justified checkpoint is invalidated. #[tokio::test] async fn justified_checkpoint_becomes_invalid() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); @@ -520,7 +520,7 @@ async fn justified_checkpoint_becomes_invalid() { /// Ensure that a `latest_valid_hash` for a pre-finality block only reverts a single block. #[tokio::test] async fn pre_finalized_latest_valid_hash() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let num_blocks = E::slots_per_epoch() * 4; @@ -569,7 +569,7 @@ async fn pre_finalized_latest_valid_hash() { /// - Will not validate `latest_valid_root` and its ancestors. #[tokio::test] async fn latest_valid_hash_will_not_validate() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } const LATEST_VALID_SLOT: u64 = 3; @@ -618,7 +618,7 @@ async fn latest_valid_hash_will_not_validate() { /// Check behaviour when the `latest_valid_hash` is a junk value. #[tokio::test] async fn latest_valid_hash_is_junk() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let num_blocks = E::slots_per_epoch() * 5; @@ -661,7 +661,7 @@ async fn latest_valid_hash_is_junk() { /// Check that descendants of invalid blocks are also invalidated. #[tokio::test] async fn invalidates_all_descendants() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let num_blocks = E::slots_per_epoch() * 4 + E::slots_per_epoch() / 2; @@ -764,7 +764,7 @@ async fn invalidates_all_descendants() { /// Check that the head will switch after the canonical branch is invalidated. #[tokio::test] async fn switches_heads() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let num_blocks = E::slots_per_epoch() * 4 + E::slots_per_epoch() / 2; @@ -863,7 +863,7 @@ async fn switches_heads() { #[tokio::test] async fn invalid_during_processing() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new(); @@ -897,7 +897,7 @@ async fn invalid_during_processing() { #[tokio::test] async fn invalid_after_optimistic_sync() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); @@ -937,7 +937,7 @@ async fn invalid_after_optimistic_sync() { #[tokio::test] async fn manually_validate_child() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); @@ -957,7 +957,7 @@ async fn manually_validate_child() { #[tokio::test] async fn manually_validate_parent() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); @@ -977,7 +977,7 @@ async fn manually_validate_parent() { #[tokio::test] async fn payload_preparation() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new(); @@ -1034,13 +1034,14 @@ async fn payload_preparation() { fee_recipient, None, None, + None, ); assert_eq!(rig.previous_payload_attributes(), payload_attributes); } #[tokio::test] async fn invalid_parent() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new(); @@ -1107,7 +1108,7 @@ async fn invalid_parent() { #[tokio::test] async fn attesting_to_optimistic_head() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new(); @@ -1320,7 +1321,7 @@ impl InvalidHeadSetup { #[tokio::test] async fn recover_from_invalid_head_by_importing_blocks() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let InvalidHeadSetup { @@ -1362,7 +1363,7 @@ async fn recover_from_invalid_head_by_importing_blocks() { #[tokio::test] async fn recover_from_invalid_head_after_persist_and_reboot() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let InvalidHeadSetup { @@ -1407,7 +1408,7 @@ async fn recover_from_invalid_head_after_persist_and_reboot() { #[tokio::test] async fn weights_after_resetting_optimistic_status() { - if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled()) { + if fork_name_from_env().is_some_and(|f| !f.bellatrix_enabled() || f.gloas_enabled()) { return; } let mut rig = InvalidPayloadRig::new().enable_attestations(); diff --git a/beacon_node/beacon_chain/tests/rewards.rs b/beacon_node/beacon_chain/tests/rewards.rs index 1889c1f625..bc7c98041f 100644 --- a/beacon_node/beacon_chain/tests/rewards.rs +++ b/beacon_node/beacon_chain/tests/rewards.rs @@ -845,14 +845,13 @@ async fn check_all_base_rewards_for_subset( .state_at_slot(Slot::new(slot - 1), StateSkipConfig::WithoutStateRoots) .unwrap(); - // TODO(gloas): handle payloads? let mut pre_state = BlockReplayer::>::new( parent_state, &harness.spec, ) .no_signature_verification() .minimal_block_root_verification() - .apply_blocks(vec![], vec![], Some(block.slot())) + .apply_blocks(vec![], Some(block.slot())) .unwrap() .into_state(); diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index c6e13bd160..47bda60eb8 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -27,6 +27,7 @@ use beacon_chain::{ }; use bls::{Keypair, Signature, SignatureBytes}; use fixed_bytes::FixedBytesExtended; +use fork_choice::PayloadStatus; use logging::create_test_tracing_subscriber; use maplit::hashset; use rand::Rng; @@ -53,7 +54,7 @@ use types::test_utils::{SeedableRng, XorShiftRng}; use types::*; // Should ideally be divisible by 3. -pub const LOW_VALIDATOR_COUNT: usize = 24; +pub const LOW_VALIDATOR_COUNT: usize = 32; pub const HIGH_VALIDATOR_COUNT: usize = 64; // When set to true, cache any states fetched from the db. @@ -184,6 +185,10 @@ async fn light_client_bootstrap_test() { // No-op prior to Altair. return; }; + // TODO(EIP-7732): Light client not yet implemented for Gloas. + if spec.is_gloas_scheduled() { + return; + } let db_path = tempdir().unwrap(); let store = get_store_generic(&db_path, StoreConfig::default(), spec.clone()); @@ -239,6 +244,10 @@ async fn light_client_updates_test() { // No-op prior to Altair. return; }; + // TODO(EIP-7732): Light client not yet implemented for Gloas. + if spec.is_gloas_scheduled() { + return; + } let num_final_blocks = E::slots_per_epoch() * 2; let db_path = tempdir().unwrap(); @@ -568,13 +577,12 @@ async fn epoch_boundary_state_attestation_processing() { .get_blinded_block(&block_root) .unwrap() .expect("block exists"); - // Use get_state as the state may be finalized by this point + // Use get_state as the state may be finalized by this point. + let state_root = block.state_root(); let mut epoch_boundary_state = store - .get_state(&block.state_root(), None, CACHE_STATE_IN_TESTS) + .get_state(&state_root, None, CACHE_STATE_IN_TESTS) .expect("no error") - .unwrap_or_else(|| { - panic!("epoch boundary state should exist {:?}", block.state_root()) - }); + .unwrap_or_else(|| panic!("epoch boundary state should exist {:?}", state_root)); let ebs_state_root = epoch_boundary_state.update_tree_hash_cache().unwrap(); let mut ebs_of_ebs = store .get_state(&ebs_state_root, None, CACHE_STATE_IN_TESTS) @@ -673,8 +681,11 @@ async fn forwards_iter_block_and_state_roots_until() { let block_root = block_roots[slot.as_usize()]; assert_eq!(block_root_iter.next().unwrap().unwrap(), (block_root, slot)); + let (iter_state_root, iter_slot) = state_root_iter.next().unwrap().unwrap(); + assert_eq!(iter_slot, slot); + let state_root = state_roots[slot.as_usize()]; - assert_eq!(state_root_iter.next().unwrap().unwrap(), (state_root, slot)); + assert_eq!(iter_state_root, state_root); } }; @@ -708,13 +719,8 @@ async fn block_replayer_hooks() { .add_attested_blocks_at_slots(state.clone(), state_root, &block_slots, &all_validators) .await; - let (blocks, envelopes) = store - .load_blocks_to_replay( - Slot::new(0), - max_slot, - end_block_root.into(), - StatePayloadStatus::Pending, - ) + let blocks = store + .load_blocks_to_replay(Slot::new(0), max_slot, end_block_root.into()) .unwrap(); let mut pre_slots = vec![]; @@ -749,7 +755,7 @@ async fn block_replayer_hooks() { post_block_slots.push(block.slot()); Ok(()) })) - .apply_blocks(blocks, envelopes, None) + .apply_blocks(blocks, None) .unwrap() .into_state(); @@ -2871,12 +2877,6 @@ async fn reproduction_unaligned_checkpoint_sync_pruned_payload() { .block_root_at_slot(checkpoint_slot, WhenSlotSkipped::Prev) .unwrap() .unwrap(); - let wss_state_root = harness - .chain - .state_root_at_slot(checkpoint_slot) - .unwrap() - .unwrap(); - let wss_block = harness .chain .store @@ -2884,8 +2884,21 @@ async fn reproduction_unaligned_checkpoint_sync_pruned_payload() { .unwrap() .unwrap(); - // The test premise requires the anchor block to have a payload. - assert!(wss_block.message().execution_payload().is_ok()); + let wss_state_root = harness + .chain + .state_root_at_slot(checkpoint_slot) + .unwrap() + .unwrap(); + + // The test premise requires the anchor block to have a payload (or a payload bid in Gloas). + assert!( + wss_block.message().execution_payload().is_ok() + || wss_block + .message() + .body() + .signed_execution_payload_bid() + .is_ok() + ); let wss_blobs_opt = harness .chain @@ -2967,15 +2980,19 @@ async fn reproduction_unaligned_checkpoint_sync_pruned_payload() { chain.head_snapshot().beacon_state.slot() ); - let payload_exists = chain - .store - .execution_payload_exists(&wss_block_root) - .unwrap_or(false); + // In Gloas, the execution payload envelope is separate from the block and will be synced + // from the network. We don't check for its existence here. + if !wss_block.fork_name_unchecked().gloas_enabled() { + let payload_exists = chain + .store + .execution_payload_exists(&wss_block_root) + .unwrap_or(false); - assert!( - payload_exists, - "Split block payload must exist in the new node's store after checkpoint sync" - ); + assert!( + payload_exists, + "Split block payload must exist in the new node's store after checkpoint sync" + ); + } } async fn weak_subjectivity_sync_test( @@ -3013,18 +3030,17 @@ async fn weak_subjectivity_sync_test( .block_root_at_slot(checkpoint_slot, WhenSlotSkipped::Prev) .unwrap() .unwrap(); - let wss_state_root = harness - .chain - .state_root_at_slot(checkpoint_slot) - .unwrap() - .unwrap(); - let wss_block = harness .chain .store .get_full_block(&wss_block_root) .unwrap() .unwrap(); + let wss_state_root = harness + .chain + .state_root_at_slot(checkpoint_slot) + .unwrap() + .unwrap(); let wss_blobs_opt = harness .chain .get_or_reconstruct_blobs(&wss_block_root) @@ -3101,6 +3117,20 @@ async fn weak_subjectivity_sync_test( .build() .expect("should build"); + // Store the WSS envelope to simulate it arriving from network sync. + // In production, the envelope would be synced from the network after checkpoint sync. + if let Some(envelope) = harness + .chain + .store + .get_payload_envelope(&wss_block.canonical_root()) + .unwrap_or(None) + { + beacon_chain + .store + .put_payload_envelope(&wss_block.canonical_root(), &envelope) + .unwrap(); + } + let beacon_chain = Arc::new(beacon_chain); let wss_block_root = wss_block.canonical_root(); let store_wss_block = harness @@ -3120,6 +3150,21 @@ async fn weak_subjectivity_sync_test( assert_eq!(store_wss_blobs_opt, wss_blobs_opt); } + // Store the WSS block's envelope in the new chain (required for Gloas forward sync). + // The first forward block needs the checkpoint block's envelope to determine the parent's + // Full state. + if let Some(envelope) = harness + .chain + .store + .get_payload_envelope(&wss_block_root) + .unwrap() + { + beacon_chain + .store + .put_payload_envelope(&wss_block_root, &envelope) + .unwrap(); + } + // Apply blocks forward to reach head. let chain_dump = harness.chain.chain_dump().unwrap(); let new_blocks = chain_dump @@ -3154,6 +3199,21 @@ async fn weak_subjectivity_sync_test( ) .await .unwrap(); + + // Store the envelope and apply it to fork choice. + if let Some(envelope) = &snapshot.execution_envelope { + beacon_chain + .store + .put_payload_envelope(&block_root, envelope) + .unwrap(); + // Update fork choice so head selection accounts for Full payload status. + beacon_chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(block_root) + .unwrap(); + } + beacon_chain.recompute_head_at_current_slot().await; // Check that the new block's state can be loaded correctly. @@ -3305,6 +3365,17 @@ async fn weak_subjectivity_sync_test( } assert_eq!(beacon_chain.store.get_oldest_block_slot(), 0); + // Store envelopes for all historic blocks (needed for dumping the chain from the new node). + for snapshot in chain_dump.iter() { + let block_root = snapshot.beacon_block_root; + if let Some(envelope) = &snapshot.execution_envelope { + beacon_chain + .store + .put_payload_envelope(&block_root, envelope) + .unwrap(); + } + } + // Sanity check for non-aligned WSS starts, to make sure the WSS block is persisted properly if wss_block_slot != wss_state_slot { let new_node_block_root_at_wss_block = beacon_chain @@ -3374,13 +3445,12 @@ async fn weak_subjectivity_sync_test( assert_eq!(state.canonical_root().unwrap(), state_root); } - // Anchor slot is still set to the slot of the checkpoint block. - // Note: since hot tree states the anchor slot is set to the aligned ws state slot - // https://github.com/sigp/lighthouse/pull/6750 - let wss_aligned_slot = if checkpoint_slot % E::slots_per_epoch() == 0 { - checkpoint_slot + // Anchor slot is set to the WSS state slot, which is always epoch-aligned (the state is + // advanced to an epoch boundary during checkpoint sync). + let wss_aligned_slot = if wss_state_slot % E::slots_per_epoch() == 0 { + wss_state_slot } else { - (checkpoint_slot.epoch(E::slots_per_epoch()) + Epoch::new(1)) + (wss_state_slot.epoch(E::slots_per_epoch()) + Epoch::new(1)) .start_slot(E::slots_per_epoch()) }; assert_eq!(store.get_anchor_info().anchor_slot, wss_aligned_slot); @@ -3635,6 +3705,10 @@ async fn test_import_historical_data_columns_batch_no_block_found() { if fork_name_from_env().is_some_and(|f| !f.fulu_enabled()) { return; }; + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } let spec = test_spec::(); let db_path = tempdir().unwrap(); @@ -3745,12 +3819,14 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { let all_validators = (0..LOW_VALIDATOR_COUNT).collect::>(); - let split_slot = Slot::new(E::slots_per_epoch() * 4); + let finalized_epoch_start_slot = Slot::new(E::slots_per_epoch() * 4); let pre_skips = 1; let post_skips = 1; - // Build the chain up to the intended split slot, with 3 skips before the split. - let slots = (1..=split_slot.as_u64() - pre_skips) + let split_slot = finalized_epoch_start_slot; + + // Build the chain up to the intended finalized epoch slot, with 1 skip before the split. + let slots = (1..=finalized_epoch_start_slot.as_u64() - pre_skips) .map(Slot::new) .collect::>(); @@ -3769,20 +3845,26 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { // // - one that is invalid because it conflicts with finalization (slot <= finalized_slot) // - one that is valid because its slot is not finalized (slot > finalized_slot) + // + // Note: block verification uses finalized_checkpoint.epoch.start_slot() (== + // finalized_epoch_start_slot) for the finalized slot check. let (unadvanced_split_state, unadvanced_split_state_root) = harness.get_current_state_and_root(); let ((invalid_fork_block, _), _) = harness - .make_block(unadvanced_split_state.clone(), split_slot) + .make_block(unadvanced_split_state.clone(), finalized_epoch_start_slot) .await; let ((valid_fork_block, _), _) = harness - .make_block(unadvanced_split_state.clone(), split_slot + 1) + .make_block( + unadvanced_split_state.clone(), + finalized_epoch_start_slot + 1, + ) .await; // Advance the chain so that the intended split slot is finalized. // Do not attest in the epoch boundary slot, to make attestation production later easier (no // equivocations). - let finalizing_slot = split_slot + 2 * E::slots_per_epoch(); + let finalizing_slot = finalized_epoch_start_slot + 2 * E::slots_per_epoch(); for _ in 0..pre_skips + post_skips { harness.advance_slot(); } @@ -3834,12 +3916,7 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { let (split_state_root, mut advanced_split_state) = harness .chain .store - .get_advanced_hot_state( - split.block_root, - StatePayloadStatus::Pending, - split.slot, - split.state_root, - ) + .get_advanced_hot_state(split.block_root, split.slot, split.state_root) .unwrap() .unwrap(); complete_state_advance( @@ -3973,6 +4050,7 @@ async fn schema_downgrade_to_min_version(store_config: StoreConfig, archive: boo let num_blocks_produced = E::slots_per_epoch() * 4; let db_path = tempdir().unwrap(); let spec = test_spec::(); + let is_gloas = spec.is_gloas_scheduled(); let chain_config = ChainConfig { archive, @@ -3995,7 +4073,11 @@ async fn schema_downgrade_to_min_version(store_config: StoreConfig, archive: boo ) .await; - let min_version = SchemaVersion(28); + let min_version = if is_gloas { + SchemaVersion(29) + } else { + SchemaVersion(28) + }; // Save the slot clock so that the new harness doesn't revert in time. let slot_clock = harness.chain.slot_clock.clone(); @@ -4565,6 +4647,10 @@ async fn fulu_prune_data_columns_happy_case() { // No-op if PeerDAS not scheduled. return; } + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if store.get_chain_spec().is_gloas_scheduled() { + return; + } let Some(fulu_fork_epoch) = store.get_chain_spec().fulu_fork_epoch else { // No-op prior to Fulu. return; @@ -4620,6 +4706,10 @@ async fn fulu_prune_data_columns_no_finalization() { // No-op if PeerDAS not scheduled. return; } + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if store.get_chain_spec().is_gloas_scheduled() { + return; + } let Some(fulu_fork_epoch) = store.get_chain_spec().fulu_fork_epoch else { // No-op prior to Fulu. return; @@ -4839,6 +4929,10 @@ async fn fulu_prune_data_columns_margin_test(margin: u64) { // No-op if PeerDAS not scheduled. return; } + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if store.get_chain_spec().is_gloas_scheduled() { + return; + } let Some(fulu_fork_epoch) = store.get_chain_spec().fulu_fork_epoch else { // No-op prior to Fulu. return; @@ -5156,6 +5250,10 @@ async fn test_custody_column_filtering_regular_node() { if !test_spec::().is_peer_das_scheduled() { return; } + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if test_spec::().is_gloas_scheduled() { + return; + } let db_path = tempdir().unwrap(); let store = get_store(&db_path); @@ -5200,6 +5298,10 @@ async fn test_custody_column_filtering_supernode() { if !test_spec::().is_peer_das_scheduled() { return; } + // TODO(Gloas): blocks don't have blob_kzg_commitments (blobs are in the execution payload envelope). + if test_spec::().is_gloas_scheduled() { + return; + } let db_path = tempdir().unwrap(); let store = get_store(&db_path); @@ -5515,7 +5617,7 @@ async fn test_gloas_block_and_envelope_storage_generic( let mut state = genesis_state; let mut block_roots = vec![]; - let mut stored_states = vec![(Slot::new(0), StatePayloadStatus::Full, genesis_state_root)]; + let mut stored_states = vec![(Slot::new(0), genesis_state_root)]; for i in 1..=num_slots { let slot = Slot::new(i); @@ -5527,10 +5629,10 @@ async fn test_gloas_block_and_envelope_storage_generic( let state_root = state.canonical_root().unwrap(); store.put_state(&state_root, &state).unwrap(); - stored_states.push((slot, state.payload_status(), state_root)); + stored_states.push((slot, state_root)); } - let (block_contents, envelope, mut pending_state) = + let (block_contents, envelope, mut post_block_state) = harness.make_block_with_envelope(state, slot).await; let block_root = block_contents.0.canonical_root(); @@ -5540,21 +5642,17 @@ async fn test_gloas_block_and_envelope_storage_generic( .await .unwrap(); - let pending_state_root = pending_state.update_tree_hash_cache().unwrap(); - stored_states.push((slot, StatePayloadStatus::Pending, pending_state_root)); + let state_root = post_block_state.update_tree_hash_cache().unwrap(); + stored_states.push((slot, state_root)); // Process the envelope. let envelope = envelope.expect("Gloas block should have envelope"); - let mut full_state = pending_state.clone(); - let envelope_state_root = envelope.message.state_root; - let full_state_root = harness - .process_envelope(block_root, envelope, &mut full_state) + harness + .process_envelope(block_root, envelope, &post_block_state, state_root) .await; - assert_eq!(full_state_root, envelope_state_root); - stored_states.push((slot, StatePayloadStatus::Full, full_state_root)); block_roots.push(block_root); - state = full_state; + state = post_block_state; } // Verify block storage. @@ -5577,20 +5675,15 @@ async fn test_gloas_block_and_envelope_storage_generic( // Verify state storage. // Iterate in reverse order to frustrate the cache. - for (slot, payload_status, state_root) in stored_states.into_iter().rev() { + for (slot, state_root) in stored_states.into_iter().rev() { println!("{slot}: {state_root:?}"); let Some(mut loaded_state) = store .get_state(&state_root, Some(slot), CACHE_STATE_IN_TESTS) .unwrap() else { - panic!("missing {payload_status:?} state at slot {slot} with root {state_root:?}"); + panic!("missing state at slot {slot} with root {state_root:?}"); }; assert_eq!(loaded_state.slot(), slot); - assert_eq!( - loaded_state.payload_status(), - payload_status, - "slot = {slot}" - ); assert_eq!( loaded_state.canonical_root().unwrap(), state_root, @@ -5600,74 +5693,6 @@ async fn test_gloas_block_and_envelope_storage_generic( check_db_invariants(&harness); } -/// Test that Pending and Full states have the correct payload status through round-trip -/// storage and retrieval. -#[tokio::test] -async fn test_gloas_state_payload_status() { - if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { - return; - } - - let db_path = tempdir().unwrap(); - let store = get_store(&db_path); - let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); - - let num_blocks = 6u64; - let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); - let mut state = genesis_state; - - for i in 1..=num_blocks { - let slot = Slot::new(i); - harness.advance_slot(); - - let (block_contents, envelope, pending_state) = - harness.make_block_with_envelope(state, slot).await; - let block_root = block_contents.0.canonical_root(); - - harness - .process_block(slot, block_root, block_contents) - .await - .unwrap(); - - // Verify the pending state has correct payload status. - assert_eq!( - pending_state.payload_status(), - StatePayloadStatus::Pending, - "pending state at slot {} should be Pending", - i - ); - - // Process the envelope and verify the full state has correct payload status. - let envelope = envelope.expect("Gloas block should have envelope"); - let mut full_state = pending_state; - let full_state_root = harness - .process_envelope(block_root, envelope, &mut full_state) - .await; - - assert_eq!( - full_state.payload_status(), - StatePayloadStatus::Full, - "full state at slot {} should be Full", - i - ); - - // Round-trip: load the full state from DB and check status. - let loaded_full = store - .get_state(&full_state_root, None, CACHE_STATE_IN_TESTS) - .unwrap() - .expect("full state should exist in DB"); - assert_eq!( - loaded_full.payload_status(), - StatePayloadStatus::Full, - "loaded full state at slot {} should be Full after round-trip", - i - ); - - state = full_state; - } - check_db_invariants(&harness); -} - /// Test block replay with and without envelopes. #[tokio::test] async fn test_gloas_block_replay_with_envelopes() { @@ -5704,11 +5729,11 @@ async fn test_gloas_block_replay_with_envelopes() { pending_states.insert(slot, (pending_state_root, pending_state.clone())); let envelope = envelope.expect("Gloas block should have envelope"); - let mut full_state = pending_state; - let full_state_root = harness - .process_envelope(block_root, envelope, &mut full_state) + let full_state = pending_state; + harness + .process_envelope(block_root, envelope, &full_state, pending_state_root) .await; - full_states.insert(slot, (full_state_root, full_state.clone())); + full_states.insert(slot, (pending_state_root, full_state.clone())); last_block_root = block_root; state = full_state; @@ -5716,94 +5741,29 @@ async fn test_gloas_block_replay_with_envelopes() { let end_slot = Slot::new(num_blocks); - // Load blocks for Pending replay (no envelopes for the last block). - let (blocks_pending, envelopes_pending) = store - .load_blocks_to_replay( - Slot::new(0), - end_slot, - last_block_root, - StatePayloadStatus::Pending, - ) + // Load blocks for replay. + let blocks = store + .load_blocks_to_replay(Slot::new(0), end_slot, last_block_root) .unwrap(); - assert!( - !blocks_pending.is_empty(), - "should have blocks for pending replay" - ); - // For Pending, no envelope for the first block (slot 0) or last block; envelopes for - // intermediate blocks whose payloads are canonical. - let expected_pending_envelopes = blocks_pending.len().saturating_sub(2); + assert!(!blocks.is_empty(), "should have blocks for replay"); + + // Replay blocks and verify against the expected state. + let mut replayed = BlockReplayer::::new(genesis_state, store.get_chain_spec()) + .no_signature_verification() + .minimal_block_root_verification() + .apply_blocks(blocks, None) + .expect("should replay blocks") + .into_state(); + replayed.apply_pending_mutations().unwrap(); + + let (_, mut expected) = pending_states.get(&end_slot).unwrap().clone(); + expected.apply_pending_mutations().unwrap(); + + replayed.drop_all_caches().unwrap(); + expected.drop_all_caches().unwrap(); assert_eq!( - envelopes_pending.len(), - expected_pending_envelopes, - "pending replay should have envelopes for all blocks except the last" - ); - assert!( - blocks_pending - .iter() - .skip(1) - .take(envelopes_pending.len()) - .map(|block| block.slot()) - .eq(envelopes_pending - .iter() - .map(|envelope| envelope.message.slot)), - "block and envelope slots should match" - ); - - // Load blocks for Full replay (envelopes for all blocks including the last). - let (blocks_full, envelopes_full) = store - .load_blocks_to_replay( - Slot::new(0), - end_slot, - last_block_root, - StatePayloadStatus::Full, - ) - .unwrap(); - assert_eq!( - envelopes_full.len(), - expected_pending_envelopes + 1, - "full replay should have one more envelope than pending replay" - ); - - // Replay to Pending state and verify. - let mut replayed_pending = - BlockReplayer::::new(genesis_state.clone(), store.get_chain_spec()) - .no_signature_verification() - .minimal_block_root_verification() - .desired_state_payload_status(StatePayloadStatus::Pending) - .apply_blocks(blocks_pending, envelopes_pending, None) - .expect("should replay blocks to pending state") - .into_state(); - replayed_pending.apply_pending_mutations().unwrap(); - - let (_, mut expected_pending) = pending_states.get(&end_slot).unwrap().clone(); - expected_pending.apply_pending_mutations().unwrap(); - - replayed_pending.drop_all_caches().unwrap(); - expected_pending.drop_all_caches().unwrap(); - assert_eq!( - replayed_pending, expected_pending, - "replayed pending state should match stored pending state" - ); - - // Replay to Full state and verify. - let mut replayed_full = - BlockReplayer::::new(genesis_state, store.get_chain_spec()) - .no_signature_verification() - .minimal_block_root_verification() - .desired_state_payload_status(StatePayloadStatus::Full) - .apply_blocks(blocks_full, envelopes_full, None) - .expect("should replay blocks to full state") - .into_state(); - replayed_full.apply_pending_mutations().unwrap(); - - let (_, mut expected_full) = full_states.get(&end_slot).unwrap().clone(); - expected_full.apply_pending_mutations().unwrap(); - - replayed_full.drop_all_caches().unwrap(); - expected_full.drop_all_caches().unwrap(); - assert_eq!( - replayed_full, expected_full, - "replayed full state should match stored full state" + replayed, expected, + "replayed state should match stored state" ); check_db_invariants(&harness); } @@ -5836,40 +5796,43 @@ async fn test_gloas_hot_state_hierarchy() { let slot = Slot::new(i); harness.advance_slot(); - let (block_contents, envelope, pending_state) = + let (block_contents, envelope, mut pending_state) = harness.make_block_with_envelope(state.clone(), slot).await; let block_root = block_contents.0.canonical_root(); - - // Attest to previous block before processing next. - if i > 1 { - let state_root = state.update_tree_hash_cache().unwrap(); - harness.attest_block( - &state, - state_root, - last_block_root.into(), - &block_contents.0, - &some_validators, - ); - } + let signed_block = block_contents.0.clone(); harness .process_block(slot, block_root, block_contents) .await .unwrap(); + // Attest to the current block at its own slot (same-slot attestation). + // In Gloas, same-slot attestations have index=0 and route to Pending in + // fork choice, correctly propagating weight through the Full path. + // Use pending_state (at slot i) so the target root resolves correctly. + let pending_state_root = pending_state.update_tree_hash_cache().unwrap(); + harness.attest_block( + &pending_state, + pending_state_root, + block_root.into(), + &signed_block, + &some_validators, + ); + let envelope = envelope.expect("Gloas block should have envelope"); - let mut full_state = pending_state; + let full_state = pending_state; harness - .process_envelope(block_root, envelope, &mut full_state) + .process_envelope(block_root, envelope, &full_state, pending_state_root) .await; last_block_root = block_root; state = full_state; } - // Verify states can be loaded and have correct payload status. - let _head_state = harness.get_current_state(); - let _head_slot = harness.head_slot(); + // Head should be the block at slot 40 with full payload. + let head = harness.chain.canonical_head.cached_head(); + assert_eq!(head.head_block_root(), last_block_root); + assert_eq!(head.head_payload_status(), PayloadStatus::Full); // States at all slots on the canonical chain should be retrievable. for slot_num in 1..=num_blocks { @@ -5880,7 +5843,7 @@ async fn test_gloas_hot_state_hierarchy() { let mut loaded_state = store .get_state(&state_root, Some(slot), CACHE_STATE_IN_TESTS) .unwrap() - .unwrap(); + .unwrap_or_else(|| panic!("missing state at {slot}/{state_root:?}")); assert_eq!(loaded_state.canonical_root().unwrap(), state_root); } diff --git a/beacon_node/beacon_chain/tests/tests.rs b/beacon_node/beacon_chain/tests/tests.rs index 10c0b429a9..3958ce6c6d 100644 --- a/beacon_node/beacon_chain/tests/tests.rs +++ b/beacon_node/beacon_chain/tests/tests.rs @@ -115,7 +115,18 @@ fn massive_skips() { assert!(state.slot() > 1, "the state should skip at least one slot"); - if state.fork_name_unchecked().fulu_enabled() { + if state.fork_name_unchecked().gloas_enabled() { + // Gloas uses compute_balance_weighted_selection for proposer selection, which + // returns InvalidIndicesCount (not InsufficientValidators) when the active + // validator set is empty. + assert_eq!( + error, + SlotProcessingError::EpochProcessingError(EpochProcessingError::BeaconStateError( + BeaconStateError::InvalidIndicesCount + )), + "should return error indicating that validators have been slashed out" + ) + } else if state.fork_name_unchecked().fulu_enabled() { // post-fulu this is done in per_epoch_processing assert_eq!( error, @@ -1006,9 +1017,12 @@ async fn pseudo_finalize_test_generic( }; // pseudo finalize + // Post-Gloas the finalized state must be Pending (the block's state_root), not Full + // (the envelope's state_root), because the payload of the finalized block is not finalized. + let finalized_state_root = head.beacon_block.message().state_root(); harness .chain - .manually_finalize_state(head.beacon_state_root(), checkpoint) + .manually_finalize_state(finalized_state_root, checkpoint) .unwrap(); let split = harness.chain.store.get_split_info(); diff --git a/beacon_node/beacon_chain/tests/validator_monitor.rs b/beacon_node/beacon_chain/tests/validator_monitor.rs index 521fc4ac97..a37ab6458f 100644 --- a/beacon_node/beacon_chain/tests/validator_monitor.rs +++ b/beacon_node/beacon_chain/tests/validator_monitor.rs @@ -117,7 +117,8 @@ async fn missed_blocks_across_epochs() { #[tokio::test] async fn missed_blocks_basic() { - let validator_count = 16; + // >= 32 validators required for Gloas genesis with MainnetEthSpec (32 slots/epoch). + let validator_count = 32; let slots_per_epoch = E::slots_per_epoch(); diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index 9c19e94c0e..236340aa29 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -1,11 +1,11 @@ use crate::engines::ForkchoiceState; use crate::http::{ ENGINE_FORKCHOICE_UPDATED_V1, ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3, - ENGINE_GET_BLOBS_V1, ENGINE_GET_BLOBS_V2, ENGINE_GET_CLIENT_VERSION_V1, - ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1, - ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, - ENGINE_GET_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V1, ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, - ENGINE_NEW_PAYLOAD_V4, ENGINE_NEW_PAYLOAD_V5, + ENGINE_FORKCHOICE_UPDATED_V4, ENGINE_GET_BLOBS_V1, ENGINE_GET_BLOBS_V2, + ENGINE_GET_CLIENT_VERSION_V1, ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, + ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1, ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, + ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V1, + ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, ENGINE_NEW_PAYLOAD_V5, }; use eth2::types::{ BlobsBundle, SsePayloadAttributes, SsePayloadAttributesV1, SsePayloadAttributesV2, @@ -158,7 +158,7 @@ impl ExecutionBlock { } #[superstruct( - variants(V1, V2, V3), + variants(V1, V2, V3, V4), variant_attributes(derive(Clone, Debug, Eq, Hash, PartialEq),), cast_error(ty = "Error", expr = "Error::IncorrectStateVariant"), partial_getter_error(ty = "Error", expr = "Error::IncorrectStateVariant") @@ -171,10 +171,12 @@ pub struct PayloadAttributes { pub prev_randao: Hash256, #[superstruct(getter(copy))] pub suggested_fee_recipient: Address, - #[superstruct(only(V2, V3))] + #[superstruct(only(V2, V3, V4))] pub withdrawals: Vec, - #[superstruct(only(V3), partial_getter(copy))] + #[superstruct(only(V3, V4), partial_getter(copy))] pub parent_beacon_block_root: Hash256, + #[superstruct(only(V4), partial_getter(copy))] + pub slot_number: u64, } impl PayloadAttributes { @@ -184,24 +186,35 @@ impl PayloadAttributes { suggested_fee_recipient: Address, withdrawals: Option>, parent_beacon_block_root: Option, + slot_number: Option, ) -> Self { - match withdrawals { - Some(withdrawals) => match parent_beacon_block_root { - Some(parent_beacon_block_root) => PayloadAttributes::V3(PayloadAttributesV3 { + match (withdrawals, parent_beacon_block_root, slot_number) { + (Some(withdrawals), Some(parent_beacon_block_root), Some(slot_number)) => { + PayloadAttributes::V4(PayloadAttributesV4 { timestamp, prev_randao, suggested_fee_recipient, withdrawals, parent_beacon_block_root, - }), - None => PayloadAttributes::V2(PayloadAttributesV2 { + slot_number, + }) + } + (Some(withdrawals), Some(parent_beacon_block_root), None) => { + PayloadAttributes::V3(PayloadAttributesV3 { timestamp, prev_randao, suggested_fee_recipient, withdrawals, - }), - }, - None => PayloadAttributes::V1(PayloadAttributesV1 { + parent_beacon_block_root, + }) + } + (Some(withdrawals), None, _) => PayloadAttributes::V2(PayloadAttributesV2 { + timestamp, + prev_randao, + suggested_fee_recipient, + withdrawals, + }), + (None, _, _) => PayloadAttributes::V1(PayloadAttributesV1 { timestamp, prev_randao, suggested_fee_recipient, @@ -246,6 +259,21 @@ impl From for SsePayloadAttributes { withdrawals, parent_beacon_block_root, }), + // V4 maps to V3 for SSE (slot_number is not part of the SSE spec) + PayloadAttributes::V4(PayloadAttributesV4 { + timestamp, + prev_randao, + suggested_fee_recipient, + withdrawals, + parent_beacon_block_root, + slot_number: _, + }) => Self::V3(SsePayloadAttributesV3 { + timestamp, + prev_randao, + suggested_fee_recipient, + withdrawals, + parent_beacon_block_root, + }), } } } @@ -555,6 +583,7 @@ pub struct EngineCapabilities { pub forkchoice_updated_v1: bool, pub forkchoice_updated_v2: bool, pub forkchoice_updated_v3: bool, + pub forkchoice_updated_v4: bool, pub get_payload_bodies_by_hash_v1: bool, pub get_payload_bodies_by_range_v1: bool, pub get_payload_v1: bool, @@ -594,6 +623,9 @@ impl EngineCapabilities { if self.forkchoice_updated_v3 { response.push(ENGINE_FORKCHOICE_UPDATED_V3); } + if self.forkchoice_updated_v4 { + response.push(ENGINE_FORKCHOICE_UPDATED_V4); + } if self.get_payload_bodies_by_hash_v1 { response.push(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1); } diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index bcd95d1ae4..dcf8205406 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -48,6 +48,7 @@ pub const ENGINE_GET_PAYLOAD_TIMEOUT: Duration = Duration::from_secs(2); pub const ENGINE_FORKCHOICE_UPDATED_V1: &str = "engine_forkchoiceUpdatedV1"; pub const ENGINE_FORKCHOICE_UPDATED_V2: &str = "engine_forkchoiceUpdatedV2"; pub const ENGINE_FORKCHOICE_UPDATED_V3: &str = "engine_forkchoiceUpdatedV3"; +pub const ENGINE_FORKCHOICE_UPDATED_V4: &str = "engine_forkchoiceUpdatedV4"; pub const ENGINE_FORKCHOICE_UPDATED_TIMEOUT: Duration = Duration::from_secs(8); pub const ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1: &str = "engine_getPayloadBodiesByHashV1"; @@ -84,6 +85,7 @@ pub static LIGHTHOUSE_CAPABILITIES: &[&str] = &[ ENGINE_FORKCHOICE_UPDATED_V1, ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3, + ENGINE_FORKCHOICE_UPDATED_V4, ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1, ENGINE_GET_CLIENT_VERSION_V1, @@ -1132,6 +1134,27 @@ impl HttpJsonRpc { Ok(response.into()) } + pub async fn forkchoice_updated_v4( + &self, + forkchoice_state: ForkchoiceState, + payload_attributes: Option, + ) -> Result { + let params = json!([ + JsonForkchoiceStateV1::from(forkchoice_state), + payload_attributes.map(JsonPayloadAttributes::from) + ]); + + let response: JsonForkchoiceUpdatedV1Response = self + .rpc_request( + ENGINE_FORKCHOICE_UPDATED_V4, + params, + ENGINE_FORKCHOICE_UPDATED_TIMEOUT * self.execution_timeout_multiplier, + ) + .await?; + + Ok(response.into()) + } + pub async fn get_payload_bodies_by_hash_v1( &self, block_hashes: Vec, @@ -1204,6 +1227,7 @@ impl HttpJsonRpc { forkchoice_updated_v1: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V1), forkchoice_updated_v2: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V2), forkchoice_updated_v3: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V3), + forkchoice_updated_v4: capabilities.contains(ENGINE_FORKCHOICE_UPDATED_V4), get_payload_bodies_by_hash_v1: capabilities .contains(ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1), get_payload_bodies_by_range_v1: capabilities @@ -1449,6 +1473,16 @@ impl HttpJsonRpc { )) } } + PayloadAttributes::V4(_) => { + if engine_capabilities.forkchoice_updated_v4 { + self.forkchoice_updated_v4(forkchoice_state, maybe_payload_attributes) + .await + } else { + Err(Error::RequiredMethodUnsupported( + "engine_forkchoiceUpdatedV4", + )) + } + } } } else if engine_capabilities.forkchoice_updated_v3 { self.forkchoice_updated_v3(forkchoice_state, maybe_payload_attributes) diff --git a/beacon_node/execution_layer/src/engine_api/json_structures.rs b/beacon_node/execution_layer/src/engine_api/json_structures.rs index 97c8e8a625..a77861981f 100644 --- a/beacon_node/execution_layer/src/engine_api/json_structures.rs +++ b/beacon_node/execution_layer/src/engine_api/json_structures.rs @@ -107,6 +107,12 @@ pub struct JsonExecutionPayload { #[superstruct(only(Deneb, Electra, Fulu, Gloas))] #[serde(with = "serde_utils::u64_hex_be")] pub excess_blob_gas: u64, + #[superstruct(only(Gloas))] + #[serde(with = "ssz_types::serde_utils::hex_var_list")] + pub block_access_list: VariableList, + #[superstruct(only(Gloas))] + #[serde(with = "serde_utils::u64_hex_be")] + pub slot_number: u64, } impl From> for JsonExecutionPayloadBellatrix { @@ -252,6 +258,8 @@ impl TryFrom> for JsonExecutionPayloadGloas withdrawals: withdrawals_to_json(payload.withdrawals)?, blob_gas_used: payload.blob_gas_used, excess_blob_gas: payload.excess_blob_gas, + block_access_list: payload.block_access_list, + slot_number: payload.slot_number.into(), }) } } @@ -425,6 +433,8 @@ impl TryFrom> for ExecutionPayloadGloas withdrawals: withdrawals_from_json(payload.withdrawals)?, blob_gas_used: payload.blob_gas_used, excess_blob_gas: payload.excess_blob_gas, + block_access_list: payload.block_access_list, + slot_number: payload.slot_number.into(), }) } } @@ -716,7 +726,7 @@ impl<'a> From<&'a JsonWithdrawal> for EncodableJsonWithdrawal<'a> { } #[superstruct( - variants(V1, V2, V3), + variants(V1, V2, V3, V4), variant_attributes( derive(Debug, Clone, PartialEq, Serialize, Deserialize), serde(rename_all = "camelCase") @@ -732,10 +742,13 @@ pub struct JsonPayloadAttributes { pub prev_randao: Hash256, #[serde(with = "serde_utils::address_hex")] pub suggested_fee_recipient: Address, - #[superstruct(only(V2, V3))] + #[superstruct(only(V2, V3, V4))] pub withdrawals: Vec, - #[superstruct(only(V3))] + #[superstruct(only(V3, V4))] pub parent_beacon_block_root: Hash256, + #[superstruct(only(V4))] + #[serde(with = "serde_utils::u64_hex_be")] + pub slot_number: u64, } impl From for JsonPayloadAttributes { @@ -759,6 +772,14 @@ impl From for JsonPayloadAttributes { withdrawals: pa.withdrawals.into_iter().map(Into::into).collect(), parent_beacon_block_root: pa.parent_beacon_block_root, }), + PayloadAttributes::V4(pa) => Self::V4(JsonPayloadAttributesV4 { + timestamp: pa.timestamp, + prev_randao: pa.prev_randao, + suggested_fee_recipient: pa.suggested_fee_recipient, + withdrawals: pa.withdrawals.into_iter().map(Into::into).collect(), + parent_beacon_block_root: pa.parent_beacon_block_root, + slot_number: pa.slot_number, + }), } } } @@ -784,6 +805,14 @@ impl From for PayloadAttributes { withdrawals: jpa.withdrawals.into_iter().map(Into::into).collect(), parent_beacon_block_root: jpa.parent_beacon_block_root, }), + JsonPayloadAttributes::V4(jpa) => Self::V4(PayloadAttributesV4 { + timestamp: jpa.timestamp, + prev_randao: jpa.prev_randao, + suggested_fee_recipient: jpa.suggested_fee_recipient, + withdrawals: jpa.withdrawals.into_iter().map(Into::into).collect(), + parent_beacon_block_root: jpa.parent_beacon_block_root, + slot_number: jpa.slot_number, + }), } } } diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index a66f7a9b55..ace6276b75 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -735,6 +735,9 @@ impl ExecutionBlockGenerator { blob_gas_used: 0, excess_blob_gas: 0, }), + _ => unreachable!(), + }, + PayloadAttributes::V4(pa) => match self.get_fork_at_timestamp(pa.timestamp) { ForkName::Gloas => ExecutionPayload::Gloas(ExecutionPayloadGloas { parent_hash: head_block_hash, fee_recipient: pa.suggested_fee_recipient, @@ -753,6 +756,8 @@ impl ExecutionBlockGenerator { withdrawals: pa.withdrawals.clone().try_into().unwrap(), blob_gas_used: 0, excess_blob_gas: 0, + block_access_list: VariableList::empty(), + slot_number: pa.slot_number.into(), }), _ => unreachable!(), }, diff --git a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs index e263e5402a..058f1e76da 100644 --- a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs +++ b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs @@ -507,7 +507,8 @@ pub async fn handle_rpc( } ENGINE_FORKCHOICE_UPDATED_V1 | ENGINE_FORKCHOICE_UPDATED_V2 - | ENGINE_FORKCHOICE_UPDATED_V3 => { + | ENGINE_FORKCHOICE_UPDATED_V3 + | ENGINE_FORKCHOICE_UPDATED_V4 => { let forkchoice_state: JsonForkchoiceStateV1 = get_param(params, 0).map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?; let payload_attributes = match method { @@ -554,6 +555,11 @@ pub async fn handle_rpc( .map(|opt| opt.map(JsonPayloadAttributes::V3)) .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))? } + ENGINE_FORKCHOICE_UPDATED_V4 => { + get_param::>(params, 1) + .map(|opt| opt.map(JsonPayloadAttributes::V4)) + .map_err(|s| (s, BAD_PARAMS_ERROR_CODE))? + } _ => unreachable!(), }; @@ -607,7 +613,7 @@ pub async fn handle_rpc( )); } } - ForkName::Deneb | ForkName::Electra | ForkName::Fulu | ForkName::Gloas => { + ForkName::Deneb | ForkName::Electra | ForkName::Fulu => { if method == ENGINE_FORKCHOICE_UPDATED_V1 { return Err(( format!("{} called after Deneb fork!", method), @@ -621,6 +627,14 @@ pub async fn handle_rpc( )); } } + ForkName::Gloas => { + if method != ENGINE_FORKCHOICE_UPDATED_V4 { + return Err(( + format!("{} called after Gloas fork! Use V4.", method), + FORK_REQUEST_MISMATCH_ERROR_CODE, + )); + } + } _ => unreachable!(), }; } diff --git a/beacon_node/execution_layer/src/test_utils/mock_builder.rs b/beacon_node/execution_layer/src/test_utils/mock_builder.rs index 7b6c4e8310..6ab6cca3f6 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_builder.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_builder.rs @@ -898,16 +898,24 @@ impl MockBuilder { fee_recipient, expected_withdrawals, None, + None, + ), + ForkName::Deneb | ForkName::Electra | ForkName::Fulu => PayloadAttributes::new( + timestamp, + *prev_randao, + fee_recipient, + expected_withdrawals, + Some(head_block_root), + None, + ), + ForkName::Gloas => PayloadAttributes::new( + timestamp, + *prev_randao, + fee_recipient, + expected_withdrawals, + Some(head_block_root), + Some(slot.as_u64()), ), - ForkName::Deneb | ForkName::Electra | ForkName::Fulu | ForkName::Gloas => { - PayloadAttributes::new( - timestamp, - *prev_randao, - fee_recipient, - expected_withdrawals, - Some(head_block_root), - ) - } ForkName::Base | ForkName::Altair => { return Err("invalid fork".to_string()); } diff --git a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs index 91966ff65e..288416d51e 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs @@ -96,8 +96,14 @@ impl MockExecutionLayer { justified_hash: None, finalized_hash: None, }; - let payload_attributes = - PayloadAttributes::new(timestamp, prev_randao, Address::repeat_byte(42), None, None); + let payload_attributes = PayloadAttributes::new( + timestamp, + prev_randao, + Address::repeat_byte(42), + None, + None, + None, + ); // Insert a proposer to ensure the fork choice updated command works. let slot = Slot::new(0); @@ -124,8 +130,14 @@ impl MockExecutionLayer { chain_health: ChainHealth::Healthy, }; let suggested_fee_recipient = self.el.get_suggested_fee_recipient(validator_index).await; - let payload_attributes = - PayloadAttributes::new(timestamp, prev_randao, suggested_fee_recipient, None, None); + let payload_attributes = PayloadAttributes::new( + timestamp, + prev_randao, + suggested_fee_recipient, + None, + None, + None, + ); let payload_parameters = PayloadParameters { parent_hash, @@ -171,8 +183,14 @@ impl MockExecutionLayer { chain_health: ChainHealth::Healthy, }; let suggested_fee_recipient = self.el.get_suggested_fee_recipient(validator_index).await; - let payload_attributes = - PayloadAttributes::new(timestamp, prev_randao, suggested_fee_recipient, None, None); + let payload_attributes = PayloadAttributes::new( + timestamp, + prev_randao, + suggested_fee_recipient, + None, + None, + None, + ); let payload_parameters = PayloadParameters { parent_hash, diff --git a/beacon_node/execution_layer/src/test_utils/mod.rs b/beacon_node/execution_layer/src/test_utils/mod.rs index 47e3c9064c..6d8c30d316 100644 --- a/beacon_node/execution_layer/src/test_utils/mod.rs +++ b/beacon_node/execution_layer/src/test_utils/mod.rs @@ -47,6 +47,7 @@ pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = EngineCapabilities { forkchoice_updated_v1: true, forkchoice_updated_v2: true, forkchoice_updated_v3: true, + forkchoice_updated_v4: true, get_payload_bodies_by_hash_v1: true, get_payload_bodies_by_range_v1: true, get_payload_v1: true, diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs index 4a974c9919..382b967b43 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs @@ -91,7 +91,7 @@ pub async fn publish_execution_payload_envelope( chain: Arc>, network_tx: &UnboundedSender>, ) -> Result, Rejection> { - let slot = envelope.message.slot; + let slot = envelope.slot(); let beacon_block_root = envelope.message.beacon_block_root; // TODO(gloas): Replace this check once we have gossip validation. @@ -161,9 +161,7 @@ pub(crate) fn get_beacon_execution_payload_envelope( )) })?; - let fork_name = chain - .spec - .fork_name_at_slot::(envelope.message.slot); + let fork_name = chain.spec.fork_name_at_slot::(envelope.slot()); match accept_header { Some(api_types::Accept::Ssz) => Response::builder() diff --git a/beacon_node/http_api/src/sync_committee_rewards.rs b/beacon_node/http_api/src/sync_committee_rewards.rs index 8715fc2b1e..9bc1f6ead4 100644 --- a/beacon_node/http_api/src/sync_committee_rewards.rs +++ b/beacon_node/http_api/src/sync_committee_rewards.rs @@ -66,12 +66,11 @@ pub fn get_state_before_applying_block( }) .map_err(|e| custom_not_found(format!("Parent state is not available! {:?}", e)))?; - // TODO(gloas): handle payloads? let replayer = BlockReplayer::new(parent_state, &chain.spec) .no_signature_verification() .state_root_iter([Ok((parent_block.state_root(), parent_block.slot()))].into_iter()) .minimal_block_root_verification() - .apply_blocks(vec![], vec![], Some(block.slot())) + .apply_blocks(vec![], Some(block.slot())) .map_err(unhandled_error::)?; Ok(replayer.into_state()) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index bf8443929c..2dd4c28040 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -3937,7 +3937,7 @@ impl ApiTester { .cloned() .expect("envelope should exist in pending cache for local building"); assert_eq!(envelope.beacon_block_root, block_root); - assert_eq!(envelope.slot, slot); + assert_eq!(envelope.slot(), slot); } /// Assert envelope fields match the expected block root and slot. @@ -3948,9 +3948,8 @@ impl ApiTester { slot: Slot, ) { assert_eq!(envelope.beacon_block_root, block_root); - assert_eq!(envelope.slot, slot); + assert_eq!(envelope.slot(), slot); assert_eq!(envelope.builder_index, BUILDER_INDEX_SELF_BUILD); - assert_ne!(envelope.state_root, Hash256::ZERO); } /// Sign an execution payload envelope. diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index d0f0557223..76c6ba812d 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -20,7 +20,6 @@ use beacon_chain::test_utils::{ use beacon_chain::{BeaconChain, WhenSlotSkipped}; use beacon_processor::{work_reprocessing_queue::*, *}; use bls::Signature; -use fixed_bytes::FixedBytesExtended; use itertools::Itertools; use libp2p::gossipsub::MessageAcceptance; use lighthouse_network::rpc::InboundRequestId; @@ -2125,12 +2124,13 @@ fn make_test_payload_envelope( ) -> SignedExecutionPayloadEnvelope { SignedExecutionPayloadEnvelope { message: ExecutionPayloadEnvelope { - payload: ExecutionPayloadGloas::default(), + payload: ExecutionPayloadGloas { + slot_number: slot, + ..ExecutionPayloadGloas::default() + }, execution_requests: ExecutionRequests::default(), builder_index: 0, beacon_block_root, - slot, - state_root: Hash256::zero(), }, signature: Signature::empty(), } @@ -2158,7 +2158,7 @@ async fn test_payload_envelopes_by_range() { let envelope = make_test_payload_envelope(Slot::new(slot), root); rig.chain .store - .put_payload_envelope(&root, envelope) + .put_payload_envelope(&root, &envelope) .unwrap(); expected_roots.push(root); } @@ -2208,7 +2208,7 @@ async fn test_payload_envelopes_by_root() { let envelope = make_test_payload_envelope(Slot::new(1), block_root); rig.chain .store - .put_payload_envelope(&block_root, envelope) + .put_payload_envelope(&block_root, &envelope) .unwrap(); let roots = RuntimeVariableList::new(vec![block_root], 1).unwrap(); @@ -2298,7 +2298,7 @@ async fn test_payload_envelopes_by_range_no_duplicates_with_skip_slots() { let envelope = make_test_payload_envelope(Slot::new(slot), root); rig.chain .store - .put_payload_envelope(&root, envelope) + .put_payload_envelope(&root, &envelope) .unwrap(); } } diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index 78dd69e55a..e9b9de76e6 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -1064,7 +1064,7 @@ impl, Cold: ItemStore> HotColdDB pub fn put_payload_envelope( &self, block_root: &Hash256, - payload_envelope: SignedExecutionPayloadEnvelope, + payload_envelope: &SignedExecutionPayloadEnvelope, ) -> Result<(), Error> { self.hot_db.put_bytes( SignedExecutionPayloadEnvelope::::db_column(), @@ -1133,13 +1133,10 @@ impl, Cold: ItemStore> HotColdDB pub fn get_advanced_hot_state( &self, block_root: Hash256, - payload_status: StatePayloadStatus, max_slot: Slot, state_root: Hash256, ) -> Result)>, Error> { - if let Some(cached) = - self.get_advanced_hot_state_from_cache(block_root, payload_status, max_slot) - { + if let Some(cached) = self.get_advanced_hot_state_from_cache(block_root, max_slot) { return Ok(Some(cached)); } @@ -1161,11 +1158,7 @@ impl, Cold: ItemStore> HotColdDB .into()); } - // Split state should always be `Pending`. - let state_root = if block_root == split.block_root - && let StatePayloadStatus::Pending = payload_status - && split.slot <= max_slot - { + let state_root = if block_root == split.block_root && split.slot <= max_slot { split.state_root } else { state_root @@ -1212,12 +1205,11 @@ impl, Cold: ItemStore> HotColdDB pub fn get_advanced_hot_state_from_cache( &self, block_root: Hash256, - payload_status: StatePayloadStatus, max_slot: Slot, ) -> Option<(Hash256, BeaconState)> { self.state_cache .lock() - .get_by_block_root(block_root, payload_status, max_slot) + .get_by_block_root(block_root, max_slot) } /// Delete a state, ensuring it is removed from the LRU cache, as well as from on-disk. @@ -1857,100 +1849,6 @@ impl, Cold: ItemStore> HotColdDB } } - /// Compute the `StatePayloadStatus` for a stored state based on its summary. - /// - /// In future this might become a field of the summary, but this would require a whole DB - /// migration. For now we use an extra read from the DB to determine it. - fn get_hot_state_summary_payload_status( - &self, - summary: &HotStateSummary, - ) -> Result { - // Treat pre-Gloas states as `Pending`. - if !self - .spec - .fork_name_at_slot::(summary.slot) - .gloas_enabled() - { - return Ok(StatePayloadStatus::Pending); - } - - // Treat genesis state as `Pending` (`BeaconBlock` state). - let previous_state_root = summary.previous_state_root; - if previous_state_root.is_zero() { - return Ok(StatePayloadStatus::Pending); - } - - // Load the hot state summary for the previous state. - // - // If it has the same slot as this summary then we know this summary is for a `Full` state - // (payload state), because they are always diffed against their same-slot `Pending` state. - // - // If the previous summary has a different slot AND the latest block is from `summary.slot`, - // then this state *must* be `Pending` (it is the summary for latest block itself). - // - // Otherwise, we are at a skipped slot and must traverse the graph of state summaries - // backwards until we reach a summary for the latest block. This recursion could be quite - // far in the case of a long skip. We could optimise this in future using the - // `diff_base_state` (like in `get_ancestor_state_root`), or by doing a proper DB - // migration. - let previous_state_summary = self - .load_hot_state_summary(&previous_state_root)? - .ok_or(Error::MissingHotStateSummary(previous_state_root))?; - - if previous_state_summary.slot == summary.slot { - Ok(StatePayloadStatus::Full) - } else if summary.slot == summary.latest_block_slot { - Ok(StatePayloadStatus::Pending) - } else { - self.get_hot_state_summary_payload_status(&previous_state_summary) - } - } - - /// Recompute the payload status for a state at `slot` that is stored in the cold DB. - /// - /// This function returns an error for any `slot` that is outside the range of slots stored in - /// the freezer DB. - /// - /// For all slots prior to Gloas, it returns `Pending`. - /// - /// For post-Gloas slots the algorithm is: - /// - /// 1. Load the most recently applied block at `slot` (may not be from `slot` in case of a skip) - /// 2. Load the canonical `state_root` at the slot of the block. If this `state_root` matches - /// the one in the block then we know the state at *that* slot is canonically empty (no - /// payload). Conversely, if it is different, we know that the block's slot is full (assuming - /// no database corruption). - /// 3. The payload status of `slot` is the same as the payload status of `block.slot()`, because - /// we only care about whether a beacon block or payload was applied most recently, and - /// `block` is by definition the most-recently-applied block. - /// - /// All of this mucking around could be avoided if we do a schema migration to record the - /// payload status in the database. For now, this is simpler. - fn get_cold_state_payload_status(&self, slot: Slot) -> Result { - // Pre-Gloas states are always `Pending`. - if !self.spec.fork_name_at_slot::(slot).gloas_enabled() { - return Ok(StatePayloadStatus::Pending); - } - - let block_root = self - .get_cold_block_root(slot)? - .ok_or(HotColdDBError::MissingFrozenBlock(slot))?; - - let block = self - .get_blinded_block(&block_root)? - .ok_or(Error::MissingBlock(block_root))?; - - let state_root = self - .get_cold_state_root(block.slot())? - .ok_or(HotColdDBError::MissingRestorePointState(block.slot()))?; - - if block.state_root() != state_root { - Ok(StatePayloadStatus::Full) - } else { - Ok(StatePayloadStatus::Pending) - } - } - fn load_hot_hdiff_buffer(&self, state_root: Hash256) -> Result { if let Some(buffer) = self .state_cache @@ -2046,20 +1944,16 @@ impl, Cold: ItemStore> HotColdDB ) -> Result, Hash256)>, Error> { metrics::inc_counter(&metrics::BEACON_STATE_HOT_GET_COUNT); - if let Some( - summary @ HotStateSummary { - slot, - latest_block_root, - diff_base_state, - .. - }, - ) = self.load_hot_state_summary(state_root)? + if let Some(HotStateSummary { + slot, + latest_block_root, + diff_base_state, + .. + }) = self.load_hot_state_summary(state_root)? { - let payload_status = self.get_hot_state_summary_payload_status(&summary)?; debug!( %slot, ?state_root, - ?payload_status, "Loading hot state" ); let mut state = match self.hot_storage_strategy(slot)? { @@ -2113,7 +2007,6 @@ impl, Cold: ItemStore> HotColdDB base_state, slot, latest_block_root, - payload_status, update_cache, )? } @@ -2131,26 +2024,19 @@ impl, Cold: ItemStore> HotColdDB base_state: BeaconState, slot: Slot, latest_block_root: Hash256, - desired_payload_status: StatePayloadStatus, update_cache: bool, ) -> Result, Error> { - if base_state.slot() == slot && base_state.payload_status() == desired_payload_status { + if base_state.slot() == slot { return Ok(base_state); } - let (blocks, envelopes) = self.load_blocks_to_replay( - base_state.slot(), - slot, - latest_block_root, - desired_payload_status, - )?; + let blocks = self.load_blocks_to_replay(base_state.slot(), slot, latest_block_root)?; let _t = metrics::start_timer(&metrics::STORE_BEACON_REPLAY_HOT_BLOCKS_TIME); // If replaying blocks, and `update_cache` is true, also cache the epoch boundary // state that this state is based on. It may be useful as the basis of more states // in the same epoch. let state_cache_hook = |state_root, state: &mut BeaconState| { - // TODO(gloas): prevent caching of the payload_status=Full state? if !update_cache || state.slot() % E::slots_per_epoch() != 0 { return Ok(()); } @@ -2177,16 +2063,12 @@ impl, Cold: ItemStore> HotColdDB debug!( %slot, blocks = ?blocks.iter().map(|block| block.slot()).collect::>(), - envelopes = ?envelopes.iter().map(|e| e.message.slot).collect::>(), - payload_status = ?desired_payload_status, - "Replaying blocks and envelopes" + "Replaying blocks" ); self.replay_blocks( base_state, blocks, - envelopes, - desired_payload_status, slot, no_state_root_iter(), Some(Box::new(state_cache_hook)), @@ -2490,7 +2372,8 @@ impl, Cold: ItemStore> HotColdDB return Ok(base_state); } - let (blocks, envelopes) = self.load_cold_blocks(base_state.slot() + 1, slot)?; + let base_slot = base_state.slot(); + let blocks = self.load_cold_blocks(base_slot + 1, slot)?; // Include state root for base state as it is required by block processing to not // have to hash the state. @@ -2499,16 +2382,7 @@ impl, Cold: ItemStore> HotColdDB self.forwards_state_roots_iterator_until(base_state.slot(), slot, || { Err(Error::StateShouldNotBeRequired(slot)) })?; - let payload_status = self.get_cold_state_payload_status(slot)?; - let state = self.replay_blocks( - base_state, - blocks, - envelopes, - payload_status, - slot, - Some(state_root_iter), - None, - )?; + let state = self.replay_blocks(base_state, blocks, slot, Some(state_root_iter), None)?; debug!( target_slot = %slot, replay_time_ms = metrics::stop_timer_with_duration(replay_timer).as_millis(), @@ -2601,76 +2475,39 @@ impl, Cold: ItemStore> HotColdDB } } - /// Load cold blocks and payload envelopes between `start_slot` and `end_slot` inclusive. - #[allow(clippy::type_complexity)] + /// Load cold blocks between `start_slot` and `end_slot` inclusive. pub fn load_cold_blocks( &self, start_slot: Slot, end_slot: Slot, - ) -> Result< - ( - Vec>, - Vec>, - ), - Error, - > { + ) -> Result>, Error> { let _t = metrics::start_timer(&metrics::STORE_BEACON_LOAD_COLD_BLOCKS_TIME); let block_root_iter = self.forwards_block_roots_iterator_until(start_slot, end_slot, || { Err(Error::StateShouldNotBeRequired(end_slot)) })?; - let blocks = process_results(block_root_iter, |iter| { + process_results(block_root_iter, |iter| { iter.map(|(block_root, _slot)| block_root) .dedup() .map(|block_root| { self.get_blinded_block(&block_root)? .ok_or(Error::MissingBlock(block_root)) }) - .collect::, Error>>() - })??; - - // If Gloas is not enabled for any slots in the range, just return `blocks`. - if !self.spec.fork_name_at_slot::(start_slot).gloas_enabled() - && !self.spec.fork_name_at_slot::(end_slot).gloas_enabled() - { - return Ok((blocks, vec![])); - } - let end_block_root = self - .get_cold_block_root(end_slot)? - .ok_or(HotColdDBError::MissingFrozenBlock(end_slot))?; - let desired_payload_status = self.get_cold_state_payload_status(end_slot)?; - let envelopes = self.load_payload_envelopes_for_blocks( - &blocks, - end_block_root, - desired_payload_status, - )?; - - Ok((blocks, envelopes)) + .collect() + })? } - /// Load the blocks & envelopes between `start_slot` and `end_slot` by backtracking from + /// Load the blocks between `start_slot` and `end_slot` by backtracking from /// `end_block_root`. /// /// Blocks are returned in slot-ascending order, suitable for replaying on a state with slot /// equal to `start_slot`, to reach a state with slot equal to `end_slot`. - /// - /// Payloads are also returned in slot-ascending order, but only payloads forming part of - /// the chain are loaded (payloads for EMPTY slots are omitted). Prior to Gloas, an empty - /// vec of payloads will be returned. - #[allow(clippy::type_complexity)] pub fn load_blocks_to_replay( &self, start_slot: Slot, end_slot: Slot, end_block_root: Hash256, - desired_payload_status: StatePayloadStatus, - ) -> Result< - ( - Vec>, - Vec>, - ), - Error, - > { + ) -> Result>, Error> { let _t = metrics::start_timer(&metrics::STORE_BEACON_LOAD_HOT_BLOCKS_TIME); let mut blocks = ParentRootBlockIterator::new(self, end_block_root) .map(|result| result.map(|(_, block)| block)) @@ -2699,70 +2536,17 @@ impl, Cold: ItemStore> HotColdDB }) .collect::, _>>()?; blocks.reverse(); - - // If Gloas is not enabled for any slots in the range, just return `blocks`. - if !self.spec.fork_name_at_slot::(start_slot).gloas_enabled() - && !self.spec.fork_name_at_slot::(end_slot).gloas_enabled() - { - return Ok((blocks, vec![])); - } - - let envelopes = self.load_payload_envelopes_for_blocks( - &blocks, - end_block_root, - desired_payload_status, - )?; - - Ok((blocks, envelopes)) - } - - pub fn load_payload_envelopes_for_blocks( - &self, - blocks: &[SignedBlindedBeaconBlock], - end_block_root: Hash256, - desired_payload_status: StatePayloadStatus, - ) -> Result>, Error> { - let mut envelopes = vec![]; - - for (block, next_block) in blocks.iter().tuple_windows() { - if block.fork_name_unchecked().gloas_enabled() { - // Check next block to see if this block's payload is canonical on this chain. - let block_hash = block.payload_bid_block_hash()?; - if !next_block.is_parent_block_full(block_hash) { - // No payload at this slot (empty), nothing to load. - continue; - } - // Using `parent_root` avoids computation. - let block_root = next_block.parent_root(); - let envelope = self - .get_payload_envelope(&block_root)? - .ok_or(HotColdDBError::MissingExecutionPayloadEnvelope(block_root))?; - envelopes.push(envelope); - } - } - - // Load the payload for the last block if desired. - if let StatePayloadStatus::Full = desired_payload_status { - let envelope = self.get_payload_envelope(&end_block_root)?.ok_or( - HotColdDBError::MissingExecutionPayloadEnvelope(end_block_root), - )?; - envelopes.push(envelope); - } - - Ok(envelopes) + Ok(blocks) } /// Replay `blocks` on top of `state` until `target_slot` is reached. /// /// Will skip slots as necessary. The returned state is not guaranteed /// to have any caches built, beyond those immediately required by block processing. - #[allow(clippy::too_many_arguments)] pub fn replay_blocks( &self, state: BeaconState, blocks: Vec>, - envelopes: Vec>, - desired_payload_status: StatePayloadStatus, target_slot: Slot, state_root_iter: Option>>, pre_slot_hook: Option>, @@ -2771,8 +2555,7 @@ impl, Cold: ItemStore> HotColdDB let mut block_replayer = BlockReplayer::new(state, &self.spec) .no_signature_verification() - .minimal_block_root_verification() - .desired_state_payload_status(desired_payload_status); + .minimal_block_root_verification(); let have_state_root_iterator = state_root_iter.is_some(); if let Some(state_root_iter) = state_root_iter { @@ -2784,7 +2567,7 @@ impl, Cold: ItemStore> HotColdDB } block_replayer - .apply_blocks(blocks, envelopes, Some(target_slot)) + .apply_blocks(blocks, Some(target_slot)) .map(|block_replayer| { if have_state_root_iterator && block_replayer.state_root_miss() { warn!( @@ -3800,6 +3583,7 @@ pub fn migrate_database, Cold: ItemStore>( ) -> Result { debug!( slot = %finalized_state.slot(), + state_root = ?finalized_state_root, "Freezer migration started" ); @@ -4219,12 +4003,8 @@ impl HotStateSummary { // slots where there isn't a skip). let latest_block_root = state.get_latest_block_root(state_root); - // Payload status of the state determines a lot about how it is stored. - let payload_status = state.payload_status(); - let get_state_root = |slot| { if slot == state.slot() { - // TODO(gloas): I think we can remove this case Ok::<_, Error>(state_root) } else { Ok::<_, Error>(get_ancestor_state_root(store, state, slot).map_err(|e| { @@ -4247,12 +4027,6 @@ impl HotStateSummary { let previous_state_root = if state.slot() == 0 { // Set to 0x0 for genesis state to prevent any sort of circular reference. Hash256::zero() - } else if let StatePayloadStatus::Full = payload_status - && state.slot() == state.latest_block_header().slot - { - // A Full state at a non-skipped slot builds off the Pending state of the same slot, - // i.e. the state with the same `state_root` as its `BeaconBlock` - state.latest_block_header().state_root } else { get_state_root(state.slot().safe_sub(1_u64)?)? }; diff --git a/beacon_node/store/src/reconstruct.rs b/beacon_node/store/src/reconstruct.rs index e51543c3a2..7aca692ef9 100644 --- a/beacon_node/store/src/reconstruct.rs +++ b/beacon_node/store/src/reconstruct.rs @@ -67,7 +67,6 @@ where state.build_caches(&self.spec)?; - // TODO(gloas): handle payload envelope replay process_results(block_root_iter, |iter| -> Result<(), Error> { let mut io_batch = vec![]; diff --git a/beacon_node/store/src/state_cache.rs b/beacon_node/store/src/state_cache.rs index d016922ade..6d159c9361 100644 --- a/beacon_node/store/src/state_cache.rs +++ b/beacon_node/store/src/state_cache.rs @@ -7,7 +7,7 @@ use lru::LruCache; use std::collections::{BTreeMap, HashMap, HashSet}; use std::num::NonZeroUsize; use tracing::instrument; -use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256, Slot, execution::StatePayloadStatus}; +use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256, Slot}; /// Fraction of the LRU cache to leave intact during culling. const CULL_EXEMPT_NUMERATOR: usize = 1; @@ -23,10 +23,10 @@ pub struct FinalizedState { state: BeaconState, } -/// Map from (block_root, payload_status) -> slot -> state_root. +/// Map from block_root -> slot -> state_root. #[derive(Debug, Default)] pub struct BlockMap { - blocks: HashMap<(Hash256, StatePayloadStatus), SlotMap>, + blocks: HashMap, } /// Map from slot -> state_root. @@ -143,11 +143,8 @@ impl StateCache { return Err(Error::FinalizedStateDecreasingSlot); } - let payload_status = state.payload_status(); - // Add to block map. - self.block_map - .insert(block_root, payload_status, state.slot(), state_root); + self.block_map.insert(block_root, state.slot(), state_root); // Prune block map. let state_roots_to_prune = self.block_map.prune(state.slot()); @@ -270,9 +267,7 @@ impl StateCache { // Record the connection from block root and slot to this state. let slot = state.slot(); - let payload_status = state.payload_status(); - self.block_map - .insert(block_root, payload_status, slot, state_root); + self.block_map.insert(block_root, slot, state_root); Ok(PutStateOutcome::New(deleted_states)) } @@ -321,10 +316,9 @@ impl StateCache { pub fn get_by_block_root( &mut self, block_root: Hash256, - payload_status: StatePayloadStatus, slot: Slot, ) -> Option<(Hash256, BeaconState)> { - let slot_map = self.block_map.blocks.get(&(block_root, payload_status))?; + let slot_map = self.block_map.blocks.get(&block_root)?; // Find the state at `slot`, or failing that the most recent ancestor. let state_root = slot_map @@ -345,12 +339,7 @@ impl StateCache { } pub fn delete_block_states(&mut self, block_root: &Hash256) { - let (pending_state_roots, full_state_roots) = - self.block_map.delete_block_states(block_root); - for slot_map in [pending_state_roots, full_state_roots] - .into_iter() - .flatten() - { + if let Some(slot_map) = self.block_map.delete_block_states(block_root) { for state_root in slot_map.slots.values() { self.states.pop(state_root); } @@ -423,14 +412,8 @@ impl StateCache { } impl BlockMap { - fn insert( - &mut self, - block_root: Hash256, - payload_status: StatePayloadStatus, - slot: Slot, - state_root: Hash256, - ) { - let slot_map = self.blocks.entry((block_root, payload_status)).or_default(); + fn insert(&mut self, block_root: Hash256, slot: Slot, state_root: Hash256) { + let slot_map = self.blocks.entry(block_root).or_default(); slot_map.slots.insert(slot, state_root); } @@ -461,12 +444,8 @@ impl BlockMap { }); } - fn delete_block_states(&mut self, block_root: &Hash256) -> (Option, Option) { - let pending_state_roots = self - .blocks - .remove(&(*block_root, StatePayloadStatus::Pending)); - let full_state_roots = self.blocks.remove(&(*block_root, StatePayloadStatus::Full)); - (pending_state_roots, full_state_roots) + fn delete_block_states(&mut self, block_root: &Hash256) -> Option { + self.blocks.remove(block_root) } } diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index dd16f46c55..d724156f86 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1093,7 +1093,6 @@ pub struct SseExecutionPayload { pub builder_index: u64, pub block_hash: ExecutionBlockHash, pub block_root: Hash256, - pub state_root: Hash256, pub execution_optimistic: bool, } @@ -1104,7 +1103,6 @@ pub struct SseExecutionPayloadGossip { pub builder_index: u64, pub block_hash: ExecutionBlockHash, pub block_root: Hash256, - pub state_root: Hash256, } #[derive(PartialEq, Debug, Serialize, Deserialize, Clone)] diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 92fd4c1faf..21415e478a 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -560,9 +560,22 @@ where )?; // Cache some values for the next forkchoiceUpdate call to the execution layer. - let head_hash = self - .get_block(&head_root) - .and_then(|b| b.execution_status.block_hash()); + // For Gloas blocks, `execution_status` is Irrelevant (no embedded payload). + // If the payload envelope was received (Full), use the bid's block_hash as the + // execution chain head. Otherwise fall back to the parent hash (Pending) or None. + // TODO(gloas): this is a bit messy, and we probably need a similar treatment for + // justified/finalized + // Can fix as part of: https://github.com/sigp/lighthouse/issues/8957 + let head_hash = self.get_block(&head_root).and_then(|b| { + b.execution_status + .block_hash() + .or(match head_payload_status { + PayloadStatus::Full => b.execution_payload_block_hash, + PayloadStatus::Pending | PayloadStatus::Empty => { + b.execution_payload_parent_hash + } + }) + }); let justified_root = self.justified_checkpoint().root; let finalized_root = self.finalized_checkpoint().root; let justified_hash = self @@ -804,7 +817,7 @@ where })); } - let attestation_threshold = spec.get_unaggregated_attestation_due(); + let attestation_threshold = spec.get_attestation_due::(block.slot()); // Add proposer score boost if the block is timely. // TODO(gloas): the spec's `update_proposer_boost_root` additionally checks that @@ -1493,6 +1506,14 @@ where } } + /// Returns whether the proposer should extend the execution payload chain of the given block. + pub fn should_extend_payload(&self, block_root: &Hash256) -> Result> { + let proposer_boost_root = self.fc_store.proposer_boost_root(); + self.proto_array + .should_extend_payload::(block_root, proposer_boost_root) + .map_err(Error::ProtoArrayStringError) + } + /// Returns an `ExecutionStatus` if the block is known **and** a descendant of the finalized root. pub fn get_block_execution_status(&self, block_root: &Hash256) -> Option { if self.is_finalized_checkpoint_or_descendant(*block_root) { 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 2e792028e5..197e1102a3 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 @@ -109,6 +109,8 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition { pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { let mut ops = vec![]; + // Block 1 at slot 1: child of genesis. Genesis has execution_payload_block_hash=zero + // (no execution payload at genesis), so all children have parent_payload_status=Empty. ops.push(Operation::ProcessBlock { slot: Slot::new(1), root: get_root(1), @@ -212,8 +214,10 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { 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)), + // Genesis has zero execution block hash (no payload at genesis), which + // ensures all children get parent_payload_status=Empty. + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::zero()), spec: Some(gloas_spec()), } } @@ -600,18 +604,20 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef /// Test interleaving of blocks, payload validation, and attestations. /// -/// Scenario: -/// - Genesis block (slot 0) -/// - Block 1 (slot 1) extends genesis, Full chain -/// - Block 2 (slot 1) extends genesis, Empty chain -/// - Before payload arrives: payload_received is false for block 1 +/// Scenario (branching at block 1 since genesis has no payload): +/// - Genesis block (slot 0) with zero execution block hash +/// - Block 1 (slot 1) child of genesis (Empty parent status since genesis hash=zero) +/// - Block 2 (slot 2) extends block 1 Full chain (parent_hash matches block 1's block_hash) +/// - Block 3 (slot 2) extends block 1 Empty chain (parent_hash doesn't match) +/// - Before payload arrives: payload_received is false for block 1, only Empty reachable /// - Process execution payload for block 1 → payload_received becomes true -/// - Payload attestations arrive voting block 1's payload as timely + available -/// - Head should follow block 1 because the PTC votes now count (payload_received = true) +/// - Both Full and Empty directions from block 1 become available +/// - With equal weight, tiebreaker prefers Full → Block 2 wins pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTestDefinition { let mut ops = vec![]; - // Block 1 at slot 1: extends genesis Full chain. + // Block 1 at slot 1: child of genesis. Genesis has zero block hash, so + // parent_payload_status = Empty regardless of block 1's execution_payload_parent_hash. ops.push(Operation::ProcessBlock { slot: Slot::new(1), root: get_root(1), @@ -622,83 +628,94 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe execution_payload_block_hash: Some(get_hash(1)), }); - // Block 2 at slot 1: extends genesis Empty chain (parent_hash doesn't match genesis EL hash). + // Block 2 at slot 2: Full child of block 1 (parent_hash matches block 1's block_hash). ops.push(Operation::ProcessBlock { - slot: Slot::new(1), + slot: Slot::new(2), root: get_root(2), - parent_root: get_root(0), + 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)), + }); + + // Block 3 at slot 2: Empty child of block 1 (parent_hash doesn't match block 1's block_hash). + ops.push(Operation::ProcessBlock { + slot: Slot::new(2), + root: get_root(3), + parent_root: get_root(1), justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), execution_payload_parent_hash: Some(get_hash(99)), - execution_payload_block_hash: Some(get_hash(100)), + execution_payload_block_hash: Some(get_hash(3)), }); - // Both children have parent_payload_status set correctly. + // Verify parent_payload_status is set correctly. ops.push(Operation::AssertParentPayloadStatus { block_root: get_root(1), + expected_status: PayloadStatus::Empty, + }); + ops.push(Operation::AssertParentPayloadStatus { + block_root: get_root(2), expected_status: PayloadStatus::Full, }); ops.push(Operation::AssertParentPayloadStatus { - block_root: get_root(2), + block_root: get_root(3), expected_status: PayloadStatus::Empty, }); - // Per spec `get_forkchoice_store`: genesis starts with payload_received=true - // (anchor block is in `payload_states`). + // Genesis does NOT have payload_received (no payload at genesis). ops.push(Operation::AssertPayloadReceived { block_root: get_root(0), - expected: true, + expected: false, }); - // Give one vote to each child so they have equal weight. + // Block 1 does not have payload_received yet. + ops.push(Operation::AssertPayloadReceived { + block_root: get_root(1), + expected: false, + }); + + // Give one vote to each competing child so they have equal weight. ops.push(Operation::ProcessAttestation { validator_index: 0, - block_root: get_root(1), - attestation_slot: Slot::new(1), + block_root: get_root(2), + attestation_slot: Slot::new(2), }); ops.push(Operation::ProcessAttestation { validator_index: 1, - block_root: get_root(2), - attestation_slot: Slot::new(1), + block_root: get_root(3), + attestation_slot: Slot::new(2), }); - // Equal weight, payload_received=true on genesis → tiebreaker uses - // payload_received (not previous slot, equal payload weights) → prefers Full. - // Block 1 (Full) wins because it matches the Full preference. + // Before payload_received on block 1: only Empty direction available. + // Block 3 (Empty child) is reachable, Block 2 (Full child) is not. ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1, 1], - expected_head: get_root(1), + expected_head: get_root(3), current_slot: Slot::new(100), expected_payload_status: None, }); - // ProcessExecutionPayloadEnvelope on genesis is a no-op (already received at init). + // Process execution payload envelope for block 1 → payload_received becomes true. ops.push(Operation::ProcessExecutionPayloadEnvelope { - block_root: get_root(0), + block_root: get_root(1), }); ops.push(Operation::AssertPayloadReceived { - block_root: get_root(0), + block_root: get_root(1), expected: true, }); - // Set PTC votes on genesis as timely + data available (simulates PTC voting). - // This doesn't change the preference since genesis is not the previous slot - // (slot 0 + 1 != current_slot 100). - ops.push(Operation::SetPayloadTiebreak { - block_root: get_root(0), - is_timely: true, - is_data_available: true, - }); - - // Still prefers Full via payload_received tiebreaker → Block 1 (Full) wins. + // After payload_received on block 1: both Full and Empty directions available. + // Equal weight, tiebreaker prefers Full → Block 2 (Full child) wins. ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1, 1], - expected_head: get_root(1), + expected_head: get_root(2), current_slot: Slot::new(100), expected_payload_status: None, }); @@ -708,8 +725,9 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe 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)), + // Genesis has zero execution block hash (no payload at genesis). + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::zero()), spec: Some(gloas_spec()), } } diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 4946631f73..4ca7dab69c 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -568,8 +568,10 @@ impl ProtoArray { ProtoNode::V29(v29) => { // Both parent and child are Gloas blocks. The parent is full if the // block hash in the parent node matches the parent block hash in the - // child bid. - if execution_payload_parent_hash == v29.execution_payload_block_hash { + // child bid and the parent block isn't the genesis block. + if v29.execution_payload_block_hash != ExecutionBlockHash::zero() + && execution_payload_parent_hash == v29.execution_payload_block_hash + { PayloadStatus::Full } else { PayloadStatus::Empty @@ -582,18 +584,16 @@ impl ProtoArray { } } } else { - // TODO(gloas): re-assess this assumption - // Parent is missing (genesis or pruned due to finalization). Default to Full - // since this path should only be hit at Gloas genesis. - PayloadStatus::Full + // Parent is missing (genesis or pruned due to finalization). This code path + // should only be hit at Gloas genesis. Default to empty, the genesis block + // has no payload enevelope. + PayloadStatus::Empty }; - // Per spec `get_forkchoice_store`: the anchor (genesis) block has - // its payload state initialized (`payload_states = {anchor_root: ...}`). - // Without `payload_received = true` on genesis, the FULL virtual - // child doesn't exist in the spec's `get_node_children`, making all - // Full concrete children of genesis unreachable in `get_head`. - let is_genesis = parent_index.is_none(); + // The spec does something slightly strange where it initialises the payload timeliness + // votes and payload data availability votes for the anchor block to all true, but never + // adds the anchor to `store.payloads`, so it is never considered full. + let is_anchor = parent_index.is_none(); ProtoNode::V29(ProtoNodeV29 { slot: block.slot, @@ -614,26 +614,25 @@ impl ProtoArray { execution_payload_block_hash, execution_payload_parent_hash, // Per spec `get_forkchoice_store`: the anchor block's PTC votes are - // initialized to all-True, ensuring `is_payload_timely` and - // `is_payload_data_available` return true for the anchor. - payload_timeliness_votes: if is_genesis { + // initialized to all-True. + payload_timeliness_votes: if is_anchor { all_true_bitvector() } else { BitVector::default() }, - payload_data_availability_votes: if is_genesis { + payload_data_availability_votes: if is_anchor { all_true_bitvector() } else { BitVector::default() }, - payload_received: is_genesis, + payload_received: false, proposer_index, // Spec: `record_block_timeliness` + `get_forkchoice_store`. // Anchor gets [True, True]. Others computed from time_into_slot. - block_timeliness_attestation_threshold: is_genesis + block_timeliness_attestation_threshold: is_anchor || (is_current_slot && time_into_slot < spec.get_attestation_due::(current_slot)), - block_timeliness_ptc_threshold: is_genesis + block_timeliness_ptc_threshold: is_anchor || (is_current_slot && time_into_slot < spec.get_payload_attestation_due()), equivocating_attestation_score: 0, }) @@ -1438,7 +1437,7 @@ impl ProtoArray { } } - fn should_extend_payload( + pub fn should_extend_payload( &self, fc_node: &IndexedForkChoiceNode, proto_node: &ProtoNode, diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 0ecaea3971..577e89baa1 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, StatePayloadStatus, + Slot, }; pub const DEFAULT_PRUNE_THRESHOLD: usize = 256; @@ -110,19 +110,6 @@ pub enum PayloadStatus { Pending = 2, } -impl PayloadStatus { - /// Convert a `PayloadStatus` into the equivalent `StatePayloadStatus`. - /// - /// This maps `Empty` onto `StatePayloadStatus::Pending` because empty and pending fork choice - /// nodes correspond to the exact same state. - pub fn as_state_payload_status(self) -> StatePayloadStatus { - match self { - Self::Empty | Self::Pending => StatePayloadStatus::Pending, - Self::Full => StatePayloadStatus::Full, - } - } -} - /// Spec's `ForkChoiceNode` augmented with ProtoNode index. pub struct IndexedForkChoiceNode { pub root: Hash256, @@ -1019,6 +1006,34 @@ impl ProtoArrayForkChoice { }) } + /// Returns whether the proposer should extend the parent's execution payload chain. + /// + /// This checks timeliness, data availability, and proposer boost conditions per the spec. + pub fn should_extend_payload( + &self, + block_root: &Hash256, + proposer_boost_root: Hash256, + ) -> Result { + let block_index = self + .proto_array + .indices + .get(block_root) + .ok_or_else(|| format!("Unknown block root: {block_root:?}"))?; + let proto_node = self + .proto_array + .nodes + .get(*block_index) + .ok_or_else(|| format!("Missing node at index: {block_index}"))?; + let fc_node = IndexedForkChoiceNode { + root: proto_node.root(), + proto_node_index: *block_index, + payload_status: proto_node.get_parent_payload_status(), + }; + self.proto_array + .should_extend_payload::(&fc_node, proto_node, proposer_boost_root) + .map_err(|e| format!("{e:?}")) + } + /// Returns the `block.execution_status` field, if the block is present. pub fn get_block_execution_status(&self, block_root: &Hash256) -> Option { let block = self.get_proto_node(block_root)?; diff --git a/consensus/state_processing/src/block_replayer.rs b/consensus/state_processing/src/block_replayer.rs index f5f06d1cb9..56e667cdd3 100644 --- a/consensus/state_processing/src/block_replayer.rs +++ b/consensus/state_processing/src/block_replayer.rs @@ -1,11 +1,6 @@ use crate::{ BlockProcessingError, BlockSignatureStrategy, ConsensusContext, SlotProcessingError, - VerifyBlockRoot, VerifySignatures, - envelope_processing::{ - EnvelopeProcessingError, VerifyStateRoot, process_execution_payload_envelope, - }, - per_block_processing, - per_epoch_processing::EpochProcessingSummary, + VerifyBlockRoot, per_block_processing, per_epoch_processing::EpochProcessingSummary, per_slot_processing, }; use itertools::Itertools; @@ -13,7 +8,7 @@ use std::iter::Peekable; use std::marker::PhantomData; use types::{ BeaconState, BeaconStateError, BlindedPayload, ChainSpec, EthSpec, Hash256, SignedBeaconBlock, - SignedExecutionPayloadEnvelope, Slot, execution::StatePayloadStatus, + Slot, }; pub type PreBlockHook<'a, E, Error> = Box< @@ -29,7 +24,7 @@ pub type PostSlotHook<'a, E, Error> = Box< >; pub type StateRootIterDefault = std::iter::Empty>; -/// Efficiently apply blocks and payloads to a state while configuring various parameters. +/// Efficiently apply blocks to a state while configuring various parameters. /// /// Usage follows a builder pattern. pub struct BlockReplayer< @@ -46,21 +41,8 @@ pub struct BlockReplayer< post_block_hook: Option>, pre_slot_hook: Option>, post_slot_hook: Option>, - /// Iterator over state roots for all *block* states. - /// - /// Pre-Gloas, this is all states. Post-Gloas, this is *just* the states corresponding to beacon - /// blocks. For states corresponding to payloads, we read the state root from the payload - /// envelope. - // TODO(gloas): this concept might need adjusting when we implement the cold DB. pub(crate) state_root_iter: Option>, state_root_miss: bool, - /// The payload status of the state desired as the end result of block replay. - /// - /// This dictates whether a payload should be applied after applying the last block. - /// - /// Prior to Gloas, this should always be set to `StatePayloadStatus::Pending` to indicate - /// that no envelope needs to be applied. - desired_state_payload_status: StatePayloadStatus, _phantom: PhantomData, } @@ -68,12 +50,7 @@ pub struct BlockReplayer< pub enum BlockReplayError { SlotProcessing(SlotProcessingError), BlockProcessing(BlockProcessingError), - EnvelopeProcessing(EnvelopeProcessingError), BeaconState(BeaconStateError), - /// A payload envelope for this `slot` was required but not provided. - MissingPayloadEnvelope { - slot: Slot, - }, } impl From for BlockReplayError { @@ -88,12 +65,6 @@ impl From for BlockReplayError { } } -impl From for BlockReplayError { - fn from(e: EnvelopeProcessingError) -> Self { - Self::EnvelopeProcessing(e) - } -} - impl From for BlockReplayError { fn from(e: BeaconStateError) -> Self { Self::BeaconState(e) @@ -125,7 +96,6 @@ where post_slot_hook: None, state_root_iter: None, state_root_miss: false, - desired_state_payload_status: StatePayloadStatus::Pending, _phantom: PhantomData, } } @@ -191,14 +161,6 @@ where self } - /// Set the desired payload status of the state reached by replay. - /// - /// This determines whether to apply a payload after applying the last block. - pub fn desired_state_payload_status(mut self, payload_status: StatePayloadStatus) -> Self { - self.desired_state_payload_status = payload_status; - self - } - /// Compute the state root for `self.state` as efficiently as possible. /// /// This function MUST only be called when `self.state` is a post-state, i.e. it MUST not be @@ -246,38 +208,6 @@ where Ok(state_root) } - /// Apply an execution payload envelope to `self.state`. - /// - /// The `block_state_root` MUST be the `state_root` of the most recently applied block. - /// - /// Returns the `state_root` of `self.state` after payload application. - fn apply_payload_envelope( - &mut self, - envelope: &SignedExecutionPayloadEnvelope, - block_state_root: Hash256, - ) -> Result { - // TODO(gloas): bulk signature verification could be relevant here? - let verify_payload_signatures = - if let BlockSignatureStrategy::NoVerification = self.block_sig_strategy { - VerifySignatures::False - } else { - VerifySignatures::True - }; - // TODO(gloas): state root verif enabled during initial prototyping - let verify_state_root = VerifyStateRoot::True; - process_execution_payload_envelope( - &mut self.state, - Some(block_state_root), - envelope, - verify_payload_signatures, - verify_state_root, - self.spec, - ) - .map_err(BlockReplayError::from)?; - - Ok(envelope.message.state_root) - } - /// Apply `blocks` atop `self.state`, taking care of slot processing. /// /// If `target_slot` is provided then the state will be advanced through to `target_slot` @@ -285,21 +215,8 @@ where pub fn apply_blocks( mut self, blocks: Vec>>, - payload_envelopes: Vec>, target_slot: Option, ) -> Result { - let mut envelopes_iter = payload_envelopes.into_iter(); - - let mut next_envelope_at_slot = |slot| { - if let Some(envelope) = envelopes_iter.next() - && envelope.message.slot == slot - { - Ok(envelope) - } else { - Err(BlockReplayError::MissingPayloadEnvelope { slot }) - } - }; - for (i, block) in blocks.iter().enumerate() { // Allow one additional block at the start which is only used for its state root. if i == 0 && block.slot() <= self.state.slot() { @@ -307,36 +224,7 @@ where } while self.state.slot() < block.slot() { - let mut state_root = self.get_state_root(&blocks, i)?; - - // Apply the payload for the *previous* block if the bid in the current block - // indicates that the parent is full (and it hasn't already been applied). - state_root = if block.fork_name_unchecked().gloas_enabled() - && self.state.slot() == self.state.latest_block_header().slot - && self.state.payload_status() == StatePayloadStatus::Pending - { - let latest_bid_block_hash = self - .state - .latest_execution_payload_bid() - .map_err(BlockReplayError::from)? - .block_hash; - - // Similar to `is_parent_block_full`, but reading the block hash from the - // not-yet-applied `block`. The slot 0 case covers genesis (no block replay reqd). - if self.state.slot() != 0 && block.is_parent_block_full(latest_bid_block_hash) { - let envelope = next_envelope_at_slot(self.state.slot())?; - // State root for the next slot processing is now the envelope's state root. - self.apply_payload_envelope(&envelope, state_root)? - } else { - // Empty payload at this slot, the state root is unchanged from when the - // beacon block was applied. - state_root - } - } else { - // Pre-Gloas or at skipped slots post-Gloas, the state root of the parent state - // is always the output from `self.get_state_root`. - state_root - }; + let state_root = self.get_state_root(&blocks, i)?; if let Some(ref mut pre_slot_hook) = self.pre_slot_hook { pre_slot_hook(state_root, &mut self.state)?; @@ -380,24 +268,9 @@ where } } - // Apply the last payload if desired. - let mut opt_state_root = if let StatePayloadStatus::Full = self.desired_state_payload_status - && let Some(last_block) = blocks.last() - { - let envelope = next_envelope_at_slot(self.state.slot())?; - Some(self.apply_payload_envelope(&envelope, last_block.state_root())?) - } else { - None - }; - if let Some(target_slot) = target_slot { while self.state.slot() < target_slot { - // Read state root from `opt_state_root` if a payload was just applied. - let state_root = if let Some(root) = opt_state_root.take() { - root - } else { - self.get_state_root(&blocks, blocks.len())? - }; + let state_root = self.get_state_root(&blocks, blocks.len())?; if let Some(ref mut pre_slot_hook) = self.pre_slot_hook { pre_slot_hook(state_root, &mut self.state)?; diff --git a/consensus/state_processing/src/envelope_processing.rs b/consensus/state_processing/src/envelope_processing.rs index 97953b835f..8ea96390e3 100644 --- a/consensus/state_processing/src/envelope_processing.rs +++ b/consensus/state_processing/src/envelope_processing.rs @@ -1,15 +1,10 @@ -use crate::BlockProcessingError; use crate::VerifySignatures; use crate::per_block_processing::compute_timestamp_at_slot; -use crate::per_block_processing::process_operations::{ - process_consolidation_requests, process_deposit_requests_post_gloas, - process_withdrawal_requests, -}; -use safe_arith::{ArithError, SafeArith}; +use safe_arith::ArithError; use tree_hash::TreeHash; use types::{ - BeaconState, BeaconStateError, BuilderIndex, BuilderPendingPayment, ChainSpec, EthSpec, - ExecutionBlockHash, Hash256, SignedExecutionPayloadEnvelope, Slot, + BeaconState, BeaconStateError, BuilderIndex, ChainSpec, EthSpec, ExecutionBlockHash, Hash256, + SignedExecutionPayloadEnvelope, Slot, }; macro_rules! envelope_verify { @@ -20,29 +15,11 @@ macro_rules! envelope_verify { }; } -/// The strategy to be used when validating the payloads state root. -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive(PartialEq, Clone, Copy)] -pub enum VerifyStateRoot { - /// Validate state root. - True, - /// Do not validate state root. Use with caution. - /// This should only be used when first constructing the payload envelope. - False, -} - -impl VerifyStateRoot { - pub fn is_true(self) -> bool { - self == VerifyStateRoot::True - } -} - #[derive(Debug, Clone)] pub enum EnvelopeProcessingError { /// Bad Signature BadSignature, BeaconStateError(BeaconStateError), - BlockProcessingError(BlockProcessingError), ArithError(ArithError), /// Envelope doesn't match latest beacon block header LatestBlockHeaderMismatch { @@ -89,15 +66,11 @@ pub enum EnvelopeProcessingError { state: u64, envelope: u64, }, - // Invalid state root - InvalidStateRoot { - state: Hash256, + // The execution requests root doesn't match the committed bid + ExecutionRequestsRootMismatch { + committed_bid: Hash256, envelope: Hash256, }, - // BitFieldError - BitFieldError(ssz::BitfieldError), - // Some kind of error calculating the builder payment index - BuilderPaymentIndexOutOfBounds(usize), /// The envelope was deemed invalid by the execution engine. ExecutionInvalid, } @@ -108,50 +81,44 @@ impl From for EnvelopeProcessingError { } } -impl From for EnvelopeProcessingError { - fn from(e: BlockProcessingError) -> Self { - EnvelopeProcessingError::BlockProcessingError(e) - } -} - impl From for EnvelopeProcessingError { fn from(e: ArithError) -> Self { EnvelopeProcessingError::ArithError(e) } } -/// Processes a `SignedExecutionPayloadEnvelope` +/// Verifies a `SignedExecutionPayloadEnvelope` against the beacon state. /// -/// This function does all the state modifications inside `process_execution_payload()` -pub fn process_execution_payload_envelope( - state: &mut BeaconState, - parent_state_root: Option, +/// This function performs pure verification with no state mutation. The execution requests +/// from the envelope are deferred to be processed in the next block via +/// `process_parent_execution_payload`. +/// +/// `block_state_root` should be the post-block state root (used to fill in the block header +/// for beacon_block_root verification). If `None`, the latest_block_header must already have +/// its state_root filled in. +pub fn verify_execution_payload_envelope( + state: &BeaconState, signed_envelope: &SignedExecutionPayloadEnvelope, verify_signatures: VerifySignatures, - verify_state_root: VerifyStateRoot, + block_state_root: Hash256, spec: &ChainSpec, ) -> Result<(), EnvelopeProcessingError> { - if verify_signatures.is_true() { - // Verify Signed Envelope Signature - if !signed_envelope.verify_signature_with_state(state, spec)? { - return Err(EnvelopeProcessingError::BadSignature); - } + if verify_signatures.is_true() && !signed_envelope.verify_signature_with_state(state, spec)? { + return Err(EnvelopeProcessingError::BadSignature); } let envelope = &signed_envelope.message; let payload = &envelope.payload; - let execution_requests = &envelope.execution_requests; - // Cache latest block header state root - if state.latest_block_header().state_root == Hash256::default() { - let previous_state_root = parent_state_root - .map(Ok) - .unwrap_or_else(|| state.canonical_root())?; - state.latest_block_header_mut().state_root = previous_state_root; + // Verify consistency with the beacon block. + // Use a copy of the header with state_root filled in, matching the spec's approach. + let mut header = state.latest_block_header().clone(); + if header.state_root == Hash256::default() { + // The caller must provide the post-block state root so we can compute + // the block header root without mutating state. + header.state_root = block_state_root; } - - // Verify consistency with the beacon block - let latest_block_header_root = state.latest_block_header().tree_hash_root(); + let latest_block_header_root = header.tree_hash_root(); envelope_verify!( envelope.beacon_block_root == latest_block_header_root, EnvelopeProcessingError::LatestBlockHeaderMismatch { @@ -160,9 +127,9 @@ pub fn process_execution_payload_envelope( } ); envelope_verify!( - envelope.slot == state.slot(), + envelope.slot() == state.slot(), EnvelopeProcessingError::SlotMismatch { - envelope_slot: envelope.slot, + envelope_slot: envelope.slot(), parent_state_slot: state.slot(), } ); @@ -238,59 +205,17 @@ pub fn process_execution_payload_envelope( } ); + // Verify execution requests root matches committed bid + let execution_requests_root = envelope.execution_requests.tree_hash_root(); + envelope_verify!( + execution_requests_root == committed_bid.execution_requests_root, + EnvelopeProcessingError::ExecutionRequestsRootMismatch { + committed_bid: committed_bid.execution_requests_root, + envelope: execution_requests_root, + } + ); + // TODO(gloas): newPayload happens here in the spec, ensure we wire that up correctly - process_deposit_requests_post_gloas(state, &execution_requests.deposits, spec)?; - process_withdrawal_requests(state, &execution_requests.withdrawals, spec)?; - process_consolidation_requests(state, &execution_requests.consolidations, spec)?; - - // Queue the builder payment - let payment_index = E::slots_per_epoch() - .safe_add(state.slot().as_u64().safe_rem(E::slots_per_epoch())?)? - as usize; - let payment_mut = state - .builder_pending_payments_mut()? - .get_mut(payment_index) - .ok_or(EnvelopeProcessingError::BuilderPaymentIndexOutOfBounds( - payment_index, - ))?; - - // We have re-ordered the blanking out of the pending payment to avoid a double-lookup. - // This is semantically equivalent to the ordering used by the spec because we have taken a - // clone of the payment prior to doing the write. - let payment_withdrawal = payment_mut.withdrawal.clone(); - *payment_mut = BuilderPendingPayment::default(); - - let amount = payment_withdrawal.amount; - if amount > 0 { - state - .builder_pending_withdrawals_mut()? - .push(payment_withdrawal) - .map_err(|e| EnvelopeProcessingError::BeaconStateError(e.into()))?; - } - - // Cache the execution payload hash - let availability_index = state - .slot() - .as_usize() - .safe_rem(E::slots_per_historical_root())?; - state - .execution_payload_availability_mut()? - .set(availability_index, true) - .map_err(EnvelopeProcessingError::BitFieldError)?; - *state.latest_block_hash_mut()? = payload.block_hash; - - if verify_state_root.is_true() { - // Verify the state root - let state_root = state.canonical_root()?; - envelope_verify!( - envelope.state_root == state_root, - EnvelopeProcessingError::InvalidStateRoot { - state: state_root, - envelope: envelope.state_root, - } - ); - } - Ok(()) } diff --git a/consensus/state_processing/src/genesis.rs b/consensus/state_processing/src/genesis.rs index 861fccb374..9dfbc87b48 100644 --- a/consensus/state_processing/src/genesis.rs +++ b/consensus/state_processing/src/genesis.rs @@ -167,9 +167,21 @@ pub fn initialize_beacon_state_from_eth1( // Remove intermediate Fulu fork from `state.fork`. state.fork_mut().previous_version = spec.gloas_fork_version; - // Override latest execution payload header. - // Here's where we *would* clone the header but there is no header here so.. - // TODO(EIP7732): check this + // The genesis block's bid must have block_hash = 0x00 per spec (empty payload). + // Retain the EL genesis hash in latest_block_hash and parent_block_hash so the + // first post-genesis proposer can build on the correct EL head. + let el_genesis_hash = state.latest_execution_payload_bid()?.block_hash; + let bid = state.latest_execution_payload_bid_mut()?; + bid.parent_block_hash = el_genesis_hash; + bid.block_hash = ExecutionBlockHash::default(); + + // Update latest_block_header to reflect the Gloas genesis block body which contains + // the EL genesis hash in the signed_execution_payload_bid. This is needed because + // BeaconState::new() created the header from BeaconBlock::empty() which has zero bid + // fields, but the spec requires the genesis block's bid to contain the EL block hash + // and the tree hash root of empty ExecutionRequests. + let block = genesis_block(&state, spec)?; + state.latest_block_header_mut().body_root = block.body_root(); } // Now that we have our validators, initialize the caches (including the committees) @@ -181,6 +193,28 @@ pub fn initialize_beacon_state_from_eth1( Ok(state) } +/// Create an unsigned genesis `BeaconBlock` whose body matches the genesis state. +/// +/// For Gloas, the block's `signed_execution_payload_bid` is populated from the state's +/// `latest_execution_payload_bid` so that the body root is consistent with +/// `state.latest_block_header.body_root`. +/// +/// The returned block has `state_root == Hash256::ZERO`; callers that need the real +/// state root should set it themselves. +pub fn genesis_block( + genesis_state: &BeaconState, + spec: &ChainSpec, +) -> Result, BeaconStateError> { + let mut block = BeaconBlock::empty(spec); + if let Ok(block) = block.as_gloas_mut() { + let state_bid = genesis_state.latest_execution_payload_bid()?; + let bid = &mut block.body.signed_execution_payload_bid.message; + bid.block_hash = state_bid.block_hash; + bid.execution_requests_root = state_bid.execution_requests_root; + } + Ok(block) +} + /// Determine whether a candidate genesis state is suitable for starting the chain. pub fn is_valid_genesis_state(state: &BeaconState, spec: &ChainSpec) -> bool { state diff --git a/consensus/state_processing/src/per_block_processing.rs b/consensus/state_processing/src/per_block_processing.rs index 210e0437be..71ad394ee6 100644 --- a/consensus/state_processing/src/per_block_processing.rs +++ b/consensus/state_processing/src/per_block_processing.rs @@ -120,7 +120,7 @@ pub fn per_block_processing>( let block = signed_block.message(); // Verify that the `SignedBeaconBlock` instantiation matches the fork at `signed_block.slot()`. - signed_block + let fork_name = signed_block .fork_name(spec) .map_err(BlockProcessingError::InconsistentBlockFork)?; @@ -129,6 +129,11 @@ pub fn per_block_processing>( .fork_name(spec) .map_err(BlockProcessingError::InconsistentStateFork)?; + // Process deferred execution requests from the parent's envelope. + if fork_name.gloas_enabled() { + process_parent_execution_payload(state, block, spec)?; + } + // Build epoch cache if it hasn't already been built, or if it is no longer valid initialize_epoch_cache(state, spec)?; initialize_progressive_balances_cache(state, spec)?; @@ -531,6 +536,139 @@ pub fn compute_timestamp_at_slot( .and_then(|since_genesis| state.genesis_time().safe_add(since_genesis)) } +/// Process the parent block's deferred execution payload effects. +/// +/// This implements the spec's `process_parent_execution_payload` function, which validates +/// the parent execution requests and delegates to `apply_parent_execution_payload` if the +/// parent block was full. This is called at the beginning of block processing, before +/// `process_block_header`. +/// +/// `process_parent_execution_payload` must be called before `process_execution_payload_bid` +/// (which overwrites `state.latest_execution_payload_bid`). +pub fn process_parent_execution_payload>( + state: &mut BeaconState, + block: BeaconBlockRef<'_, E, Payload>, + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> { + let bid_parent_block_hash = block + .body() + .signed_execution_payload_bid()? + .message + .parent_block_hash; + let parent_bid = state.latest_execution_payload_bid()?.clone(); + let requests = block.body().parent_execution_requests()?; + + let is_genesis_block = parent_bid.block_hash == ExecutionBlockHash::zero(); + let is_parent_block_empty = bid_parent_block_hash != parent_bid.block_hash; + + if is_genesis_block || is_parent_block_empty { + // Parent was EMPTY -- no execution requests expected + block_verify!( + *requests == ExecutionRequests::default(), + BlockProcessingError::NonEmptyParentExecutionRequests + ); + return Ok(()); + } + + // Parent was FULL -- verify the bid commitment and apply the payload + let requests_root = requests.tree_hash_root(); + block_verify!( + requests_root == parent_bid.execution_requests_root, + BlockProcessingError::ExecutionRequestsRootMismatch { + expected: parent_bid.execution_requests_root, + found: requests_root, + } + ); + + apply_parent_execution_payload(state, &parent_bid, requests, spec) +} + +/// Apply the parent execution payload's deferred effects to the state. +/// +/// This implements the spec's `apply_parent_execution_payload` function: +/// 1. Processes deposits, withdrawals, and consolidations from execution requests +/// 2. Queues the builder pending payment from the parent's committed bid +/// 3. Updates `execution_payload_availability` and `latest_block_hash` +pub fn apply_parent_execution_payload( + state: &mut BeaconState, + parent_bid: &ExecutionPayloadBid, + requests: &ExecutionRequests, + spec: &ChainSpec, +) -> Result<(), BlockProcessingError> { + let parent_slot = parent_bid.slot; + let parent_epoch = parent_slot.epoch(E::slots_per_epoch()); + + // Process execution requests from the parent's payload + process_operations::process_deposit_requests_post_gloas(state, &requests.deposits, spec)?; + process_operations::process_withdrawal_requests(state, &requests.withdrawals, spec)?; + process_operations::process_consolidation_requests(state, &requests.consolidations, spec)?; + + // Queue the builder payment + if parent_epoch == state.current_epoch() { + let payment_index = E::slots_per_epoch() + .safe_add(parent_slot.as_u64().safe_rem(E::slots_per_epoch())?)? + as usize; + settle_builder_payment(state, payment_index)?; + } else if parent_epoch == state.previous_epoch() { + let payment_index = parent_slot.as_u64().safe_rem(E::slots_per_epoch())? as usize; + settle_builder_payment(state, payment_index)?; + } else if parent_bid.value > 0 { + // Parent is older than previous epoch -- payment entry has already been + // settled or evicted by process_builder_pending_payments at epoch boundaries. + // Append the withdrawal directly from the bid. + state + .builder_pending_withdrawals_mut()? + .push(BuilderPendingWithdrawal { + fee_recipient: parent_bid.fee_recipient, + amount: parent_bid.value, + builder_index: parent_bid.builder_index, + }) + .map_err(|e| BlockProcessingError::BeaconStateError(e.into()))?; + } + + // Update execution payload availability for the parent slot + let availability_index = parent_slot + .as_usize() + .safe_rem(E::slots_per_historical_root())?; + state + .execution_payload_availability_mut()? + .set(availability_index, true) + .map_err(BlockProcessingError::BitfieldError)?; + + // Update latest_block_hash to the parent bid's block_hash + *state.latest_block_hash_mut()? = parent_bid.block_hash; + + Ok(()) +} + +/// Spec: `settle_builder_payment`. +/// +/// Moves a pending payment from `builder_pending_payments[payment_index]` into +/// `builder_pending_withdrawals`, then clears the slot. +pub fn settle_builder_payment( + state: &mut BeaconState, + payment_index: usize, +) -> Result<(), BlockProcessingError> { + let payment_mut = state + .builder_pending_payments_mut()? + .get_mut(payment_index) + .ok_or(BlockProcessingError::BuilderPaymentIndexOutOfBounds( + payment_index, + ))?; + + let withdrawal = payment_mut.withdrawal.clone(); + *payment_mut = BuilderPendingPayment::default(); + + if withdrawal.amount > 0 { + state + .builder_pending_withdrawals_mut()? + .push(withdrawal) + .map_err(|e| BlockProcessingError::BeaconStateError(e.into()))?; + } + + Ok(()) +} + pub fn process_execution_payload_bid>( state: &mut BeaconState, block: BeaconBlockRef<'_, E, Payload>, diff --git a/consensus/state_processing/src/per_block_processing/errors.rs b/consensus/state_processing/src/per_block_processing/errors.rs index 71083378db..93d668c8c9 100644 --- a/consensus/state_processing/src/per_block_processing/errors.rs +++ b/consensus/state_processing/src/per_block_processing/errors.rs @@ -108,6 +108,13 @@ pub enum BlockProcessingError { }, /// Builder payment index out of bounds (Gloas) BuilderPaymentIndexOutOfBounds(usize), + /// The parent execution requests root doesn't match the committed bid + ExecutionRequestsRootMismatch { + expected: Hash256, + found: Hash256, + }, + /// Parent was not full but non-empty execution requests were provided + NonEmptyParentExecutionRequests, } impl From for BlockProcessingError { diff --git a/consensus/state_processing/src/per_block_processing/tests.rs b/consensus/state_processing/src/per_block_processing/tests.rs index 0203b33e61..96610c2010 100644 --- a/consensus/state_processing/src/per_block_processing/tests.rs +++ b/consensus/state_processing/src/per_block_processing/tests.rs @@ -1014,7 +1014,7 @@ async fn block_replayer_peeking_state_roots() { let block_replayer = BlockReplayer::new(parent_state, &harness.chain.spec) .state_root_iter(state_root_iter.into_iter()) .no_signature_verification() - .apply_blocks(vec![target_block], vec![], None) + .apply_blocks(vec![target_block], None) .unwrap(); assert_eq!( diff --git a/consensus/state_processing/src/per_block_processing/withdrawals.rs b/consensus/state_processing/src/per_block_processing/withdrawals.rs index 72c3339b10..3b14e904c4 100644 --- a/consensus/state_processing/src/per_block_processing/withdrawals.rs +++ b/consensus/state_processing/src/per_block_processing/withdrawals.rs @@ -9,8 +9,8 @@ use safe_arith::{SafeArith, SafeArithIter}; use tree_hash::TreeHash; use types::{ AbstractExecPayload, BeaconState, BeaconStateError, ChainSpec, EthSpec, ExecPayload, - ExpectedWithdrawals, ExpectedWithdrawalsCapella, ExpectedWithdrawalsElectra, - ExpectedWithdrawalsGloas, Validator, Withdrawal, Withdrawals, + ExecutionBlockHash, ExpectedWithdrawals, ExpectedWithdrawalsCapella, + ExpectedWithdrawalsElectra, ExpectedWithdrawalsGloas, Validator, Withdrawal, Withdrawals, }; /// Compute the next batch of withdrawals which should be included in a block. @@ -494,7 +494,11 @@ pub mod gloas { state: &mut BeaconState, spec: &ChainSpec, ) -> Result<(), BlockProcessingError> { - if !state.is_parent_block_full() { + // Return early if the parent block is empty. + let is_genesis_block = *state.latest_block_hash()? == ExecutionBlockHash::default(); + let is_parent_block_empty = + *state.latest_block_hash()? != state.latest_execution_payload_bid()?.block_hash; + if is_genesis_block || is_parent_block_empty { return Ok(()); } diff --git a/consensus/state_processing/src/upgrade/gloas.rs b/consensus/state_processing/src/upgrade/gloas.rs index b39ee6048f..84cdbf22c2 100644 --- a/consensus/state_processing/src/upgrade/gloas.rs +++ b/consensus/state_processing/src/upgrade/gloas.rs @@ -7,10 +7,12 @@ use ssz_types::BitVector; use ssz_types::FixedVector; use std::collections::HashSet; use std::mem; +use tree_hash::TreeHash; use typenum::Unsigned; use types::{ BeaconState, BeaconStateError as Error, BeaconStateGloas, BuilderPendingPayment, ChainSpec, - DepositData, EthSpec, ExecutionPayloadBid, Fork, is_builder_withdrawal_credential, + DepositData, EthSpec, ExecutionPayloadBid, ExecutionRequests, Fork, + is_builder_withdrawal_credential, }; /// Transform a `Fulu` state into a `Gloas` state. @@ -78,6 +80,7 @@ pub fn upgrade_state_to_gloas( // Execution Bid latest_execution_payload_bid: ExecutionPayloadBid { block_hash: pre.latest_execution_payload_header.block_hash, + execution_requests_root: ExecutionRequests::::default().tree_hash_root(), ..Default::default() }, // Capella diff --git a/consensus/types/src/block/beacon_block.rs b/consensus/types/src/block/beacon_block.rs index 5634d842b6..3360728eaa 100644 --- a/consensus/types/src/block/beacon_block.rs +++ b/consensus/types/src/block/beacon_block.rs @@ -716,6 +716,7 @@ impl> EmptyBlock for BeaconBlockGloa voluntary_exits: VariableList::empty(), sync_aggregate: SyncAggregate::empty(), bls_to_execution_changes: VariableList::empty(), + parent_execution_requests: ExecutionRequests::default(), signed_execution_payload_bid: SignedExecutionPayloadBid::empty(), payload_attestations: VariableList::empty(), _phantom: PhantomData, diff --git a/consensus/types/src/block/beacon_block_body.rs b/consensus/types/src/block/beacon_block_body.rs index fd5d976c9b..cd3f4dcaba 100644 --- a/consensus/types/src/block/beacon_block_body.rs +++ b/consensus/types/src/block/beacon_block_body.rs @@ -170,6 +170,8 @@ pub struct BeaconBlockBody = FullPay pub signed_execution_payload_bid: SignedExecutionPayloadBid, #[superstruct(only(Gloas))] pub payload_attestations: VariableList, E::MaxPayloadAttestations>, + #[superstruct(only(Gloas))] + pub parent_execution_requests: ExecutionRequests, #[superstruct(only(Base, Altair, Gloas))] #[metastruct(exclude_from(fields))] #[ssz(skip_serializing, skip_deserializing)] @@ -564,6 +566,7 @@ impl From>> voluntary_exits, sync_aggregate, bls_to_execution_changes, + parent_execution_requests, signed_execution_payload_bid, payload_attestations, _phantom, @@ -580,6 +583,7 @@ impl From>> voluntary_exits, sync_aggregate, bls_to_execution_changes, + parent_execution_requests, signed_execution_payload_bid, payload_attestations, _phantom: PhantomData, @@ -898,6 +902,7 @@ impl From>> voluntary_exits, sync_aggregate, bls_to_execution_changes, + parent_execution_requests, signed_execution_payload_bid, payload_attestations, _phantom, @@ -915,6 +920,7 @@ impl From>> voluntary_exits, sync_aggregate, bls_to_execution_changes, + parent_execution_requests, signed_execution_payload_bid, payload_attestations, _phantom: PhantomData, diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index dd6f52426a..23b01415c8 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -394,13 +394,15 @@ impl> SignedBeaconBlock /// `block_hash` from the parent beacon block's bid. If the parent beacon state is available /// this can alternatively be fetched from `state.latest_payload_bid`. /// - /// This function returns `false` for all blocks prior to Gloas. + /// This function returns `false` for all blocks prior to Gloas and for the zero + /// `parent_block_hash`. pub fn is_parent_block_full(&self, parent_block_hash: ExecutionBlockHash) -> bool { let Ok(signed_payload_bid) = self.message().body().signed_execution_payload_bid() else { // Prior to Gloas. return false; }; - signed_payload_bid.message.parent_block_hash == parent_block_hash + parent_block_hash != ExecutionBlockHash::zero() + && signed_payload_bid.message.parent_block_hash == parent_block_hash } } diff --git a/consensus/types/src/execution/execution_payload.rs b/consensus/types/src/execution/execution_payload.rs index d99b8785fa..c84a46874d 100644 --- a/consensus/types/src/execution/execution_payload.rs +++ b/consensus/types/src/execution/execution_payload.rs @@ -10,7 +10,7 @@ use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ - core::{Address, EthSpec, ExecutionBlockHash, Hash256}, + core::{Address, EthSpec, ExecutionBlockHash, Hash256, Slot}, fork::{ForkName, ForkVersionDecode}, state::BeaconStateError, test_utils::TestRandom, @@ -109,6 +109,12 @@ pub struct ExecutionPayload { #[superstruct(only(Deneb, Electra, Fulu, Gloas), partial_getter(copy))] #[serde(with = "serde_utils::quoted_u64")] pub excess_blob_gas: u64, + /// EIP-7928: Block access list + #[superstruct(only(Gloas))] + #[serde(with = "ssz_types::serde_utils::hex_var_list")] + pub block_access_list: VariableList, + #[superstruct(only(Gloas), partial_getter(copy))] + pub slot_number: Slot, } impl<'a, E: EthSpec> ExecutionPayloadRef<'a, E> { diff --git a/consensus/types/src/execution/execution_payload_bid.rs b/consensus/types/src/execution/execution_payload_bid.rs index 5c8771993e..b2438681c1 100644 --- a/consensus/types/src/execution/execution_payload_bid.rs +++ b/consensus/types/src/execution/execution_payload_bid.rs @@ -37,6 +37,7 @@ pub struct ExecutionPayloadBid { #[serde(with = "serde_utils::quoted_u64")] pub execution_payment: u64, pub blob_kzg_commitments: KzgCommitments, + pub execution_requests_root: Hash256, } impl SignedRoot for ExecutionPayloadBid {} diff --git a/consensus/types/src/execution/execution_payload_envelope.rs b/consensus/types/src/execution/execution_payload_envelope.rs index 169331a884..028423d681 100644 --- a/consensus/types/src/execution/execution_payload_envelope.rs +++ b/consensus/types/src/execution/execution_payload_envelope.rs @@ -20,8 +20,6 @@ pub struct ExecutionPayloadEnvelope { #[serde(with = "serde_utils::quoted_u64")] pub builder_index: u64, pub beacon_block_root: Hash256, - pub slot: Slot, - pub state_root: Hash256, } impl ExecutionPayloadEnvelope { @@ -32,8 +30,6 @@ impl ExecutionPayloadEnvelope { execution_requests: ExecutionRequests::default(), builder_index: 0, beacon_block_root: Hash256::zero(), - slot: Slot::new(0), - state_root: Hash256::zero(), } } @@ -60,6 +56,10 @@ impl ExecutionPayloadEnvelope { + (E::max_consolidation_requests_per_payload() * ::ssz_fixed_len()) } + + pub fn slot(&self) -> Slot { + self.payload.slot_number + } } impl SignedRoot for ExecutionPayloadEnvelope {} diff --git a/consensus/types/src/execution/mod.rs b/consensus/types/src/execution/mod.rs index 591be32b24..a3d4ed8730 100644 --- a/consensus/types/src/execution/mod.rs +++ b/consensus/types/src/execution/mod.rs @@ -12,7 +12,6 @@ mod payload; mod signed_bls_to_execution_change; mod signed_execution_payload_bid; mod signed_execution_payload_envelope; -mod state_payload_status; pub use bls_to_execution_change::BlsToExecutionChange; pub use eth1_data::Eth1Data; @@ -42,4 +41,3 @@ pub use payload::{ pub use signed_bls_to_execution_change::SignedBlsToExecutionChange; pub use signed_execution_payload_bid::SignedExecutionPayloadBid; pub use signed_execution_payload_envelope::SignedExecutionPayloadEnvelope; -pub use state_payload_status::StatePayloadStatus; diff --git a/consensus/types/src/execution/signed_execution_payload_envelope.rs b/consensus/types/src/execution/signed_execution_payload_envelope.rs index 76fa841680..522c8b3f54 100644 --- a/consensus/types/src/execution/signed_execution_payload_envelope.rs +++ b/consensus/types/src/execution/signed_execution_payload_envelope.rs @@ -42,7 +42,7 @@ impl SignedExecutionPayloadEnvelope { } pub fn slot(&self) -> Slot { - self.message.slot + self.message.slot() } pub fn epoch(&self) -> Epoch { diff --git a/consensus/types/src/execution/state_payload_status.rs b/consensus/types/src/execution/state_payload_status.rs deleted file mode 100644 index 1661be6060..0000000000 --- a/consensus/types/src/execution/state_payload_status.rs +++ /dev/null @@ -1,18 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Payload status as it applies to a `BeaconState` post-Gloas. -/// -/// A state can either be a post-state for a block (in which case we call it `Pending`) or a -/// payload envelope (`Full`). When handling states it is often necessary to know which of these -/// two variants is required. -/// -/// Note that states at skipped slots could be either `Pending` or `Full`, depending on whether -/// the payload for the most-recently applied block was also applied. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum StatePayloadStatus { - /// For states produced by `process_block` executed on a `BeaconBlock`. - Pending, - /// For states produced by `process_execution_payload` on a `ExecutionPayloadEnvelope`. - Full, -} diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 8bef8816e5..7e2b3096a8 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -37,7 +37,7 @@ use crate::{ execution::{ Eth1Data, ExecutionPayloadHeaderBellatrix, ExecutionPayloadHeaderCapella, ExecutionPayloadHeaderDeneb, ExecutionPayloadHeaderElectra, ExecutionPayloadHeaderFulu, - ExecutionPayloadHeaderRef, ExecutionPayloadHeaderRefMut, StatePayloadStatus, + ExecutionPayloadHeaderRef, ExecutionPayloadHeaderRefMut, }, fork::{Fork, ForkName, ForkVersionDecode, InconsistentFork, map_fork_name}, light_client::consts::{ @@ -571,9 +571,10 @@ where )] #[metastruct(exclude_from(tree_lists))] pub latest_execution_payload_header: ExecutionPayloadHeaderFulu, + #[test_random(default)] #[superstruct(only(Gloas))] #[metastruct(exclude_from(tree_lists))] - pub latest_execution_payload_bid: ExecutionPayloadBid, + pub latest_block_hash: ExecutionBlockHash, #[superstruct(only(Capella, Deneb, Electra, Fulu, Gloas), partial_getter(copy))] #[serde(with = "serde_utils::quoted_u64")] #[metastruct(exclude_from(tree_lists))] @@ -657,10 +658,9 @@ where pub builder_pending_withdrawals: List, - #[test_random(default)] #[superstruct(only(Gloas))] #[metastruct(exclude_from(tree_lists))] - pub latest_block_hash: ExecutionBlockHash, + pub latest_execution_payload_bid: ExecutionPayloadBid, #[compare_fields(as_iter)] #[test_random(default)] @@ -1273,24 +1273,6 @@ impl BeaconState { } } - /// Determine the payload status of this state. - /// - /// Prior to Gloas this is always `Pending`. - /// - /// Post-Gloas, the definition of the `StatePayloadStatus` is: - /// - /// - `Full` if this state is the result of envelope processing. - /// - `Pending` if this state is the result of block processing. - pub fn payload_status(&self) -> StatePayloadStatus { - if !self.fork_name_unchecked().gloas_enabled() { - StatePayloadStatus::Pending - } else if self.is_parent_block_full() { - StatePayloadStatus::Full - } else { - StatePayloadStatus::Pending - } - } - /// Return `true` if the validator who produced `slot_signature` is eligible to aggregate. /// /// Spec v0.12.1 @@ -2507,22 +2489,6 @@ impl BeaconState { } } - /// Return true if the parent block was full (both beacon block and execution payload were present). - pub fn is_parent_block_full(&self) -> bool { - match self { - BeaconState::Base(_) | BeaconState::Altair(_) => false, - // TODO(EIP-7732): check the implications of this when we get to forkchoice modifications - BeaconState::Bellatrix(_) - | BeaconState::Capella(_) - | BeaconState::Deneb(_) - | BeaconState::Electra(_) - | BeaconState::Fulu(_) => true, - BeaconState::Gloas(state) => { - state.latest_execution_payload_bid.block_hash == state.latest_block_hash - } - } - } - /// Get the committee cache for some `slot`. /// /// Return an error if the cache for the slot's epoch is not initialized. diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index ab24ea35a0..facc8208d9 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.4 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.5 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index 2daafada31..5a54e150db 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -49,8 +49,6 @@ excluded_paths = [ "tests/.*/eip7805", # Heze fork is not implemented "tests/.*/heze/.*", - # TODO(gloas): remove these ignores as Gloas consensus is implemented - "tests/.*/gloas/fork_choice/.*", # Ignore MatrixEntry SSZ tests for now. "tests/.*/.*/ssz_static/MatrixEntry/.*", # TODO: partial data column not implemented yet @@ -77,7 +75,9 @@ excluded_paths = [ # We don't need these manifest files at the moment. "tests/.*/manifest.yaml", # TODO: gossip condition tests not implemented yet - "tests/.*/.*/networking/.*" + "tests/.*/.*/networking/.*", + # TODO: fast confirmation rule not merged yet + "tests/.*/.*/fast_confirmation", ] diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 5e9dc001c7..2af205ee47 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -19,9 +19,13 @@ use beacon_chain::{ custody_context::NodeCustodyType, test_utils::{BeaconChainHarness, EphemeralHarnessType}, }; -use execution_layer::{PayloadStatusV1, json_structures::JsonPayloadStatusV1Status}; +use execution_layer::{ + PayloadStatusV1, PayloadStatusV1Status, json_structures::JsonPayloadStatusV1Status, +}; use serde::Deserialize; use ssz_derive::Decode; +use state_processing::VerifySignatures; +use state_processing::envelope_processing::verify_execution_payload_envelope; use state_processing::state_advance::complete_state_advance; use std::future::Future; use std::sync::Arc; @@ -995,38 +999,95 @@ impl Tester { valid: bool, ) -> Result<(), Error> { let block_root = signed_envelope.message.beacon_block_root; + let block_hash = signed_envelope.message.payload.block_hash; + let store = &self.harness.chain.store; + let spec = &self.harness.chain.spec; - // Store the envelope in the database so that child blocks extending - // the FULL path can load the parent's post-payload state. + // Simulate the EL: pre-configure the mock execution engine to return VALID + // for envelopes the test expects to be valid. Invalid envelopes are left + // unconfigured so the mock EE's default (SYNCING) rejects them. + let el = self.harness.mock_execution_layer.as_ref().unwrap(); if valid { - self.harness - .chain - .store - .put_payload_envelope(&block_root, signed_envelope.clone()) + el.server.set_new_payload_status( + block_hash, + PayloadStatusV1 { + status: JsonPayloadStatusV1Status::Valid.into(), + latest_valid_hash: Some(block_hash), + validation_error: None, + }, + ); + } + + // Attempt to verify the envelope against the block's post-state. + let verification_result = (|| { + let block = store + .get_blinded_block(&block_root) + .map_err(|e| Error::InternalError(format!("Failed to load block: {e:?}")))? + .ok_or_else(|| { + Error::InternalError(format!("Block not found for root {block_root:?}")) + })?; + let block_state_root = block.state_root(); + + let state = store + .get_hot_state(&block_state_root, CACHE_STATE_IN_TESTS) + .map_err(|e| Error::InternalError(format!("Failed to load state: {e:?}")))? + .ok_or_else(|| { + Error::InternalError(format!("State not found for root {block_state_root:?}")) + })?; + + verify_execution_payload_envelope( + &state, + signed_envelope, + VerifySignatures::True, + block_state_root, + spec, + ) + .map_err(|e| { + Error::InternalError(format!("Failed to process execution payload: {e:?}")) + })?; + + // Check the mock EE's response for this block hash (simulates newPayload). + let ee_valid = el + .server + .ctx + .get_new_payload_status(&block_hash) + .and_then(|r| r.ok()) + .is_some_and(|s| s.status == PayloadStatusV1Status::Valid); + if !ee_valid { + return Err(Error::InternalError(format!( + "Mock EE rejected payload with block hash {block_hash:?}", + ))); + } + + Ok(()) + })(); + + if valid { + verification_result?; + + // Store the envelope so that child blocks can load the parent's payload. + store + .put_payload_envelope(&block_root, signed_envelope) .map_err(|e| { Error::InternalError(format!( "Failed to store payload envelope for {block_root:?}: {e:?}", )) })?; - } - let result = self - .harness - .chain - .canonical_head - .fork_choice_write_lock() - .on_valid_payload_envelope_received(block_root); - - if valid { - result.map_err(|e| { - Error::InternalError(format!( - "on_execution_payload for block root {} failed: {:?}", - block_root, e - )) - })?; - } else if result.is_ok() { + self.harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_valid_payload_envelope_received(block_root) + .map_err(|e| { + Error::InternalError(format!( + "on_execution_payload for block root {} failed: {:?}", + block_root, e + )) + })?; + } else if verification_result.is_ok() { return Err(Error::DidntFail(format!( - "on_execution_payload for block root {} should have failed", + "on_execution_payload envelope for block root {} should have failed", block_root ))); } diff --git a/testing/ef_tests/src/cases/operations.rs b/testing/ef_tests/src/cases/operations.rs index 1399815763..f90b6f2a6e 100644 --- a/testing/ef_tests/src/cases/operations.rs +++ b/testing/ef_tests/src/cases/operations.rs @@ -5,7 +5,7 @@ use crate::decode::{ssz_decode_file, ssz_decode_file_with, ssz_decode_state, yam use serde::Deserialize; use ssz::Decode; use state_processing::common::update_progressive_balances_cache::initialize_progressive_balances_cache; -use state_processing::envelope_processing::VerifyStateRoot; +use state_processing::envelope_processing::verify_execution_payload_envelope; use state_processing::epoch_cache::initialize_epoch_cache; use state_processing::per_block_processing::process_operations::{ process_consolidation_requests, process_deposit_requests_post_gloas, @@ -13,7 +13,7 @@ use state_processing::per_block_processing::process_operations::{ }; use state_processing::{ ConsensusContext, - envelope_processing::{EnvelopeProcessingError, process_execution_payload_envelope}, + envelope_processing::EnvelopeProcessingError, per_block_processing::{ VerifyBlockRoot, VerifySignatures, errors::BlockProcessingError, @@ -23,7 +23,7 @@ use state_processing::{ process_bls_to_execution_changes, process_deposits, process_exits, process_payload_attestation, process_proposer_slashings, }, - process_sync_aggregate, withdrawals, + process_parent_execution_payload, process_sync_aggregate, withdrawals, }, }; use std::fmt::Debug; @@ -59,6 +59,12 @@ pub struct ExecutionPayloadBidBlock { block: BeaconBlock, } +/// Newtype for testing parent execution payload processing. +#[derive(Debug, Clone, Deserialize)] +pub struct ParentExecutionPayloadBlock { + block: BeaconBlock, +} + #[derive(Debug, Clone)] pub struct Operations> { metadata: Metadata, @@ -441,8 +447,10 @@ impl Operation for SignedExecutionPayloadEnvelope { "signed_envelope.ssz_snappy".into() } - fn is_enabled_for_fork(fork_name: ForkName) -> bool { - fork_name.gloas_enabled() + fn is_enabled_for_fork(_fork_name: ForkName) -> bool { + // TODO(gloas): re-enable this test when enabled upstream + // fork_name.gloas_enabled() + false } fn decode(path: &Path, _: ForkName, _spec: &ChainSpec) -> Result { @@ -460,12 +468,12 @@ impl Operation for SignedExecutionPayloadEnvelope { .as_ref() .is_some_and(|e| e.execution_valid); if valid { - process_execution_payload_envelope( + let block_state_root = state.update_tree_hash_cache()?; + verify_execution_payload_envelope( state, - None, self, VerifySignatures::True, - VerifyStateRoot::True, + block_state_root, spec, ) } else { @@ -505,6 +513,36 @@ impl Operation for ExecutionPayloadBidBlock { } } +impl Operation for ParentExecutionPayloadBlock { + type Error = BlockProcessingError; + + fn handler_name() -> String { + "parent_execution_payload".into() + } + + fn filename() -> String { + "block.ssz_snappy".into() + } + + fn is_enabled_for_fork(fork_name: ForkName) -> bool { + fork_name.gloas_enabled() + } + + fn decode(path: &Path, _fork_name: ForkName, spec: &ChainSpec) -> Result { + ssz_decode_file_with(path, |bytes| BeaconBlock::from_ssz_bytes(bytes, spec)) + .map(|block| ParentExecutionPayloadBlock { block }) + } + + fn apply_to( + &self, + state: &mut BeaconState, + spec: &ChainSpec, + _: &Operations, + ) -> Result<(), BlockProcessingError> { + process_parent_execution_payload(state, self.block.to_ref(), spec) + } +} + impl Operation for WithdrawalsPayload { type Error = BlockProcessingError; diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index 4373d6b7d1..96798c910c 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -723,8 +723,12 @@ impl Handler for ForkChoiceHandler { return false; } - // on_execution_payload tests exist only for Gloas. - if self.handler_name == "on_execution_payload" && !fork_name.gloas_enabled() { + // on_execution_payload_envelope and get_parent_payload_status tests exist only for + // Gloas and later. + if (self.handler_name == "on_execution_payload_envelope" + || self.handler_name == "get_parent_payload_status") + && !fork_name.gloas_enabled() + { return false; } diff --git a/testing/ef_tests/src/lib.rs b/testing/ef_tests/src/lib.rs index 5587bbed41..0ffedc7eb8 100644 --- a/testing/ef_tests/src/lib.rs +++ b/testing/ef_tests/src/lib.rs @@ -2,10 +2,10 @@ pub use case_result::CaseResult; pub use cases::{ BuilderPendingPayments, Case, EffectiveBalanceUpdates, Eth1DataReset, ExecutionPayloadBidBlock, FeatureName, HistoricalRootsUpdate, HistoricalSummariesUpdate, InactivityUpdates, - JustificationAndFinalization, ParticipationFlagUpdates, ParticipationRecordUpdates, - PendingBalanceDeposits, PendingConsolidations, ProposerLookahead, PtcWindow, RandaoMixesReset, - RegistryUpdates, RewardsAndPenalties, Slashings, SlashingsReset, SyncCommitteeUpdates, - WithdrawalsPayload, + JustificationAndFinalization, ParentExecutionPayloadBlock, ParticipationFlagUpdates, + ParticipationRecordUpdates, PendingBalanceDeposits, PendingConsolidations, ProposerLookahead, + PtcWindow, RandaoMixesReset, RegistryUpdates, RewardsAndPenalties, Slashings, SlashingsReset, + SyncCommitteeUpdates, WithdrawalsPayload, }; pub use decode::log_file_access; pub use error::Error; diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 62eb2dd038..79a02d7e80 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -99,6 +99,12 @@ fn operations_execution_payload_bid() { OperationsHandler::>::default().run(); } +#[test] +fn operations_parent_execution_payload() { + OperationsHandler::>::default().run(); + OperationsHandler::>::default().run(); +} + #[test] fn operations_payload_attestation() { OperationsHandler::>::default().run(); @@ -1039,9 +1045,15 @@ fn fork_choice_deposit_with_reorg() { } #[test] -fn fork_choice_on_execution_payload() { - ForkChoiceHandler::::new("on_execution_payload").run(); - ForkChoiceHandler::::new("on_execution_payload").run(); +fn fork_choice_on_execution_payload_envelope() { + ForkChoiceHandler::::new("on_execution_payload_envelope").run(); + ForkChoiceHandler::::new("on_execution_payload_envelope").run(); +} + +#[test] +fn fork_choice_get_parent_payload_status() { + ForkChoiceHandler::::new("get_parent_payload_status").run(); + ForkChoiceHandler::::new("get_parent_payload_status").run(); } #[test] diff --git a/testing/execution_engine_integration/src/test_rig.rs b/testing/execution_engine_integration/src/test_rig.rs index 6bf4a1aa52..05170d907c 100644 --- a/testing/execution_engine_integration/src/test_rig.rs +++ b/testing/execution_engine_integration/src/test_rig.rs @@ -315,6 +315,7 @@ impl TestRig { Address::repeat_byte(42), Some(vec![]), None, + None, ), ) .await; @@ -359,6 +360,7 @@ impl TestRig { suggested_fee_recipient, Some(vec![]), None, + None, ); let payload_parameters = PayloadParameters { @@ -517,6 +519,7 @@ impl TestRig { suggested_fee_recipient, Some(vec![]), None, + None, ); let payload_parameters = PayloadParameters { @@ -577,6 +580,7 @@ impl TestRig { Address::repeat_byte(42), Some(vec![]), None, + None, ); let slot = Slot::new(42); let head_block_root = Hash256::repeat_byte(100); diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index 76f7a86aab..c5bcd88eb1 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -1432,7 +1432,7 @@ impl ValidatorStore for LighthouseValidatorS ) -> Result, Error> { let signing_context = self.signing_context( Domain::BeaconBuilder, - envelope.slot.epoch(E::slots_per_epoch()), + envelope.slot().epoch(E::slots_per_epoch()), ); // Execution payload envelope signing is not slashable, bypass doppelganger protection. From 02c2841db0832506dcd67c0258043452374c3ee6 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 21 Apr 2026 17:23:07 +1000 Subject: [PATCH 128/189] Revert Gloas API changes from 9092 (#9151) This reverts commit 2749e18d0e35e6f148642623327acac5a7066658, from: - #9092 We no longer need those changes since the abolition of pending/full states. Co-Authored-By: Michael Sproul --- beacon_node/http_api/src/block_id.rs | 10 +++--- beacon_node/http_api/src/state_id.rs | 51 ++++++---------------------- common/eth2/src/types.rs | 8 ----- 3 files changed, 15 insertions(+), 54 deletions(-) diff --git a/beacon_node/http_api/src/block_id.rs b/beacon_node/http_api/src/block_id.rs index f4645f1304..e6b1ed0879 100644 --- a/beacon_node/http_api/src/block_id.rs +++ b/beacon_node/http_api/src/block_id.rs @@ -1,5 +1,5 @@ use crate::version::inconsistent_fork_rejection; -use crate::{ExecutionOptimistic, state_id::checkpoint_block_and_execution_optimistic}; +use crate::{ExecutionOptimistic, state_id::checkpoint_slot_and_execution_optimistic}; use beacon_chain::kzg_utils::reconstruct_blobs; use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes, WhenSlotSkipped}; use eth2::beacon_response::{ExecutionOptimisticFinalizedMetadata, UnversionedResponse}; @@ -60,15 +60,15 @@ impl BlockId { CoreBlockId::Finalized => { let finalized_checkpoint = chain.canonical_head.cached_head().finalized_checkpoint(); - let (_block, execution_optimistic) = - checkpoint_block_and_execution_optimistic(chain, finalized_checkpoint)?; + let (_slot, execution_optimistic) = + checkpoint_slot_and_execution_optimistic(chain, finalized_checkpoint)?; Ok((finalized_checkpoint.root, execution_optimistic, true)) } CoreBlockId::Justified => { let justified_checkpoint = chain.canonical_head.cached_head().justified_checkpoint(); - let (_block, execution_optimistic) = - checkpoint_block_and_execution_optimistic(chain, justified_checkpoint)?; + let (_slot, execution_optimistic) = + checkpoint_slot_and_execution_optimistic(chain, justified_checkpoint)?; Ok((justified_checkpoint.root, execution_optimistic, false)) } CoreBlockId::Slot(slot) => { diff --git a/beacon_node/http_api/src/state_id.rs b/beacon_node/http_api/src/state_id.rs index ce18388926..13fb9b2c58 100644 --- a/beacon_node/http_api/src/state_id.rs +++ b/beacon_node/http_api/src/state_id.rs @@ -2,7 +2,6 @@ use crate::ExecutionOptimistic; use crate::metrics; use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes}; use eth2::types::StateId as CoreStateId; -use proto_array::Block; use std::fmt; use std::str::FromStr; use types::{BeaconState, Checkpoint, EthSpec, Fork, Hash256, Slot}; @@ -20,8 +19,6 @@ impl StateId { Self(CoreStateId::Slot(slot)) } - // TODO(gloas) add tests for finalized and justified checkpoint states to ensure - // we return the post block state for gloas /// Return the state root identified by `self`. pub fn root( &self, @@ -44,41 +41,15 @@ impl StateId { CoreStateId::Finalized => { let finalized_checkpoint = chain.canonical_head.cached_head().finalized_checkpoint(); - - let slot = finalized_checkpoint - .epoch - .start_slot(T::EthSpec::slots_per_epoch()); - let (block, execution_optimistic) = - checkpoint_block_and_execution_optimistic(chain, finalized_checkpoint)?; - - if chain - .spec - .fork_name_at_slot::(block.slot) - .gloas_enabled() - { - return Ok((block.state_root, execution_optimistic, true)); - } - + let (slot, execution_optimistic) = + checkpoint_slot_and_execution_optimistic(chain, finalized_checkpoint)?; (slot, execution_optimistic, true) } CoreStateId::Justified => { let justified_checkpoint = chain.canonical_head.cached_head().justified_checkpoint(); - - let slot = justified_checkpoint - .epoch - .start_slot(T::EthSpec::slots_per_epoch()); - let (block, execution_optimistic) = - checkpoint_block_and_execution_optimistic(chain, justified_checkpoint)?; - - if chain - .spec - .fork_name_at_slot::(block.slot) - .gloas_enabled() - { - return Ok((block.state_root, execution_optimistic, false)); - } - + let (slot, execution_optimistic) = + checkpoint_slot_and_execution_optimistic(chain, justified_checkpoint)?; (slot, execution_optimistic, false) } CoreStateId::Slot(slot) => ( @@ -283,11 +254,13 @@ impl fmt::Display for StateId { } } -/// Returns checkpoint block and the execution status of the checkpoint's `root`. -pub fn checkpoint_block_and_execution_optimistic( +/// Returns the first slot of the checkpoint's `epoch` and the execution status of the checkpoint's +/// `root`. +pub fn checkpoint_slot_and_execution_optimistic( chain: &BeaconChain, checkpoint: Checkpoint, -) -> Result<(Block, ExecutionOptimistic), warp::reject::Rejection> { +) -> Result<(Slot, ExecutionOptimistic), warp::reject::Rejection> { + let slot = checkpoint.epoch.start_slot(T::EthSpec::slots_per_epoch()); let fork_choice = chain.canonical_head.fork_choice_read_lock(); let finalized_checkpoint = fork_choice.cached_fork_choice_view().finalized_checkpoint; @@ -304,9 +277,5 @@ pub fn checkpoint_block_and_execution_optimistic( .map_err(BeaconChainError::ForkChoiceError) .map_err(warp_utils::reject::unhandled_error)?; - let block = fork_choice.get_block(&checkpoint.root).ok_or_else(|| { - warp_utils::reject::custom_not_found(format!("Block {:?} not found", checkpoint.root)) - })?; - - Ok((block, execution_optimistic)) + Ok((slot, execution_optimistic)) } diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index d724156f86..950abeadd8 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -125,15 +125,7 @@ impl fmt::Display for BlockId { pub enum StateId { Head, Genesis, - /// Pre-gloas the finalized state is the checkpoint block state - /// advanced to the epoch boundary. - /// Post-gloas this state is always the checkpoint post-block state and is not advanced - /// to the epoch boundary. Finalized, - /// Pre-gloas the justified state is the checkpoint block state - /// advanced to the epoch boundary. - /// Post-gloas this state is always the checkpoint post-block state and is not advanced - /// to the epoch boundary. Justified, Slot(Slot), Root(Hash256), From 7731b5f250745a8fa039ce46d90826ca91250a11 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Tue, 21 Apr 2026 16:36:22 +0900 Subject: [PATCH 129/189] Gloas engine api updates (#9150) Co-Authored-By: Eitan Seri-Levi --- beacon_node/client/src/notifier.rs | 12 ++++---- beacon_node/execution_layer/src/engine_api.rs | 9 ++++-- .../execution_layer/src/engine_api/http.rs | 28 +++++++++++++++---- .../src/test_utils/handle_rpc.rs | 13 +++++++-- .../execution_layer/src/test_utils/mod.rs | 1 + 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/beacon_node/client/src/notifier.rs b/beacon_node/client/src/notifier.rs index 4acb8c3aed..0d73a6bf7a 100644 --- a/beacon_node/client/src/notifier.rs +++ b/beacon_node/client/src/notifier.rs @@ -7,8 +7,8 @@ use execution_layer::{ EngineCapabilities, http::{ ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3, ENGINE_GET_PAYLOAD_V2, - ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V2, - ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, + ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_GET_PAYLOAD_V6, + ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, ENGINE_NEW_PAYLOAD_V5, }, }; use lighthouse_network::{NetworkGlobals, types::SyncState}; @@ -555,11 +555,11 @@ fn methods_required_for_fork( } } ForkName::Gloas => { - if !capabilities.get_payload_v5 { - missing_methods.push(ENGINE_GET_PAYLOAD_V5); + if !capabilities.get_payload_v6 { + missing_methods.push(ENGINE_GET_PAYLOAD_V6); } - if !capabilities.new_payload_v4 { - missing_methods.push(ENGINE_NEW_PAYLOAD_V4); + if !capabilities.new_payload_v5 { + missing_methods.push(ENGINE_NEW_PAYLOAD_V5); } } } diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index 236340aa29..6566616c04 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -4,8 +4,9 @@ use crate::http::{ ENGINE_FORKCHOICE_UPDATED_V4, ENGINE_GET_BLOBS_V1, ENGINE_GET_BLOBS_V2, ENGINE_GET_CLIENT_VERSION_V1, ENGINE_GET_PAYLOAD_BODIES_BY_HASH_V1, ENGINE_GET_PAYLOAD_BODIES_BY_RANGE_V1, ENGINE_GET_PAYLOAD_V1, ENGINE_GET_PAYLOAD_V2, - ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_NEW_PAYLOAD_V1, - ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, ENGINE_NEW_PAYLOAD_V5, + ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, ENGINE_GET_PAYLOAD_V6, + ENGINE_NEW_PAYLOAD_V1, ENGINE_NEW_PAYLOAD_V2, ENGINE_NEW_PAYLOAD_V3, ENGINE_NEW_PAYLOAD_V4, + ENGINE_NEW_PAYLOAD_V5, }; use eth2::types::{ BlobsBundle, SsePayloadAttributes, SsePayloadAttributesV1, SsePayloadAttributesV2, @@ -591,6 +592,7 @@ pub struct EngineCapabilities { pub get_payload_v3: bool, pub get_payload_v4: bool, pub get_payload_v5: bool, + pub get_payload_v6: bool, pub get_client_version_v1: bool, pub get_blobs_v1: bool, pub get_blobs_v2: bool, @@ -647,6 +649,9 @@ impl EngineCapabilities { if self.get_payload_v5 { response.push(ENGINE_GET_PAYLOAD_V5); } + if self.get_payload_v6 { + response.push(ENGINE_GET_PAYLOAD_V6); + } if self.get_client_version_v1 { response.push(ENGINE_GET_CLIENT_VERSION_V1); } diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index dcf8205406..b9f6289d05 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -43,6 +43,7 @@ pub const ENGINE_GET_PAYLOAD_V2: &str = "engine_getPayloadV2"; pub const ENGINE_GET_PAYLOAD_V3: &str = "engine_getPayloadV3"; pub const ENGINE_GET_PAYLOAD_V4: &str = "engine_getPayloadV4"; pub const ENGINE_GET_PAYLOAD_V5: &str = "engine_getPayloadV5"; +pub const ENGINE_GET_PAYLOAD_V6: &str = "engine_getPayloadV6"; pub const ENGINE_GET_PAYLOAD_TIMEOUT: Duration = Duration::from_secs(2); pub const ENGINE_FORKCHOICE_UPDATED_V1: &str = "engine_forkchoiceUpdatedV1"; @@ -82,6 +83,7 @@ pub static LIGHTHOUSE_CAPABILITIES: &[&str] = &[ ENGINE_GET_PAYLOAD_V3, ENGINE_GET_PAYLOAD_V4, ENGINE_GET_PAYLOAD_V5, + ENGINE_GET_PAYLOAD_V6, ENGINE_FORKCHOICE_UPDATED_V1, ENGINE_FORKCHOICE_UPDATED_V2, ENGINE_FORKCHOICE_UPDATED_V3, @@ -1052,10 +1054,25 @@ impl HttpJsonRpc { .try_into() .map_err(Error::BadResponse) } + _ => Err(Error::UnsupportedForkVariant(format!( + "called get_payload_v5 with {}", + fork_name + ))), + } + } + + pub async fn get_payload_v6( + &self, + fork_name: ForkName, + payload_id: PayloadId, + ) -> Result, Error> { + let params = json!([JsonPayloadIdRequest::from(payload_id)]); + + match fork_name { ForkName::Gloas => { let response: JsonGetPayloadResponseGloas = self .rpc_request( - ENGINE_GET_PAYLOAD_V5, + ENGINE_GET_PAYLOAD_V6, params, ENGINE_GET_PAYLOAD_TIMEOUT * self.execution_timeout_multiplier, ) @@ -1065,7 +1082,7 @@ impl HttpJsonRpc { .map_err(Error::BadResponse) } _ => Err(Error::UnsupportedForkVariant(format!( - "called get_payload_v5 with {}", + "called get_payload_v6 with {}", fork_name ))), } @@ -1237,6 +1254,7 @@ impl HttpJsonRpc { get_payload_v3: capabilities.contains(ENGINE_GET_PAYLOAD_V3), get_payload_v4: capabilities.contains(ENGINE_GET_PAYLOAD_V4), get_payload_v5: capabilities.contains(ENGINE_GET_PAYLOAD_V5), + get_payload_v6: capabilities.contains(ENGINE_GET_PAYLOAD_V6), get_client_version_v1: capabilities.contains(ENGINE_GET_CLIENT_VERSION_V1), get_blobs_v1: capabilities.contains(ENGINE_GET_BLOBS_V1), get_blobs_v2: capabilities.contains(ENGINE_GET_BLOBS_V2), @@ -1429,10 +1447,10 @@ impl HttpJsonRpc { } } ForkName::Gloas => { - if engine_capabilities.get_payload_v5 { - self.get_payload_v5(fork_name, payload_id).await + if engine_capabilities.get_payload_v6 { + self.get_payload_v6(fork_name, payload_id).await } else { - Err(Error::RequiredMethodUnsupported("engine_getPayloadv5")) + Err(Error::RequiredMethodUnsupported("engine_getPayloadV6")) } } ForkName::Base | ForkName::Altair => Err(Error::UnsupportedForkVariant(format!( diff --git a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs index 058f1e76da..3054289996 100644 --- a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs +++ b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs @@ -277,7 +277,8 @@ pub async fn handle_rpc( | ENGINE_GET_PAYLOAD_V2 | ENGINE_GET_PAYLOAD_V3 | ENGINE_GET_PAYLOAD_V4 - | ENGINE_GET_PAYLOAD_V5 => { + | ENGINE_GET_PAYLOAD_V5 + | ENGINE_GET_PAYLOAD_V6 => { let request: JsonPayloadIdRequest = get_param(params, 0).map_err(|s| (s, BAD_PARAMS_ERROR_CODE))?; let id = request.into(); @@ -363,7 +364,8 @@ pub async fn handle_rpc( && (method == ENGINE_GET_PAYLOAD_V1 || method == ENGINE_GET_PAYLOAD_V2 || method == ENGINE_GET_PAYLOAD_V3 - || method == ENGINE_GET_PAYLOAD_V4) + || method == ENGINE_GET_PAYLOAD_V4 + || method == ENGINE_GET_PAYLOAD_V5) { return Err(( format!("{} called after Gloas fork!", method), @@ -455,13 +457,18 @@ pub async fn handle_rpc( }) .unwrap() } + _ => unreachable!(), + }) + } + ENGINE_GET_PAYLOAD_V6 => { + Ok(match JsonExecutionPayload::try_from(response).unwrap() { JsonExecutionPayload::Gloas(execution_payload) => { serde_json::to_value(JsonGetPayloadResponseGloas { execution_payload, block_value: Uint256::from(DEFAULT_MOCK_EL_PAYLOAD_VALUE_WEI), blobs_bundle: maybe_blobs .ok_or(( - "No blobs returned despite V5 Payload".to_string(), + "No blobs returned despite V6 Payload".to_string(), GENERIC_ERROR_CODE, ))? .into(), diff --git a/beacon_node/execution_layer/src/test_utils/mod.rs b/beacon_node/execution_layer/src/test_utils/mod.rs index 6d8c30d316..c382d8abf5 100644 --- a/beacon_node/execution_layer/src/test_utils/mod.rs +++ b/beacon_node/execution_layer/src/test_utils/mod.rs @@ -55,6 +55,7 @@ pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = EngineCapabilities { get_payload_v3: true, get_payload_v4: true, get_payload_v5: true, + get_payload_v6: true, get_client_version_v1: true, get_blobs_v1: true, get_blobs_v2: true, From 4de08f1b4ab6dfcea543319266eb8d2f8db0cd6f Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 22 Apr 2026 12:03:13 +1000 Subject: [PATCH 130/189] Remove more mentions of "pending"/"full" states (#9156) Just a little naming cleanup (no semantic changes) to remove mentions of pending and full states that were still lurking. This hopefully helps Claude forget about the concept (it defaults to naming variables `pending_state`s without this change). Co-Authored-By: Michael Sproul --- .../src/block_production/gloas.rs | 9 ++--- beacon_node/beacon_chain/src/test_utils.rs | 14 +++---- beacon_node/beacon_chain/tests/store_tests.rs | 38 ++++++++----------- beacon_node/http_api/src/produce_block.rs | 2 +- 4 files changed, 27 insertions(+), 36 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index df8d19d214..f895120eac 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -444,9 +444,9 @@ impl BeaconChain { /// Complete a block by computing its state root, and /// - /// Return `(block, pending_state, block_value)` where: + /// Return `(block, post_block_state, block_value)` where: /// - /// - `pending_state` is the state post block application (prior to payload application) + /// - `post_block_state` is the state post block application /// - `block_value` is the consensus-layer rewards for `block` #[allow(clippy::type_complexity)] #[instrument(skip_all, level = "debug")] @@ -571,9 +571,6 @@ impl BeaconChain { drop(state_root_timer); - // Clone the Pending state (post-block, pre-envelope) for callers that need it. - let pending_state = state.clone(); - let (mut block, _) = signed_beacon_block.deconstruct(); *block.state_root_mut() = state_root; @@ -628,7 +625,7 @@ impl BeaconChain { "Produced beacon block" ); - Ok((block, pending_state, consensus_block_value)) + Ok((block, state, consensus_block_value)) } // TODO(gloas) introduce `ProposerPreferences` so we can build out trustless diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index e84f9ad983..00a2ed64f1 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1102,7 +1102,7 @@ where } /// Returns a newly created block, signed by the proposer for the given slot, - /// along with the execution payload envelope (for Gloas) and the pending state. + /// along with the execution payload envelope (for Gloas) and the post-block state. /// /// For pre-Gloas forks, the envelope is `None` and this behaves like `make_block`. pub async fn make_block_with_envelope( @@ -1142,7 +1142,7 @@ where ) }; - let (block, pending_state, _consensus_block_value) = self + let (block, post_block_state, _consensus_block_value) = self .chain .produce_block_on_state_gloas( state, @@ -1159,8 +1159,8 @@ where let signed_block = Arc::new(block.sign( &self.validator_keypairs[proposer_index].sk, - &pending_state.fork(), - pending_state.genesis_validators_root(), + &post_block_state.fork(), + post_block_state.genesis_validators_root(), &self.spec, )); @@ -1175,8 +1175,8 @@ where let domain = self.spec.get_domain( epoch, Domain::BeaconBuilder, - &pending_state.fork(), - pending_state.genesis_validators_root(), + &post_block_state.fork(), + post_block_state.genesis_validators_root(), ); let message = envelope.signing_root(domain); let signature = self.validator_keypairs[proposer_index].sk.sign(message); @@ -1187,7 +1187,7 @@ where }); let block_contents: SignedBlockContentsTuple = (signed_block, None); - (block_contents, signed_envelope, pending_state) + (block_contents, signed_envelope, post_block_state) } else { let (block_contents, state) = self.make_block(state, slot).await; (block_contents, None, state) diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 47bda60eb8..86adf50995 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -5693,7 +5693,7 @@ async fn test_gloas_block_and_envelope_storage_generic( check_db_invariants(&harness); } -/// Test block replay with and without envelopes. +/// Test that Gloas block replay works without envelopes. #[tokio::test] async fn test_gloas_block_replay_with_envelopes() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { @@ -5709,14 +5709,13 @@ async fn test_gloas_block_replay_with_envelopes() { let mut state = genesis_state.clone(); let mut last_block_root = Hash256::zero(); - let mut pending_states = HashMap::new(); - let mut full_states = HashMap::new(); + let mut states = HashMap::new(); for i in 1..=num_blocks { let slot = Slot::new(i); harness.advance_slot(); - let (block_contents, envelope, pending_state) = + let (block_contents, envelope, mut block_state) = harness.make_block_with_envelope(state, slot).await; let block_root = block_contents.0.canonical_root(); @@ -5725,18 +5724,16 @@ async fn test_gloas_block_replay_with_envelopes() { .await .unwrap(); - let pending_state_root = pending_state.clone().update_tree_hash_cache().unwrap(); - pending_states.insert(slot, (pending_state_root, pending_state.clone())); + let state_root = block_state.update_tree_hash_cache().unwrap(); + states.insert(slot, (state_root, block_state.clone())); let envelope = envelope.expect("Gloas block should have envelope"); - let full_state = pending_state; harness - .process_envelope(block_root, envelope, &full_state, pending_state_root) + .process_envelope(block_root, envelope, &block_state, state_root) .await; - full_states.insert(slot, (pending_state_root, full_state.clone())); last_block_root = block_root; - state = full_state; + state = block_state; } let end_slot = Slot::new(num_blocks); @@ -5756,7 +5753,7 @@ async fn test_gloas_block_replay_with_envelopes() { .into_state(); replayed.apply_pending_mutations().unwrap(); - let (_, mut expected) = pending_states.get(&end_slot).unwrap().clone(); + let (_, mut expected) = states.get(&end_slot).unwrap().clone(); expected.apply_pending_mutations().unwrap(); replayed.drop_all_caches().unwrap(); @@ -5782,8 +5779,7 @@ async fn test_gloas_hot_state_hierarchy() { // Build enough blocks to span multiple epochs. With MinimalEthSpec (8 slots/epoch), // 40 slots covers 5 epochs. let num_blocks = E::slots_per_epoch() * 5; - // TODO(gloas): enable finalisation by increasing this threshold - let some_validators = (0..LOW_VALIDATOR_COUNT).collect::>(); + let all_validators = (0..LOW_VALIDATOR_COUNT).collect::>(); let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); @@ -5796,7 +5792,7 @@ async fn test_gloas_hot_state_hierarchy() { let slot = Slot::new(i); harness.advance_slot(); - let (block_contents, envelope, mut pending_state) = + let (block_contents, envelope, mut block_state) = harness.make_block_with_envelope(state.clone(), slot).await; let block_root = block_contents.0.canonical_root(); let signed_block = block_contents.0.clone(); @@ -5809,24 +5805,22 @@ async fn test_gloas_hot_state_hierarchy() { // Attest to the current block at its own slot (same-slot attestation). // In Gloas, same-slot attestations have index=0 and route to Pending in // fork choice, correctly propagating weight through the Full path. - // Use pending_state (at slot i) so the target root resolves correctly. - let pending_state_root = pending_state.update_tree_hash_cache().unwrap(); + let state_root = block_state.update_tree_hash_cache().unwrap(); harness.attest_block( - &pending_state, - pending_state_root, + &block_state, + state_root, block_root.into(), &signed_block, - &some_validators, + &all_validators, ); let envelope = envelope.expect("Gloas block should have envelope"); - let full_state = pending_state; harness - .process_envelope(block_root, envelope, &full_state, pending_state_root) + .process_envelope(block_root, envelope, &block_state, state_root) .await; last_block_root = block_root; - state = full_state; + state = block_state; } // Head should be the block at slot 40 with full payload. diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 70475de130..7173eb698f 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -70,7 +70,7 @@ pub async fn produce_block_v4( let graffiti_settings = GraffitiSettings::new(query.graffiti, query.graffiti_policy); - let (block, _pending_state, consensus_block_value) = chain + let (block, _block_state, consensus_block_value) = chain .produce_block_with_verification_gloas( randao_reveal, slot, From 5a13e37456493c5d0441f27f8e51e3eae50ccd40 Mon Sep 17 00:00:00 2001 From: Mac L Date: Wed, 22 Apr 2026 15:07:59 +0300 Subject: [PATCH 131/189] Fix audit failure for `rustls-webpki` (#9161) Another `rustls-webpki` audit failure: https://rustsec.org/advisories/RUSTSEC-2026-0104 Bump `rustls-webpki` to the latest (unaffected) version. As with the previous `rustls-webpki` vulns, we add an ignore for our older version required by warp 0.3. This ignore will be resolved by https://github.com/sigp/lighthouse/pull/9001 Co-Authored-By: Mac L --- Cargo.lock | 8 ++++---- Makefile | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 329518f647..b136e7da98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5266,7 +5266,7 @@ dependencies = [ "rcgen", "ring", "rustls 0.23.35", - "rustls-webpki 0.103.12", + "rustls-webpki 0.103.13", "thiserror 2.0.17", "x509-parser", "yasna", @@ -7678,7 +7678,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.12", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] @@ -7727,9 +7727,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", diff --git a/Makefile b/Makefile index 280e74d1d9..9246b33999 100644 --- a/Makefile +++ b/Makefile @@ -330,7 +330,7 @@ install-audit: cargo install --force cargo-audit audit-CI: - cargo audit --ignore RUSTSEC-2026-0049 --ignore RUSTSEC-2026-0098 --ignore RUSTSEC-2026-0099 + cargo audit --ignore RUSTSEC-2026-0049 --ignore RUSTSEC-2026-0098 --ignore RUSTSEC-2026-0099 --ignore RUSTSEC-2026-0104 # Runs cargo deny (check for banned crates, duplicate versions, and source restrictions) deny: install-deny deny-CI From cfc748309f55a9da5d585be646e1d425c5d9571d Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Thu, 23 Apr 2026 00:43:17 +0900 Subject: [PATCH 132/189] At the fork transition ensure we build ontop of the correct parent block hash (#9160) When producing a block at the fork, treat parent payload status as full I've been testing on kurtosis and this fixes an issue where we cant propose a block at the fork. This is a screenshot of the fix. The envelope shows missing because we are missing an SSE event, but the envelope is in fact being imported and the chain is progressing just fine image Co-Authored-By: Eitan Seri-Levi --- .../src/block_production/gloas.rs | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index f895120eac..9b3fc2806e 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -690,13 +690,19 @@ impl BeaconChain { let parent_bid = state.latest_execution_payload_bid()?; // TODO(gloas): need should_extend_payload check here as well - let parent_block_hash = if parent_payload_status == PayloadStatus::Full { - // Build on parent bid's payload. - parent_bid.block_hash - } else { - // Skip parent bid's payload. For genesis this is the EL genesis hash. - parent_bid.parent_block_hash - }; + let parent_block_slot = state.latest_block_header().slot; + let parent_is_pre_gloas = !self + .spec + .fork_name_at_slot::(parent_block_slot) + .gloas_enabled(); + let parent_block_hash = + if parent_payload_status == PayloadStatus::Full || parent_is_pre_gloas { + // Build on parent bid's payload. + parent_bid.block_hash + } else { + // Skip parent bid's payload. For genesis this is the EL genesis hash. + parent_bid.parent_block_hash + }; // TODO(gloas) this should be BlockProductionVersion::V4 // V3 is okay for now as long as we're not connected to a builder From 82dc8b4edc859469647e07d475d4b68466beb498 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Thu, 23 Apr 2026 20:32:26 +0900 Subject: [PATCH 133/189] Ensure payload envelope streamer always serves canonical envelopes after the split slot (#9085) Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi --- .../beacon_chain/src/canonical_head.rs | 23 +- .../beacon_chain_adapter.rs | 4 +- .../src/payload_envelope_streamer/mod.rs | 9 +- .../src/payload_envelope_streamer/tests.rs | 19 +- consensus/fork_choice/src/fork_choice.rs | 24 ++ .../src/fork_choice_test_definition.rs | 113 +++++++- .../gloas_payload.rs | 273 +++++++++++++++++- consensus/proto_array/src/proto_array.rs | 86 +++++- .../src/proto_array_fork_choice.rs | 18 ++ 9 files changed, 533 insertions(+), 36 deletions(-) diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 1e5e1300ab..74670b02d7 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -383,11 +383,24 @@ impl CanonicalHead { Ok((head, execution_status)) } - // TODO(gloas) just a stub for now, implement this once we have fork choice. - /// Returns true if the payload for this block is canonical according to fork choice - /// Returns an error if the block root doesn't exist in fork choice. - pub fn block_has_canonical_payload(&self, _root: &Hash256) -> Result { - Ok(true) + /// Returns `true` if the payload for this block is canonical (Full) according to fork choice. + pub fn block_has_canonical_payload( + &self, + root: &Hash256, + spec: &ChainSpec, + ) -> Result { + let cached_head = self.cached_head(); + let head_root = cached_head.head_block_root(); + let head_payload_status = cached_head.head_payload_status(); + + if *root == head_root { + return Ok(head_payload_status == PayloadStatus::Full); + } + + self.fork_choice_read_lock() + .get_canonical_payload_status(root, spec) + .map(|status| status == PayloadStatus::Full) + .map_err(Error::ForkChoiceError) } /// Returns a clone of `self.cached_head`. diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs index 47c58f07b9..4e36cf7895 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/beacon_chain_adapter.rs @@ -37,6 +37,8 @@ impl EnvelopeStreamerBeaconAdapter { &self, root: &Hash256, ) -> Result { - self.chain.canonical_head.block_has_canonical_payload(root) + self.chain + .canonical_head + .block_has_canonical_payload(root, &self.chain.spec) } } diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs index d10e3762a4..5b1bda5dd5 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/mod.rs @@ -132,13 +132,8 @@ impl PayloadEnvelopeStreamer { results.push((*root, Ok(None))); } } - Err(_) => { - results.push(( - *root, - Err(BeaconChainError::EnvelopeStreamerError( - Error::BlockMissingFromForkChoice, - )), - )); + Err(e) => { + results.push((*root, Err(e))); } } } else { diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs index 0db6d57ed6..be3dbf33ce 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::beacon_chain::ForkChoiceError; use crate::payload_envelope_streamer::beacon_chain_adapter::MockEnvelopeStreamerBeaconAdapter; use crate::test_utils::EphemeralHarnessType; use bls::{FixedBytesExtended, Signature}; @@ -279,15 +280,18 @@ async fn stream_envelopes_by_root() { } /// When `block_has_canonical_payload` returns an error, the streamer should -/// yield `Err(EnvelopeStreamerError(BlockMissingFromForkChoice))` for those roots. +/// propagate that error for those roots. #[tokio::test] async fn stream_envelopes_error() { let chain = build_chain(4, &[], &[], &[]); let (mut mock, _runtime) = mock_adapter(); mock.expect_get_split_slot().return_const(Slot::new(0)); mock_envelopes(&mut mock, &chain); - mock.expect_block_has_canonical_payload() - .returning(|_| Err(BeaconChainError::CanonicalHeadLockTimeout)); + mock.expect_block_has_canonical_payload().returning(|_| { + Err(BeaconChainError::ForkChoiceError( + ForkChoiceError::DoesNotDescendFromFinalizedCheckpoint, + )) + }); let streamer = PayloadEnvelopeStreamer::new(mock, EnvelopeRequestSource::ByRange); let mut stream = streamer.launch_stream(roots(&chain)); @@ -299,13 +303,8 @@ async fn stream_envelopes_error() { .unwrap_or_else(|| panic!("stream ended early at index {i}")); assert_eq!(root, entry.block_root, "root mismatch at index {i}"); assert!( - matches!( - result.as_ref(), - Err(BeaconChainError::EnvelopeStreamerError( - Error::BlockMissingFromForkChoice - )) - ), - "expected BlockMissingFromForkChoice error at index {i}, got {:?}", + result.as_ref().is_err(), + "expected error at index {i}, got {:?}", result ); } diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 21415e478a..f9d779fd24 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -78,6 +78,7 @@ pub enum Error { UnrealizedVoteProcessing(state_processing::EpochProcessingError), ValidatorStatuses(BeaconStateError), ChainSpecError(String), + DoesNotDescendFromFinalizedCheckpoint, } impl From for Error { @@ -1523,6 +1524,29 @@ where } } + /// Returns the canonical payload status of a block. See + /// `ProtoArrayForkChoice::get_canonical_payload_status`. + pub fn get_canonical_payload_status( + &self, + block_root: &Hash256, + spec: &ChainSpec, + ) -> Result> { + if self.is_finalized_checkpoint_or_descendant(*block_root) { + let current_slot = self.fc_store.get_current_slot(); + let proposer_boost_root = self.fc_store.proposer_boost_root(); + self.proto_array + .get_canonical_payload_status::( + block_root, + current_slot, + proposer_boost_root, + spec, + ) + .map_err(Error::ProtoArrayError) + } else { + Err(Error::DoesNotDescendFromFinalizedCheckpoint) + } + } + /// Returns the weight for the given block root. pub fn get_block_weight(&self, block_root: &Hash256) -> Option { self.proto_array.get_weight(block_root) diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index c9764d3e44..d537f16bb2 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -4,6 +4,7 @@ mod gloas_payload; mod no_votes; mod votes; +use crate::error::Error; use crate::proto_array_fork_choice::{Block, ExecutionStatus, PayloadStatus, ProtoArrayForkChoice}; use crate::{InvalidationOperation, JustifiedBalances}; use fixed_bytes::FixedBytesExtended; @@ -30,6 +31,8 @@ pub enum Operation { justified_state_balances: Vec, expected_head: Hash256, current_slot: Slot, + // TODO(gloas): Make this non-optional. `find_head` always returns a `PayloadStatus` + // (Empty for pre-GLOAS), so every test should assert on it explicitly. #[serde(default)] expected_payload_status: Option, }, @@ -61,6 +64,12 @@ pub enum Operation { block_root: Hash256, attestation_slot: Slot, }, + ProcessGloasAttestation { + validator_index: usize, + block_root: Hash256, + attestation_slot: Slot, + payload_present: bool, + }, ProcessPayloadAttestation { validator_index: usize, block_root: Hash256, @@ -105,6 +114,16 @@ pub enum Operation { block_root: Hash256, expected: bool, }, + AssertPayloadStatusByWeight { + block_root: Hash256, + expected_status: PayloadStatus, + /// Override `current_slot`. Defaults to the `current_slot` of the last `FindHead`. + #[serde(default)] + current_slot: Option, + /// Override the proposer boost root. Defaults to `Hash256::zero()`. + #[serde(default)] + proposer_boost_root: Option, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -149,6 +168,7 @@ impl ForkChoiceTestDefinition { ) .expect("should create fork choice struct"); let equivocating_indices = BTreeSet::new(); + let mut last_current_slot = Slot::new(0); for (op_index, op) in self.operations.into_iter().enumerate() { match op.clone() { @@ -189,6 +209,16 @@ impl ForkChoiceTestDefinition { op_index, op ); } + assert_canonical_payload_status_matches_find_head( + &fork_choice, + &head, + current_slot, + Hash256::zero(), + &spec, + payload_status, + op_index, + ); + last_current_slot = current_slot; check_bytes_round_trip(&fork_choice); } Operation::ProposerBoostFindHead { @@ -201,7 +231,7 @@ impl ForkChoiceTestDefinition { let justified_balances = JustifiedBalances::from_effective_balances(justified_state_balances) .unwrap(); - let (head, _payload_status) = fork_choice + let (head, payload_status) = fork_choice .find_head::( justified_checkpoint, finalized_checkpoint, @@ -220,6 +250,15 @@ impl ForkChoiceTestDefinition { "Operation at index {} failed head check. Operation: {:?}", op_index, op ); + assert_canonical_payload_status_matches_find_head( + &fork_choice, + &head, + Slot::new(0), + proposer_boost_root, + &spec, + payload_status, + op_index, + ); check_bytes_round_trip(&fork_choice); } Operation::InvalidFindHead { @@ -308,6 +347,27 @@ impl ForkChoiceTestDefinition { }); check_bytes_round_trip(&fork_choice); } + Operation::ProcessGloasAttestation { + validator_index, + block_root, + attestation_slot, + payload_present, + } => { + fork_choice + .process_attestation( + validator_index, + block_root, + attestation_slot, + payload_present, + ) + .unwrap_or_else(|_| { + panic!( + "process_attestation op at index {} returned error", + op_index + ) + }); + check_bytes_round_trip(&fork_choice); + } Operation::ProcessPayloadAttestation { validator_index, block_root, @@ -522,6 +582,26 @@ impl ForkChoiceTestDefinition { op_index ); } + Operation::AssertPayloadStatusByWeight { + block_root, + expected_status, + current_slot, + proposer_boost_root, + } => { + let actual = fork_choice + .get_canonical_payload_status::( + &block_root, + current_slot.unwrap_or(last_current_slot), + proposer_boost_root.unwrap_or_else(Hash256::zero), + &spec, + ) + .unwrap(); + assert_eq!( + actual, expected_status, + "canonical payload status mismatch at op index {}", + op_index + ); + } } } } @@ -546,6 +626,37 @@ fn get_checkpoint(i: u64) -> Checkpoint { } } +/// Checks that `get_canonical_payload_status` agrees with the `payload_status` +/// returned by `find_head` for the head block. +fn assert_canonical_payload_status_matches_find_head( + fork_choice: &ProtoArrayForkChoice, + head: &Hash256, + current_slot: Slot, + proposer_boost_root: Hash256, + spec: &ChainSpec, + expected: PayloadStatus, + op_index: usize, +) { + match fork_choice.get_canonical_payload_status::( + head, + current_slot, + proposer_boost_root, + spec, + ) { + Ok(actual) => assert_eq!( + actual, expected, + "get_canonical_payload_status disagreed with find_head for head {:?} at op index {}", + head, op_index + ), + // Skip the check for pre-gloas nodes + Err(Error::InvalidNodeVariant { .. }) => {} + Err(e) => panic!( + "get_canonical_payload_status failed at op index {}: {:?}", + op_index, e + ), + } +} + fn check_bytes_round_trip(original: &ProtoArrayForkChoice) { let bytes = original.as_bytes(); let decoded = ProtoArrayForkChoice::from_bytes(&bytes, original.balances.clone()) 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 197e1102a3..ac4f8992c4 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 @@ -81,20 +81,88 @@ pub fn get_gloas_chain_following_test_definition() -> ForkChoiceTestDefinition { expected_payload_status: None, }); - ops.push(Operation::SetPayloadTiebreak { - block_root: get_root(0), - is_timely: false, - is_data_available: false, + // Cross-slot attestation with payload_present=true to Full branch (root 3, slot 2). + // vote_slot=3 differs from block_slot=2 and payload_present=true, so it counts as Full weight. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 0, + block_root: get_root(3), + attestation_slot: Slot::new(3), + payload_present: true, }); ops.push(Operation::FindHead { justified_checkpoint: get_checkpoint(0), finalized_checkpoint: get_checkpoint(0), justified_state_balances: vec![1], + expected_head: get_root(3), + current_slot: Slot::new(0), + expected_payload_status: None, + }); + + // Full weight propagated up: root 0 and root 1 should show Full. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); + // Root 2 has no payload received, so it's always Empty. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); + + // Cross-slot attestations with payload_present=false to Empty branch (root 4, slot 2). + // Two validators so Empty branch outweighs Full branch. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 1, + block_root: get_root(4), + attestation_slot: Slot::new(3), + payload_present: false, + }); + ops.push(Operation::ProcessGloasAttestation { + validator_index: 2, + block_root: get_root(4), + attestation_slot: Slot::new(3), + payload_present: false, + }); + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1, 1, 1], expected_head: get_root(4), current_slot: Slot::new(0), expected_payload_status: None, }); + // Empty weight now dominates, so root 0 flips to Empty. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(2), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); + // Root 1 (Full branch) still has 1 Full vote and 0 Empty, so it stays Full. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); + ForkChoiceTestDefinition { finalized_block_slot: Slot::new(0), justified_checkpoint: get_checkpoint(0), @@ -143,7 +211,7 @@ pub fn get_gloas_payload_probe_test_definition() -> ForkChoiceTestDefinition { justified_state_balances: vec![1, 1], expected_head: get_root(1), current_slot: Slot::new(0), - // With MainnetEthSpec PTC_SIZE=512, 1 bit set out of 256 threshold → not timely → Empty. + // With MainnetEthSpec PTC_SIZE=512 and a 256-bit threshold, 1 bit set is not timely, so Empty. expected_payload_status: Some(PayloadStatus::Empty), }); // PTC votes write to bitfields only, not to full/empty weight. @@ -286,7 +354,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe expected_payload_status: None, }); - // CL attestation to Empty branch (root 4) from validator 0 → head flips to 4. + // CL attestation to Empty branch (root 4) from validator 0 flips the head to 4. ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(4), @@ -301,7 +369,7 @@ pub fn get_gloas_find_head_vote_transition_test_definition() -> ForkChoiceTestDe expected_payload_status: None, }); - // CL attestation back to Full branch (root 3) → head returns to 3. + // CL attestation back to Full branch (root 3) returns the head to 3. ops.push(Operation::ProcessAttestation { validator_index: 0, block_root: get_root(3), @@ -546,7 +614,7 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef block_root: get_root(1), }); - // Step 4: Set tiebreaker to Empty on genesis → Empty branch wins. + // Step 4: Set tiebreaker to Empty on genesis so the Empty branch wins. ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), is_timely: false, @@ -560,8 +628,15 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef current_slot: Slot::new(1), expected_payload_status: None, }); + // Weights are tied (1 vote each branch), tiebreaker is Empty. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Empty, + current_slot: None, + proposer_boost_root: None, + }); - // Step 5: Flip tiebreaker to Full → Full branch wins. + // Step 5: Flip tiebreaker to Full so the Full branch wins. ops.push(Operation::SetPayloadTiebreak { block_root: get_root(0), is_timely: true, @@ -575,8 +650,15 @@ pub fn get_gloas_interleaved_attestations_test_definition() -> ForkChoiceTestDef current_slot: Slot::new(100), expected_payload_status: None, }); + // Weights still tied, tiebreaker flipped to Full. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(0), + expected_status: PayloadStatus::Full, + current_slot: None, + proposer_boost_root: None, + }); - // Step 6: Add extra CL weight to Empty branch → overrides Full tiebreaker. + // Step 6: Add extra CL weight to the Empty branch; this overrides the Full tiebreaker. ops.push(Operation::ProcessAttestation { validator_index: 2, block_root: get_root(4), @@ -732,6 +814,163 @@ pub fn get_gloas_payload_received_interleaving_test_definition() -> ForkChoiceTe } } +/// When `current_slot == node.slot + 1`, spec `get_weight` zeroes out Full and Empty +/// weights so the tiebreaker decides. Tests that the zero-out is applied and +/// doesn't just compare raw payload weights. +pub fn get_gloas_previous_slot_tiebreaker_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1 with its payload received. + // Genesis has zero block hash so all its children are Empty (genesis never has + // payload_received). Block 1's parent_hash doesn't match zero → Empty child. + 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), + }); + + // Block 2 at slot 2 with a mismatched EL parent hash, giving it an Empty parent payload status. + 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(99)), + execution_payload_block_hash: Some(get_hash(2)), + }); + + // More Full weight than Empty on block 1. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: true, + }); + + // Materialize the attestation into `full_payload_weight`. + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![1], + expected_head: get_root(1), + current_slot: Slot::new(1), + expected_payload_status: Some(PayloadStatus::Full), + }); + + // Before zero-out (current_slot == block 1's slot), raw weights decide payload status (Full) + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: Some(Slot::new(1)), + proposer_boost_root: None, + }); + + // At current_slot == block 1's slot + 1, both weights zero out and the + // tiebreaker picks Empty (block 2 extends block 1 with an Empty parent + // payload status). + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Empty, + current_slot: Some(Slot::new(2)), + proposer_boost_root: Some(get_root(2)), + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::zero()), + spec: Some(gloas_spec()), + } +} + +/// Proposer boost on a descendant can flip an ancestor's canonical payload status. +/// Boost supports the ancestor's Full variant (via the descendant's Full parent +/// payload status) but not Empty, so a large enough boost overrides raw Empty weight. +pub fn get_gloas_proposer_boost_flips_ancestor_test_definition() -> ForkChoiceTestDefinition { + let mut ops = vec![]; + + // Block 1 at slot 1 with payload received. + 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), + }); + + // Block 2 at slot 3 with a Full parent payload status (skip slot 2 so + // block 1's previous-slot zero-out doesn't fire at current_slot 3). + ops.push(Operation::ProcessBlock { + slot: Slot::new(3), + 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)), + }); + + // One Empty vote on block 1. Balance totals are chosen so the proposer + // boost score exceeds the single Empty voter's balance. + ops.push(Operation::ProcessGloasAttestation { + validator_index: 0, + block_root: get_root(1), + attestation_slot: Slot::new(2), + payload_present: false, + }); + + ops.push(Operation::FindHead { + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + justified_state_balances: vec![100, 10000], + expected_head: get_root(1), + current_slot: Slot::new(3), + expected_payload_status: Some(PayloadStatus::Empty), + }); + + // Without boost the raw weights decide and Empty wins. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Empty, + current_slot: Some(Slot::new(3)), + proposer_boost_root: None, + }); + + // With boost on block 2 the boost supports block 1's Full variant, so Full wins. + ops.push(Operation::AssertPayloadStatusByWeight { + block_root: get_root(1), + expected_status: PayloadStatus::Full, + current_slot: Some(Slot::new(3)), + proposer_boost_root: Some(get_root(2)), + }); + + ForkChoiceTestDefinition { + finalized_block_slot: Slot::new(0), + justified_checkpoint: get_checkpoint(0), + finalized_checkpoint: get_checkpoint(0), + operations: ops, + execution_payload_parent_hash: Some(ExecutionBlockHash::zero()), + execution_payload_block_hash: Some(ExecutionBlockHash::zero()), + spec: Some(gloas_spec()), + } +} + #[cfg(test)] mod tests { use super::*; @@ -758,7 +997,7 @@ mod tests { let mut ops = vec![]; // Block at slot 31 — last pre-Gloas slot. Created as a V17 node because - // gloas_fork_epoch = 1 → Gloas starts at slot 32. + // gloas_fork_epoch = 1 means Gloas starts at slot 32. // // The test harness sets execution_status = Optimistic(ExecutionBlockHash::from_root(root)), // so this V17 node's EL block hash = ExecutionBlockHash::from_root(get_root(1)). @@ -909,6 +1148,18 @@ mod tests { test.run(); } + #[test] + fn previous_slot_tiebreaker() { + let test = get_gloas_previous_slot_tiebreaker_test_definition(); + test.run(); + } + + #[test] + fn proposer_boost_flips_ancestor() { + let test = get_gloas_proposer_boost_flips_ancestor_test_definition(); + test.run(); + } + /// Test that execution payload invalidation propagates across the V17→V29 fork /// boundary: after invalidating a V17 parent, head must not select any descendant. /// diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 4ca7dab69c..8548974054 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -1262,6 +1262,90 @@ impl ProtoArray { } } + /// 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( + &self, + root: Hash256, + current_slot: Slot, + proposer_boost_root: Hash256, + justified_balances: &JustifiedBalances, + spec: &ChainSpec, + ) -> Result { + let proto_node_index = *self.indices.get(&root).ok_or(Error::NodeUnknown(root))?; + let proto_node = self + .nodes + .get(proto_node_index) + .ok_or(Error::InvalidNodeIndex(proto_node_index))?; + + if !proto_node + .payload_received() + .map_err(|_| Error::InvalidNodeVariant { block_root: root })? + { + return Ok(PayloadStatus::Empty); + } + + let full_fc = IndexedForkChoiceNode { + root, + proto_node_index, + payload_status: PayloadStatus::Full, + }; + let empty_fc = IndexedForkChoiceNode { + root, + proto_node_index, + payload_status: PayloadStatus::Empty, + }; + + // Matches the hoisting optimization in `find_head`: `get_weight`'s spec-level + // `should_apply_proposer_boost` check is precomputed once. + let apply_proposer_boost = + self.should_apply_proposer_boost::(proposer_boost_root, justified_balances, spec)?; + + let full_weight = self.get_weight::( + &full_fc, + proto_node, + apply_proposer_boost, + proposer_boost_root, + current_slot, + justified_balances, + spec, + )?; + + let empty_weight = self.get_weight::( + &empty_fc, + proto_node, + apply_proposer_boost, + proposer_boost_root, + current_slot, + justified_balances, + spec, + )?; + + match full_weight.cmp(&empty_weight) { + std::cmp::Ordering::Greater => Ok(PayloadStatus::Full), + std::cmp::Ordering::Less => Ok(PayloadStatus::Empty), + std::cmp::Ordering::Equal => { + let full_tb = self.get_payload_status_tiebreaker::( + &full_fc, + proto_node, + current_slot, + proposer_boost_root, + )?; + let empty_tb = self.get_payload_status_tiebreaker::( + &empty_fc, + proto_node, + current_slot, + proposer_boost_root, + )?; + if full_tb >= empty_tb { + Ok(PayloadStatus::Full) + } else { + Ok(PayloadStatus::Empty) + } + } + } + } + /// Spec: `get_weight`. #[allow(clippy::too_many_arguments)] fn get_weight( @@ -1417,7 +1501,7 @@ impl ProtoArray { } } - fn get_payload_status_tiebreaker( + pub(crate) fn get_payload_status_tiebreaker( &self, fc_node: &IndexedForkChoiceNode, proto_node: &ProtoNode, diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 577e89baa1..1c6d3f3201 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -1053,6 +1053,24 @@ impl ProtoArrayForkChoice { .unwrap_or(false) } + /// 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( + &self, + block_root: &Hash256, + current_slot: Slot, + proposer_boost_root: Hash256, + spec: &ChainSpec, + ) -> Result { + self.proto_array.get_canonical_payload_status::( + *block_root, + current_slot, + proposer_boost_root, + &self.balances, + spec, + ) + } + /// Returns the weight of a given block. pub fn get_weight(&self, block_root: &Hash256) -> Option { let block_index = self.proto_array.indices.get(block_root)?; From e086628efe572aee7a91016a304fb443266857d3 Mon Sep 17 00:00:00 2001 From: Pawan Dhananjay Date: Thu, 23 Apr 2026 18:20:15 +0530 Subject: [PATCH 134/189] Avoid lint and fmt for agents (#9166) N/A Do not make the AI agent always fmt and lint. This takes way too long and the agents I work with take this too literally sometimes and run lint after incomplete changes just wasting time. I feel its not a big ask to run fmt and lint yourself and/or run it in some local configs instead of global ones. Co-Authored-By: Pawan Dhananjay --- CLAUDE.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 79ed344e35..34a895f464 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,7 @@ This file provides guidance for AI assistants (Claude Code, Codex, etc.) working ## CRITICAL - Always Follow After completing ANY code changes: -1. **MUST** run `cargo fmt --all && make lint-fix` to format and fix linting issues -2. **MUST** run `cargo check` to verify compilation before considering task complete +1. **MUST** run `cargo check` to verify compilation before considering task complete Run `make install-hooks` if you have not already to install git hooks. Never skip git hooks. If cargo is not available install the toolchain. From 8a384ff4454bfb1061b1c4fd51cb947b26fa6803 Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:52:28 +0200 Subject: [PATCH 135/189] Cell Dissemination (Partial messages) (#8314) - https://github.com/ethereum/consensus-specs/pull/4558 - https://eips.ethereum.org/EIPS/eip-8136 Co-Authored-By: Daniel Knopik Co-Authored-By: Pawan Dhananjay Co-Authored-By: Jimmy Chen --- Cargo.lock | 316 ++--- Cargo.toml | 3 - beacon_node/beacon_chain/src/beacon_chain.rs | 160 ++- beacon_node/beacon_chain/src/builder.rs | 2 + beacon_node/beacon_chain/src/chain_config.rs | 3 + .../src/data_availability_checker.rs | 176 ++- .../src/data_column_verification.rs | 1062 +++++++++++++++-- .../fetch_blobs/fetch_blobs_beacon_adapter.rs | 40 +- .../beacon_chain/src/fetch_blobs/mod.rs | 266 +++-- .../beacon_chain/src/fetch_blobs/tests.rs | 69 +- beacon_node/beacon_chain/src/kzg_utils.rs | 215 +++- beacon_node/beacon_chain/src/lib.rs | 1 + beacon_node/beacon_chain/src/metrics.rs | 114 ++ .../src/observed_data_sidecars.rs | 12 +- .../src/partial_data_column_assembler.rs | 569 +++++++++ beacon_node/beacon_chain/src/test_utils.rs | 1 + beacon_node/beacon_processor/src/lib.rs | 14 + .../src/scheduler/work_queue.rs | 6 + beacon_node/execution_layer/src/engine_api.rs | 1 + .../execution_layer/src/engine_api/http.rs | 16 + .../src/engine_api/json_structures.rs | 3 + beacon_node/execution_layer/src/lib.rs | 19 +- .../execution_layer/src/test_utils/mod.rs | 1 + beacon_node/http_api/src/publish_blocks.rs | 62 +- beacon_node/lighthouse_network/Cargo.toml | 2 + beacon_node/lighthouse_network/src/config.rs | 4 + beacon_node/lighthouse_network/src/lib.rs | 2 +- beacon_node/lighthouse_network/src/metrics.rs | 8 + .../lighthouse_network/src/service/mod.rs | 183 ++- .../service/partial_column_header_tracker.rs | 28 + .../lighthouse_network/src/types/mod.rs | 5 +- .../lighthouse_network/src/types/partial.rs | 503 ++++++++ .../lighthouse_network/src/types/pubsub.rs | 51 +- .../lighthouse_network/src/types/topics.rs | 11 +- beacon_node/network/src/metrics.rs | 48 + .../gossip_methods.rs | 572 ++++++++- .../src/network_beacon_processor/mod.rs | 51 +- .../network_beacon_processor/sync_methods.rs | 10 +- beacon_node/network/src/router.rs | 18 +- beacon_node/network/src/service.rs | 42 +- .../network/src/sync/block_lookups/mod.rs | 18 +- .../sync/block_lookups/single_block_lookup.rs | 4 +- beacon_node/network/src/sync/manager.rs | 32 +- beacon_node/src/cli.rs | 9 + beacon_node/src/config.rs | 15 + book/src/help_bn.md | 3 + .../types/src/block/beacon_block_body.rs | 50 +- consensus/types/src/data/blob_sidecar.rs | 30 +- .../types/src/data/data_column_sidecar.rs | 77 ++ consensus/types/src/data/mod.rs | 5 + .../src/data/partial_data_column_sidecar.rs | 429 +++++++ consensus/types/src/kzg_ext/mod.rs | 52 +- .../generate_random_block_and_blobs.rs | 16 +- lighthouse/tests/beacon_node.rs | 18 + 54 files changed, 4797 insertions(+), 630 deletions(-) create mode 100644 beacon_node/beacon_chain/src/partial_data_column_assembler.rs create mode 100644 beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs create mode 100644 beacon_node/lighthouse_network/src/types/partial.rs create mode 100644 consensus/types/src/data/partial_data_column_sidecar.rs diff --git a/Cargo.lock b/Cargo.lock index b136e7da98..aefd51a950 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -695,7 +695,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -706,7 +706,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1397,7 +1397,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.12.1", "log", "prettyplease", "proc-macro2", @@ -3109,7 +3109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3646,12 +3646,12 @@ dependencies = [ [[package]] name = "futures-bounded" -version = "0.2.4" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91f328e7fb845fc832912fb6a34f40cf6d1888c92f974d1893a54e97b5ff542e" +checksum = "b604752cefc5aa3ab98992a107a8bd99465d2825c1584e0b60cb6957b21e19d7" dependencies = [ - "futures-timer", "futures-util", + "tokio", ] [[package]] @@ -3737,6 +3737,10 @@ name = "futures-timer" version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +dependencies = [ + "gloo-timers", + "send_wrapper", +] [[package]] name = "futures-util" @@ -3832,6 +3836,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "graffiti_file" version = "0.1.0" @@ -4364,7 +4380,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -4382,7 +4398,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -4502,16 +4518,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "if-addrs" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cabb0019d51a643781ff15c9c8a3e5dedc365c47211270f4e8f82812fedd8f0a" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "if-addrs" version = "0.14.0" @@ -4523,16 +4529,26 @@ dependencies = [ ] [[package]] -name = "if-watch" -version = "3.2.1" +name = "if-addrs" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdf9d64cfcf380606e64f9a0bcf493616b65331199f984151a6fa11a7b3cde38" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "if-watch" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2" dependencies = [ "async-io", "core-foundation 0.9.4", "fnv", "futures", - "if-addrs 0.10.2", + "if-addrs 0.15.0", "ipnet", "log", "netlink-packet-core", @@ -4919,9 +4935,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libloading" @@ -4956,8 +4972,8 @@ dependencies = [ [[package]] name = "libp2p" -version = "0.56.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.57.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "bytes", "either", @@ -4987,8 +5003,8 @@ dependencies = [ [[package]] name = "libp2p-allow-block-list" -version = "0.6.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.7.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "libp2p-core", "libp2p-identity", @@ -4997,8 +5013,8 @@ dependencies = [ [[package]] name = "libp2p-connection-limits" -version = "0.6.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.7.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5007,8 +5023,8 @@ dependencies = [ [[package]] name = "libp2p-core" -version = "0.43.2" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.44.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "either", "fnv", @@ -5032,7 +5048,7 @@ dependencies = [ [[package]] name = "libp2p-dns" version = "0.45.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "hickory-resolver", @@ -5046,7 +5062,7 @@ dependencies = [ [[package]] name = "libp2p-gossipsub" version = "0.50.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "async-channel 2.5.0", "asynchronous-codec", @@ -5075,8 +5091,8 @@ dependencies = [ [[package]] name = "libp2p-identify" -version = "0.47.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.48.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "asynchronous-codec", "either", @@ -5115,8 +5131,8 @@ dependencies = [ [[package]] name = "libp2p-mdns" -version = "0.48.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.49.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "hickory-proto", @@ -5126,15 +5142,15 @@ dependencies = [ "libp2p-swarm", "rand 0.8.5", "smallvec", - "socket2 0.6.1", + "socket2 0.6.3", "tokio", "tracing", ] [[package]] name = "libp2p-metrics" -version = "0.17.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.18.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "libp2p-core", @@ -5149,8 +5165,8 @@ dependencies = [ [[package]] name = "libp2p-mplex" -version = "0.43.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.44.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "asynchronous-codec", "bytes", @@ -5167,8 +5183,8 @@ dependencies = [ [[package]] name = "libp2p-noise" -version = "0.46.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.47.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "asynchronous-codec", "bytes", @@ -5189,8 +5205,8 @@ dependencies = [ [[package]] name = "libp2p-quic" -version = "0.13.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.14.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "futures-timer", @@ -5202,7 +5218,7 @@ dependencies = [ "rand 0.8.5", "ring", "rustls 0.23.35", - "socket2 0.6.1", + "socket2 0.6.3", "thiserror 2.0.17", "tokio", "tracing", @@ -5210,13 +5226,14 @@ dependencies = [ [[package]] name = "libp2p-swarm" -version = "0.47.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.48.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "either", "fnv", "futures", "futures-timer", + "getrandom 0.2.16", "hashlink 0.11.0", "libp2p-core", "libp2p-identity", @@ -5226,13 +5243,14 @@ dependencies = [ "smallvec", "tokio", "tracing", + "wasm-bindgen-futures", "web-time", ] [[package]] name = "libp2p-swarm-derive" -version = "0.35.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.36.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "heck", "quote", @@ -5241,23 +5259,23 @@ dependencies = [ [[package]] name = "libp2p-tcp" -version = "0.44.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.45.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "futures-timer", "if-watch", "libc", "libp2p-core", - "socket2 0.6.1", + "socket2 0.6.3", "tokio", "tracing", ] [[package]] name = "libp2p-tls" -version = "0.6.2" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.7.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "futures-rustls", @@ -5274,8 +5292,8 @@ dependencies = [ [[package]] name = "libp2p-upnp" -version = "0.6.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.7.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "futures-timer", @@ -5288,8 +5306,8 @@ dependencies = [ [[package]] name = "libp2p-yamux" -version = "0.47.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.48.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "either", "futures", @@ -5422,6 +5440,7 @@ dependencies = [ "if-addrs 0.14.0", "itertools 0.14.0", "libp2p", + "libp2p-gossipsub", "libp2p-mplex", "lighthouse_version", "logging", @@ -5968,8 +5987,8 @@ dependencies = [ [[package]] name = "multistream-select" -version = "0.13.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.14.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "bytes", "futures", @@ -5981,46 +6000,30 @@ dependencies = [ [[package]] name = "netlink-packet-core" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" dependencies = [ - "anyhow", - "byteorder", - "netlink-packet-utils", + "paste", ] [[package]] name = "netlink-packet-route" -version = "0.17.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053998cea5a306971f88580d0829e90f270f940befd7cf928da179d4187a5a66" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" dependencies = [ - "anyhow", - "bitflags 1.3.2", - "byteorder", + "bitflags 2.10.0", "libc", + "log", "netlink-packet-core", - "netlink-packet-utils", -] - -[[package]] -name = "netlink-packet-utils" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" -dependencies = [ - "anyhow", - "byteorder", - "paste", - "thiserror 1.0.69", ] [[package]] name = "netlink-proto" -version = "0.11.5" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72452e012c2f8d612410d89eea01e2d9b56205274abb35d53f60200b2ec41d60" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" dependencies = [ "bytes", "futures", @@ -6032,12 +6035,12 @@ dependencies = [ [[package]] name = "netlink-sys" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" dependencies = [ "bytes", - "futures", + "futures-util", "libc", "log", "tokio", @@ -6123,17 +6126,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nix" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", -] - [[package]] name = "nix" version = "0.30.1" @@ -6195,7 +6187,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6623,18 +6615,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", @@ -7000,7 +6992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.117", @@ -7066,8 +7058,8 @@ dependencies = [ [[package]] name = "quick-protobuf-codec" -version = "0.3.1" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.4.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "asynchronous-codec", "bytes", @@ -7090,7 +7082,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.35", - "socket2 0.6.1", + "socket2 0.6.3", "thiserror 2.0.17", "tokio", "tracing", @@ -7127,7 +7119,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -7513,18 +7505,18 @@ dependencies = [ [[package]] name = "rtnetlink" -version = "0.13.1" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a552eb82d19f38c3beed3f786bd23aa434ceb9ac43ab44419ca6d67a7e186c0" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" dependencies = [ - "futures", + "futures-channel", + "futures-util", "log", "netlink-packet-core", "netlink-packet-route", - "netlink-packet-utils", "netlink-proto", "netlink-sys", - "nix 0.26.4", + "nix 0.30.1", "thiserror 1.0.69", "tokio", ] @@ -7651,7 +7643,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7756,8 +7748,8 @@ dependencies = [ [[package]] name = "rw-stream-sink" -version = "0.4.0" -source = "git+https://github.com/sigp/rust-libp2p.git?rev=defcaf1a78cf5b70a723b3fee0e0be051c1dbd88#defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" +version = "0.5.0" +source = "git+https://github.com/libp2p/rust-libp2p.git#f4cf4bf79b710c7502969eeab8343191ec63c956" dependencies = [ "futures", "pin-project", @@ -7946,6 +7938,12 @@ dependencies = [ "pest", ] +[[package]] +name = "send_wrapper" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f638d531eccd6e23b980caf34876660d38e265409d8e99b397ab71eb3612fad0" + [[package]] name = "sensitive_url" version = "0.1.0" @@ -8346,9 +8344,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", "windows-sys 0.60.2", @@ -8384,9 +8382,9 @@ dependencies = [ [[package]] name = "ssz_types" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc20a89bab2dabeee65e9c9eb96892dc222c23254b401e1319b85efd852fa31" +checksum = "d625e4de8e0057eefe7e0b1510ba1dd7adf10cd375fad6cc7fcceac7c39623c9" dependencies = [ "arbitrary", "context_deserialize", @@ -8622,9 +8620,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", @@ -8696,7 +8694,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8927,7 +8925,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.3", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -9151,9 +9149,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -9186,9 +9184,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -10015,7 +10013,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10026,12 +10024,14 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.53.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efc5cf48f83140dcaab716eeaea345f9e93d0018fb81162753a3f76c3397b538" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-core 0.53.0", - "windows-targets 0.52.6", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -10047,13 +10047,12 @@ dependencies = [ ] [[package]] -name = "windows-core" -version = "0.53.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcc5b895a6377f1ab9fa55acedab1fd5ac0db66ad1e6c7f47e28a22e446a5dd" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -10065,10 +10064,21 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link", - "windows-result 0.4.1", + "windows-result", "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -10098,12 +10108,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-result" -version = "0.1.2" +name = "windows-numerics" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-targets 0.52.6", + "windows-core", + "windows-link", ] [[package]] @@ -10217,6 +10228,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index db6853d44d..1f58c322f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -276,6 +276,3 @@ debug = true [patch.crates-io] quick-protobuf = { git = "https://github.com/sigp/quick-protobuf.git", rev = "87c4ccb9bb2af494de375f5f6c62850badd26304" } -[patch."https://github.com/libp2p/rust-libp2p.git"] -libp2p = { git = "https://github.com/sigp/rust-libp2p.git", rev = "defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" } -libp2p-mplex = { git = "https://github.com/sigp/rust-libp2p.git", rev = "defcaf1a78cf5b70a723b3fee0e0be051c1dbd88" } diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index e14c7c047f..f3861ac727 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -22,7 +22,12 @@ use crate::data_availability_checker::{ Availability, AvailabilityCheckError, AvailableBlock, AvailableBlockData, DataAvailabilityChecker, DataColumnReconstructionResult, }; -use crate::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; +use crate::data_column_verification::{ + GossipDataColumnError, GossipPartialDataColumnError, GossipVerifiedDataColumn, + GossipVerifiedPartialDataColumnHeader, KzgVerifiedCustodyPartialDataColumn, + KzgVerifiedPartialDataColumn, PartialColumnVerificationResult, + validate_partial_data_column_sidecar_for_gossip, +}; use crate::early_attester_cache::EarlyAttesterCache; use crate::envelope_times_cache::EnvelopeTimesCache; use crate::errors::{BeaconChainError as Error, BlockProductionError}; @@ -54,6 +59,7 @@ use crate::observed_block_producers::ObservedBlockProducers; use crate::observed_data_sidecars::ObservedDataSidecars; use crate::observed_operations::{ObservationOutcome, ObservedOperations}; use crate::observed_slashable::ObservedSlashable; +use crate::partial_data_column_assembler::PartialMergeResult; use crate::payload_bid_verification::payload_bid_cache::GossipVerifiedPayloadBidCache; #[cfg(not(test))] use crate::payload_envelope_streamer::{EnvelopeRequestSource, launch_payload_envelope_stream}; @@ -552,6 +558,9 @@ impl FinalizationAndCanonicity { } } +type ProcessedPartialColumnStatus = + Option<(AvailabilityProcessingStatus, PartialMergeResult)>; + impl BeaconChain { /// Checks if a block is finalized. /// The finalization check is done with the block slot. The block root is used to verify that @@ -2297,6 +2306,59 @@ impl BeaconChain { }) } + pub fn verify_partial_data_column_header_for_gossip( + &self, + block_root: Hash256, + data_column_header: PartialDataColumnHeader, + ) -> Result, GossipPartialDataColumnError> + { + metrics::inc_counter(&metrics::PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_REQUESTS); + let _timer = metrics::start_timer( + &metrics::PARTIAL_DATA_COLUMN_SIDECAR_HEADER_GOSSIP_VERIFICATION_TIMES, + ); + let Some(assembler) = self.data_availability_checker.partial_assembler() else { + return Err(GossipPartialDataColumnError::PartialColumnsDisabled); + }; + if let Some(cached_header) = assembler.get_header(&block_root) { + return if *cached_header == data_column_header { + metrics::inc_counter(&metrics::PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_DUPES); + Ok(GossipVerifiedPartialDataColumnHeader::new_from_cached( + cached_header, + )) + } else { + Err(GossipPartialDataColumnError::HeaderMismatches) + }; + } + + GossipVerifiedPartialDataColumnHeader::new(block_root, data_column_header, self).inspect( + |_| { + metrics::inc_counter( + &metrics::PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_SUCCESSES, + ); + }, + ) + } + + #[instrument(skip_all, level = "trace")] + pub fn verify_partial_data_column_sidecar_for_gossip( + self: &Arc, + data_column_sidecar: Box>, + seen_timestamp: Duration, + ) -> PartialColumnVerificationResult { + metrics::inc_counter(&metrics::PARTIAL_DATA_COLUMN_SIDECAR_PROCESSING_REQUESTS); + let _timer = + metrics::start_timer(&metrics::PARTIAL_DATA_COLUMN_SIDECAR_GOSSIP_VERIFICATION_TIMES); + let ret = validate_partial_data_column_sidecar_for_gossip( + data_column_sidecar, + self, + seen_timestamp, + ); + if matches!(ret, PartialColumnVerificationResult::Ok { .. }) { + metrics::inc_counter(&metrics::PARTIAL_DATA_COLUMN_SIDECAR_PROCESSING_SUCCESSES); + } + ret + } + #[instrument(skip_all, level = "trace")] pub fn verify_blob_sidecar_for_gossip( self: &Arc, @@ -3128,6 +3190,7 @@ impl BeaconChain { /// Cache the data columns in the processing cache, process it, then evict it from the cache if it was /// imported or errors. + /// Only accepts full columns. Partials are handled via PartialDataColumnAssembler. #[instrument(skip_all, level = "debug")] pub async fn process_gossip_data_columns( self: &Arc, @@ -3169,6 +3232,93 @@ impl BeaconChain { .await } + /// Process a gossip-verified partial data column by attempting to merge it in the assembler. + /// Returns the merge result which indicates if a column was completed. + #[instrument(skip_all, level = "debug")] + pub async fn process_gossip_partial_data_column( + self: &Arc, + verified_partial: KzgVerifiedPartialDataColumn, + verified_header: GossipVerifiedPartialDataColumnHeader, + slot: Slot, + ) -> Result, BlockError> { + let block_root = verified_partial.block_root(); + let partial = verified_partial.as_data_column(); + let index_str = partial.index.to_string(); + metrics::inc_counter_vec_by( + &metrics::BEACON_PARTIAL_MESSAGE_CELLS_RECEIVED_TOTAL, + &[index_str.as_str()], + partial.sidecar.column.len() as u64, + ); + + // Check if we have custody of this column + let sampling_columns = + self.sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch())); + let verified_partial = if sampling_columns.contains(&partial.index) { + KzgVerifiedCustodyPartialDataColumn::from_asserted_custody(verified_partial) + } else { + return Ok(None); + }; + + // If this block has already been imported to forkchoice it must have been available + if self + .canonical_head + .fork_choice_read_lock() + .contains_block(&block_root) + { + return Err(BlockError::DuplicateFullyImported(block_root)); + } + + let Some(assembler) = self.data_availability_checker.partial_assembler() else { + // Partial messages are apparently not activated + return Ok(None); + }; + + // Merge the partial into the assembler + let merge_result = assembler + .merge_partials( + block_root, + vec![verified_partial], + verified_header.into_header(), + ) + .ok_or_else(|| BlockError::InternalError("No assembly found for block".to_string()))?; + + metrics::inc_counter_vec_by( + &metrics::BEACON_PARTIAL_MESSAGE_USEFUL_CELLS_TOTAL, + &[index_str.as_str()], + merge_result.added_cells as u64, + ); + + let availability = if !merge_result.full_columns.is_empty() { + metrics::inc_counter_vec_by( + &metrics::BEACON_PARTIAL_MESSAGE_COLUMN_COMPLETIONS_TOTAL, + &[index_str.as_str()], + merge_result.full_columns.len() as u64, + ); + + self.emit_sse_data_column_sidecar_events( + &block_root, + merge_result + .full_columns + .iter() + .map(|column| column.as_data_column()), + ); + + let availability = self + .data_availability_checker + .put_kzg_verified_custody_data_columns( + block_root, + merge_result.full_columns.clone(), + )?; + + self.process_availability(slot, availability, || Ok(())) + .await? + } else { + AvailabilityProcessingStatus::MissingComponents(slot, block_root) + }; + + Ok(Some((availability, merge_result))) + } + /// Cache the blobs in the processing cache, process it, then evict it from the cache if it was /// imported or errors. #[instrument(skip_all, level = "debug")] @@ -3624,6 +3774,8 @@ impl BeaconChain { /// Checks if the provided data column can make any cached blocks available, and imports immediately /// if so, otherwise caches the data column in the data availability checker. + /// Check gossip data columns for availability and import. Only accepts full columns. + /// Partials are handled separately via PartialDataColumnAssembler. async fn check_gossip_data_columns_availability_and_import( self: &Arc, slot: Slot, @@ -3774,13 +3926,13 @@ impl BeaconChain { // from RPC. for header in custody_columns .into_iter() - .map(|c| c.signed_block_header.clone()) + .map(|c| &c.signed_block_header) .unique() { // Return an error if *any* header signature is invalid, we do not want to import this // list of blobs into the DA checker. However, we will process any valid headers prior // to the first invalid header in the slashable cache & slasher. - verify_header_signature::(self, &header)?; + verify_header_signature::(self, header)?; slashable_cache .observe_slashable( @@ -3790,7 +3942,7 @@ impl BeaconChain { ) .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))?; if let Some(slasher) = self.slasher.as_ref() { - slasher.accept_block_header(header); + slasher.accept_block_header(header.clone()); } } Ok(()) diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 74141dc64a..19eb1aa877 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -930,6 +930,7 @@ where CanonicalHead::new(fork_choice, Arc::new(head_snapshot), head_payload_status); let shuffling_cache_size = self.chain_config.shuffling_cache_size; let complete_blob_backfill = self.chain_config.complete_blob_backfill; + let enable_partial_columns = self.chain_config.enable_partial_columns; // Calculate the weak subjectivity point in which to backfill blocks to. let genesis_backfill_slot = if self.chain_config.genesis_backfill { @@ -1063,6 +1064,7 @@ where self.kzg.clone(), Arc::new(custody_context), self.spec, + enable_partial_columns, ) .map_err(|e| format!("Error initializing DataAvailabilityChecker: {:?}", e))?, ), diff --git a/beacon_node/beacon_chain/src/chain_config.rs b/beacon_node/beacon_chain/src/chain_config.rs index e9cc4f24e9..b2c017a469 100644 --- a/beacon_node/beacon_chain/src/chain_config.rs +++ b/beacon_node/beacon_chain/src/chain_config.rs @@ -121,6 +121,8 @@ pub struct ChainConfig { pub ignore_ws_check: bool, /// Disable the getBlobs optimisation to fetch blobs from the EL mempool. pub disable_get_blobs: bool, + /// Whether to enable partial data column support. + pub enable_partial_columns: bool, /// The node's custody type, determining how many data columns to custody and sample. pub node_custody_type: NodeCustodyType, } @@ -164,6 +166,7 @@ impl Default for ChainConfig { invalid_block_roots: HashSet::new(), ignore_ws_check: false, disable_get_blobs: false, + enable_partial_columns: false, node_custody_type: NodeCustodyType::Fullnode, } } diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index 4372efa809..9d8b76aaed 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -5,6 +5,7 @@ use crate::block_verification_types::{AvailabilityPendingExecutedBlock, Availabl use crate::data_availability_checker::overflow_lru_cache::{ DataAvailabilityCheckerInner, ReconstructColumnsDecision, }; +use crate::partial_data_column_assembler::{AssemblyColumn, PartialDataColumnAssembler}; use crate::{BeaconChain, BeaconChainTypes, BlockProcessStatus, CustodyContext, metrics}; use educe::Educe; use kzg::Kzg; @@ -17,10 +18,11 @@ use std::sync::Arc; use std::time::Duration; use task_executor::TaskExecutor; use tracing::{debug, error, instrument}; -use types::data::{BlobIdentifier, FixedBlobSidecarList}; +use types::data::{BlobIdentifier, FixedBlobSidecarList, PartialDataColumn}; use types::{ BlobSidecar, BlobSidecarList, BlockImportSource, ChainSpec, DataColumnSidecar, - DataColumnSidecarList, Epoch, EthSpec, Hash256, SignedBeaconBlock, Slot, + DataColumnSidecarList, Epoch, EthSpec, Hash256, PartialDataColumnSidecarError, + PartialDataColumnSidecarRef, SignedBeaconBlock, Slot, new_non_zero_usize, }; mod error; @@ -36,7 +38,6 @@ use crate::metrics::{ }; use crate::observed_data_sidecars::ObservationStrategy; pub use error::{Error as AvailabilityCheckError, ErrorCategory as AvailabilityCheckErrorCategory}; -use types::new_non_zero_usize; /// The LRU Cache stores `PendingComponents`, which store block and its associated blob data: /// @@ -78,6 +79,7 @@ const OVERFLOW_LRU_CAPACITY_NON_ZERO: NonZeroUsize = new_non_zero_usize(32); pub struct DataAvailabilityChecker { complete_blob_backfill: bool, availability_cache: Arc>, + partial_assembler: Option>>, slot_clock: T::SlotClock, kzg: Arc, custody_context: Arc>, @@ -120,14 +122,23 @@ impl DataAvailabilityChecker { kzg: Arc, custody_context: Arc>, spec: Arc, + enable_partial_columns: bool, ) -> Result { let inner = DataAvailabilityCheckerInner::new( OVERFLOW_LRU_CAPACITY_NON_ZERO, custody_context.clone(), spec.clone(), )?; + let partial_assembler = if enable_partial_columns { + Some(Arc::new(PartialDataColumnAssembler::new( + OVERFLOW_LRU_CAPACITY_NON_ZERO, + ))) + } else { + None + }; Ok(Self { complete_blob_backfill, + partial_assembler, availability_cache: Arc::new(inner), slot_clock, kzg, @@ -140,6 +151,10 @@ impl DataAvailabilityChecker { &self.custody_context } + pub fn partial_assembler(&self) -> Option<&Arc>> { + self.partial_assembler.as_ref() + } + /// Checks if the block root is currently in the availability cache awaiting import because /// of missing components. /// @@ -172,19 +187,104 @@ impl DataAvailabilityChecker { }) } - /// Check if the exact data column is in the availability cache. - pub fn is_data_column_cached( - &self, - block_root: &Hash256, - data_column: &DataColumnSidecar, - ) -> bool { - self.availability_cache - .peek_pending_components(block_root, |components| { - components.is_some_and(|components| { - let cached_column_opt = components.get_cached_data_column(*data_column.index()); - cached_column_opt.is_some_and(|cached| *cached == *data_column) + /// Filter out all cells that are already cached for the given `block_root`. + /// Returns None if all cells are already cached. + /// Returns an error if any cells or proofs mismatch the cached cells. + pub fn missing_cells_for_column_sidecar<'a>( + &'_ self, + data_column: &'a DataColumnSidecar, + ) -> Result>, MissingCellsError> { + let block_root = data_column.block_root(); + let column_index = *data_column.index(); + + // Check DA checker cache first - if we have a full column cached, nothing is missing. + // We return Some(true) from the peek if it exists and matches, Some(false) if it exists but + // does not match, and None if it doesn't exist. + if let Some(matches) = + self.availability_cache + .peek_pending_components(&block_root, |components| { + components + .and_then(|c| c.get_cached_data_column(column_index)) + .map(|cached| *cached == *data_column) }) + { + return if matches { + Ok(None) + } else { + Err(MissingCellsError::MismatchesCachedColumn) + }; + } + + // Check assembler for partial columns + if let Some(assembler) = &self.partial_assembler { + match assembler.get_partial(&block_root, column_index) { + Some(AssemblyColumn::Incomplete(cached_partial)) => { + return data_column.try_filter_to_partial_ref(|idx, cell, proof| { + match cached_partial.as_data_column().sidecar.get(idx) { + None => Ok(true), + Some((cached_cell, cached_proof)) => { + if cell == cached_cell && proof == cached_proof { + Ok(false) + } else { + Err(MissingCellsError::MismatchesCachedColumn) + } + } + } + }); + } + // This can happen if the column has been marked as completed already but has not + // reached the availability cache yet. + Some(AssemblyColumn::Complete(_)) => { + return Ok(None); + } + None => { + // No cached data, all cells are "missing" (new data we want) + } + } + } + // No cached data, all cells are "missing" (new data we want) + data_column.try_filter_to_partial_ref(|_, _, _| Ok(true)) + } + + /// Filter out all cells that are already cached for the given `block_root`. + /// Returns input for kzg verification, or None if all cells are already cached. + pub fn missing_cells_for_partial_column_sidecar<'a>( + &'_ self, + partial_data_column: &'a PartialDataColumn, + ) -> Result>, MissingCellsError> { + let column_index = partial_data_column.index; + let block_root = partial_data_column.block_root; + + // Check DA checker cache first - if we have a full column cached, nothing is missing. + if self + .availability_cache + .peek_pending_components(&block_root, |components| { + components.is_some_and(|c| c.get_cached_data_column(column_index).is_some()) }) + { + return Ok(None); + } + + // Check assembler for partial columns + if let Some(assembler) = &self.partial_assembler { + match assembler.get_partial(&block_root, column_index) { + Some(AssemblyColumn::Incomplete(cached_partial)) => { + return Ok(partial_data_column.sidecar.filter(|idx| { + cached_partial.as_data_column().sidecar.get(idx).is_none() + })?); + } + // This can happen if the column has been marked as completed already but has not + // reached the availability cache yet. + Some(AssemblyColumn::Complete(_)) => { + return Ok(None); + } + None => { + // No cached data, all cells are "missing" (new data we want) + } + } + } + // No cached data, all cells are "missing" (new data we want) + Ok(partial_data_column.sidecar.filter(|_| true)?) } /// Get a blob from the availability cache. @@ -295,7 +395,8 @@ impl DataAvailabilityChecker { /// have a block cached, return the `Availability` variant triggering block import. /// Otherwise cache the data column sidecar. /// - /// This should only accept gossip verified data columns, so we should not have to worry about dupes. + /// This should only accept gossip verified full data columns (not partials). + /// Partials are assembled in PartialDataColumnAssembler. #[instrument(skip_all, level = "trace")] pub fn put_gossip_verified_data_columns< O: ObservationStrategy, @@ -316,10 +417,18 @@ impl DataAvailabilityChecker { .map(|c| KzgVerifiedCustodyDataColumn::from_asserted_custody(c.into_inner())) .collect::>(); + if let Some(assembler) = &self.partial_assembler { + for column in &custody_columns { + assembler.mark_as_complete(block_root, column); + } + } + self.availability_cache .put_kzg_verified_data_columns(block_root, custody_columns) } + /// Put KZG-verified full custody data columns. + /// Only accepts full columns. Partials are assembled in PartialDataColumnAssembler. #[instrument(skip_all, level = "trace")] pub fn put_kzg_verified_custody_data_columns< I: IntoIterator>, @@ -338,6 +447,12 @@ impl DataAvailabilityChecker { &self, executed_block: AvailabilityPendingExecutedBlock, ) -> Result, AvailabilityCheckError> { + let block = executed_block.as_block(); + if let Some(assembler) = &self.partial_assembler + && let Ok(header) = block.try_into() + { + assembler.init(executed_block.import_data.block_root, Arc::new(header)); + } self.availability_cache.put_executed_block(executed_block) } @@ -349,6 +464,11 @@ impl DataAvailabilityChecker { block: Arc>, source: BlockImportSource, ) -> Result<(), Error> { + if let Some(assembler) = &self.partial_assembler + && let Ok(header) = block.as_ref().try_into() + { + assembler.init(block_root, Arc::new(header)); + } self.availability_cache .put_pre_execution_block(block_root, block, source) } @@ -568,8 +688,12 @@ pub fn start_availability_cache_maintenance_service( // this cache only needs to be maintained if deneb is configured if chain.spec.deneb_fork_epoch.is_some() { let overflow_cache = chain.data_availability_checker.availability_cache.clone(); + let partial_assembler = chain.data_availability_checker.partial_assembler.clone(); executor.spawn( - async move { availability_cache_maintenance_service(chain, overflow_cache).await }, + async move { + availability_cache_maintenance_service(chain, overflow_cache, partial_assembler) + .await + }, "availability_cache_service", ); } else { @@ -580,6 +704,7 @@ pub fn start_availability_cache_maintenance_service( async fn availability_cache_maintenance_service( chain: Arc>, overflow_cache: Arc>, + partial_assembler: Option>>, ) { let epoch_duration = chain.slot_clock.slot_duration() * T::EthSpec::slots_per_epoch() as u32; loop { @@ -631,6 +756,9 @@ async fn availability_cache_maintenance_service( if let Err(e) = overflow_cache.do_maintenance(cutoff_epoch) { error!(error = ?e,"Failed to maintain availability cache"); } + if let Some(assembler) = &partial_assembler { + assembler.do_maintenance(cutoff_epoch); + } } None => { error!("Failed to read slot clock"); @@ -887,6 +1015,21 @@ impl MaybeAvailableBlock { } } +pub enum MissingCellsError { + /// The provided column is not matching with the existing cached column. + /// This is to be treated as a KZG verification failure. + MismatchesCachedColumn, + /// An error occurred while operating on the column. It is possibly malformed. + /// This is not expected to happen for columns passing basic validation. + UnexpectedError(PartialDataColumnSidecarError), +} + +impl From for MissingCellsError { + fn from(e: PartialDataColumnSidecarError) -> Self { + Self::UnexpectedError(e) + } +} + #[cfg(test)] mod test { use super::*; @@ -1254,6 +1397,7 @@ mod test { kzg, custody_context, spec, + true, ) .expect("should initialise data availability checker") } diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index a24dbd8942..8ea3c792f4 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -1,7 +1,10 @@ use crate::block_verification::{ BlockSlashInfo, get_validator_pubkey_cache, process_block_slash_info, }; -use crate::kzg_utils::{reconstruct_data_columns, validate_data_columns}; +use crate::data_availability_checker::MissingCellsError; +use crate::kzg_utils::{ + reconstruct_data_columns, validate_full_data_columns, validate_partial_data_columns, +}; use crate::observed_data_sidecars::{ Error as ObservedDataSidecarsError, ObservationKey, ObservationStrategy, Observe, }; @@ -18,10 +21,14 @@ use std::marker::PhantomData; use std::sync::Arc; use std::time::Duration; use tracing::{debug, instrument}; -use types::data::ColumnIndex; +use tree_hash::TreeHash; +use types::data::{ + ColumnIndex, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnSidecar, + PartialDataColumnSidecarError, +}; use types::{ BeaconStateError, ChainSpec, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSubnetId, - EthSpec, Hash256, Slot, + EthSpec, Hash256, PartialDataColumnSidecarRef, SignedBeaconBlockHeader, Slot, }; /// An error occurred while validating a gossip data column. @@ -63,6 +70,13 @@ pub enum GossipDataColumnError { /// /// The data column sidecar is invalid and the peer is faulty. InvalidKzgProof(kzg::Error), + /// The column mismatches the cached (possibly partial) column. + /// This is equivalent to failed kzg verification. + /// + /// ## Peer scoring + /// + /// The data column sidecar is invalid and the peer is faulty. + MismatchesCachedColumn, /// The column was gossiped over an incorrect subnet. /// /// ## Peer scoring @@ -115,6 +129,7 @@ pub enum GossipDataColumnError { /// We cannot process the columns without validating its parent, the peer isn't necessarily faulty. ParentUnknown { parent_root: Hash256, + slot: Slot, }, /// The column conflicts with finalization, no need to propagate. /// @@ -199,25 +214,88 @@ impl From for GossipDataColumnError { } } +#[derive(Debug)] +pub enum GossipPartialDataColumnError { + GossipDataColumnError(GossipDataColumnError), + /// Partial messages are disabled and we can not validate them. + /// + /// ## Peer scoring + /// A peer sent us a partial message even though we did not advertize support for it, penalize + /// it + PartialColumnsDisabled, + /// There was an unexpected error while performing an operation on the partial data column. + InternalError(PartialDataColumnSidecarError), + /// The partial data column does not contain a header, and we do not have it cached. + /// + /// ## Peer scoring + /// The peer SHOULD send us the header on the first partial message, but is not required to. + /// Still, the peer incorrectly assumed that we have the header, and sent us data we can not + /// process due to that. Penalize it slightly. + MissingHeader, + /// The partial data column header does not match the valid one we have already cached. + /// + /// ## Peer scoring + /// The column sidecar is invalid and the peer is faulty + HeaderMismatches, + /// The partial data column header block root does not match the group id. + /// + /// ## Peer scoring + /// The column sidecar is invalid and the peer is faulty + HeaderIncorrectRoot { + group_id: Hash256, + header_hash: Hash256, + }, + /// The partial message has neither a header nor cells. + /// + /// ## Peer scoring + /// The column sidecar is invalid and the peer is faulty + EmptyMessage, + /// The partial message has a count of proofs anc/or cells that is inconsistent with the bitmap. + /// + /// ## Peer scoring + /// The column sidecar is invalid and the peer is faulty + InconsistentPresentCount { + bitmap_popcount: usize, + cells_len: usize, + proofs_len: usize, + }, + /// The partial message has a bitmap length that is inconsistent with the number of commitments. + /// + /// ## Peer scoring + /// The column sidecar is invalid and the peer is faulty + InconsistentCommitmentsLength { + bitmap_len: usize, + commitments_len: usize, + }, +} + +impl From for GossipPartialDataColumnError { + fn from(e: GossipDataColumnError) -> Self { + GossipPartialDataColumnError::GossipDataColumnError(e) + } +} + +impl From for GossipPartialDataColumnError { + fn from(e: BeaconChainError) -> Self { + GossipDataColumnError::from(e).into() + } +} + +impl From for GossipPartialDataColumnError { + fn from(e: BeaconStateError) -> Self { + GossipDataColumnError::from(e).into() + } +} + /// A wrapper around a `DataColumnSidecar` that indicates it has been approved for re-gossiping on /// the p2p network. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct GossipVerifiedDataColumn { block_root: Hash256, data_column: KzgVerifiedDataColumn, _phantom: PhantomData, } -impl Clone for GossipVerifiedDataColumn { - fn clone(&self) -> Self { - Self { - block_root: self.block_root, - data_column: self.data_column.clone(), - _phantom: PhantomData, - } - } -} - impl GossipVerifiedDataColumn { pub fn new( column_sidecar: Arc>, @@ -262,22 +340,29 @@ impl GossipVerifiedDataColumn // In this case, we should accept it for gossip propagation. verify_is_unknown_sidecar(chain, &column_sidecar)?; - if chain + match chain .data_availability_checker - .is_data_column_cached(&column_sidecar.block_root(), &column_sidecar) + .missing_cells_for_column_sidecar(&column_sidecar) { - // Observe this data column so we don't process it again. - if O::observe() { - observe_gossip_data_column(&column_sidecar, chain)?; + Ok(Some(_)) => Ok(Self { + block_root: column_sidecar.block_root(), + data_column: KzgVerifiedDataColumn::from_execution_verified(column_sidecar), + _phantom: Default::default(), + }), + Ok(None) => { + // Observe this data column so we don't process it again. + if O::observe() { + observe_gossip_data_column(&column_sidecar, chain)?; + } + Err(GossipDataColumnError::PriorKnownUnpublished) + } + Err(MissingCellsError::MismatchesCachedColumn) => { + Err(GossipDataColumnError::MismatchesCachedColumn) + } + Err(MissingCellsError::UnexpectedError(_)) => { + todo!("handle unexpected error") } - return Err(GossipDataColumnError::PriorKnownUnpublished); } - - Ok(Self { - block_root: column_sidecar.block_root(), - data_column: KzgVerifiedDataColumn::from_execution_verified(column_sidecar), - _phantom: Default::default(), - }) } /// Create a `GossipVerifiedDataColumn` from `DataColumnSidecar` for testing ONLY. @@ -316,24 +401,14 @@ impl GossipVerifiedDataColumn } /// Wrapper over a `DataColumnSidecar` for which we have completed kzg verification. -#[derive(Debug, Educe, Clone, Encode)] +#[derive(Debug, Educe, Clone)] #[educe(PartialEq, Eq)] -#[ssz(struct_behaviour = "transparent")] pub struct KzgVerifiedDataColumn { data: Arc>, - #[ssz(skip_serializing, skip_deserializing)] seen_timestamp: Duration, } impl KzgVerifiedDataColumn { - pub fn new( - data_column: Arc>, - kzg: &Kzg, - seen_timestamp: Duration, - ) -> Result, KzgError)> { - verify_kzg_for_data_column(data_column, kzg, seen_timestamp) - } - /// Mark a data column as KZG verified. Caller must ONLY use this on columns constructed /// from EL blobs. pub fn from_execution_verified(data_column: Arc>) -> Self { @@ -381,6 +456,131 @@ impl KzgVerifiedDataColumn { } } +/// Wrapper over a `VerifiablePartialDataColumn` for which we have completed kzg verification. +#[derive(Debug, Educe, Clone)] +#[educe(PartialEq, Eq)] +pub struct KzgVerifiedPartialDataColumn { + data: Arc>, + latest_cell_timestamp: Duration, +} + +impl KzgVerifiedPartialDataColumn { + /// Create a `KzgVerifiedPartialDataColumn` for testing ONLY. + pub(crate) fn __new_for_testing(data_column: Arc>) -> Self { + Self { + data: data_column, + latest_cell_timestamp: timestamp_now(), + } + } + + /// Mark a partial data column as KZG verified. Caller must ONLY use this on columns constructed + /// from EL blobs. + pub fn from_execution_verified(data_column: Arc>) -> Self { + Self { + data: data_column, + latest_cell_timestamp: timestamp_now(), + } + } + + pub fn to_data_column(self) -> Arc> { + self.data + } + + pub fn as_data_column(&self) -> &PartialDataColumn { + &self.data + } + + pub fn index(&self) -> ColumnIndex { + self.data.index + } + + pub fn block_root(&self) -> Hash256 { + self.data.block_root + } +} + +/// Wrapper over a `PartialDataColumnHeader` for which we have completed gossip verification. +#[derive(Debug, Educe, Clone)] +#[educe(PartialEq, Eq)] +pub struct GossipVerifiedPartialDataColumnHeader { + header: Arc>, + previously_cached: bool, +} + +impl GossipVerifiedPartialDataColumnHeader { + pub fn new>( + group_id: Hash256, + header: PartialDataColumnHeader, + chain: &BeaconChain, + ) -> Result { + let column_slot = header.slot(); + if header.kzg_commitments.is_empty() { + return Err(GossipDataColumnError::UnexpectedDataColumn.into()); + } + + let header_hash = header.signed_block_header.message.canonical_root(); + if group_id != header_hash { + return Err(GossipPartialDataColumnError::HeaderIncorrectRoot { + group_id, + header_hash, + }); + } + + verify_sidecar_not_from_future_slot(chain, column_slot)?; + verify_slot_greater_than_latest_finalized_slot(chain, column_slot)?; + verify_partial_column_header_inclusion_proof(&header)?; + let parent_block = verify_parent_block_and_finalized_descendant( + header.signed_block_header.message.parent_root, + column_slot, + chain, + )?; + verify_slot_higher_than_parent(&parent_block, column_slot)?; + verify_proposer_and_signature(&header.signed_block_header, &parent_block, chain)?; + + let header = Arc::new(header); + + // Cache the valid header + let Some(assembler) = chain.data_availability_checker.partial_assembler() else { + return Err(GossipPartialDataColumnError::PartialColumnsDisabled); + }; + let newly_cached = assembler.init(group_id, header.clone()); + + chain + .observed_slashable + .write() + .observe_slashable( + column_slot, + header.signed_block_header.message.proposer_index, + header_hash, + ) + .map_err(BeaconChainError::from)?; + + Ok(Self { + header, + previously_cached: !newly_cached, + }) + } + + pub fn new_from_cached(header: Arc>) -> Self { + Self { + header, + previously_cached: true, + } + } + + pub fn was_cached(&self) -> bool { + self.previously_cached + } + + pub fn as_header(&self) -> &PartialDataColumnHeader { + &self.header + } + + pub fn into_header(self) -> Arc> { + self.header + } +} + pub type CustodyDataColumnList = VariableList, ::NumberOfColumns>; @@ -414,13 +614,12 @@ impl CustodyDataColumn { } } -/// Data column that we must custody and has completed kzg verification -#[derive(Debug, Educe, Clone, Encode)] +/// Data column that we must custody and has completed kzg verification. +/// Wraps a full `DataColumnSidecar`. +#[derive(Debug, Educe, Clone)] #[educe(PartialEq, Eq)] -#[ssz(struct_behaviour = "transparent")] pub struct KzgVerifiedCustodyDataColumn { data: Arc>, - #[ssz(skip_serializing, skip_deserializing)] seen_timestamp: Duration, } @@ -434,19 +633,6 @@ impl KzgVerifiedCustodyDataColumn { } } - /// Verify a column already marked as custody column - pub fn new( - data_column: CustodyDataColumn, - kzg: &Kzg, - seen_timestamp: Duration, - ) -> Result, KzgError)> { - verify_kzg_for_data_column(data_column.clone_arc(), kzg, seen_timestamp)?; - Ok(Self { - data: data_column.data, - seen_timestamp, - }) - } - pub fn reconstruct_columns( kzg: &Kzg, partial_set_of_columns: &[Self], @@ -493,23 +679,211 @@ impl KzgVerifiedCustodyDataColumn { } } +/// Partial data column that we must custody and has completed kzg verification. +/// Wraps a `VerifiablePartialDataColumn`. +#[derive(Debug, Educe, Clone)] +#[educe(PartialEq, Eq)] +pub struct KzgVerifiedCustodyPartialDataColumn { + data: Arc>, + latest_cell_timestamp: Duration, +} + +impl KzgVerifiedCustodyPartialDataColumn { + /// Mark a partial column as custody column. Caller must ensure that our current custody requirements + /// include this column + pub fn from_asserted_custody(kzg_verified: KzgVerifiedPartialDataColumn) -> Self { + Self { + latest_cell_timestamp: kzg_verified.latest_cell_timestamp, + data: kzg_verified.to_data_column(), + } + } + + pub fn into_inner(self) -> Arc> { + self.data + } + + pub fn as_data_column(&self) -> &PartialDataColumn { + &self.data + } + + pub fn index(&self) -> ColumnIndex { + self.data.index + } + + /// Merge two verified partial data columns. + /// + /// Each column must be internally consistent. Additionally, the columns to be merged must have + /// the same block root and index. + /// An error is returned if the columns are internally inconsistent or incompatible for merging. + /// + /// If both columns contain the same cell, the cell from `self` is used - however, as they are + /// KZG verified, they will be the same. + pub fn merge(&self, other: &Self) -> Result { + let self_sidecar = &self.data.sidecar; + let other_sidecar = &other.data.sidecar; + + // Check that each sidecar is internally consistent by checking the lengths. + self_sidecar.verify_len()?; + other_sidecar.verify_len()?; + if self.data.block_root != other.data.block_root || self.data.index != other.data.index { + return Err(PartialDataColumnSidecarError::ConflictingData); + } + if self_sidecar.cells_present_bitmap.len() != other_sidecar.cells_present_bitmap.len() { + return Err(PartialDataColumnSidecarError::DifferingLengths { + lhs_len: self_sidecar.cells_present_bitmap.len(), + rhs_len: other_sidecar.cells_present_bitmap.len(), + }); + } + + let new_bitmap = self_sidecar + .cells_present_bitmap + .union(&other_sidecar.cells_present_bitmap); + let len = new_bitmap.num_set_bits(); + let mut new_column = Vec::with_capacity(len); + let mut new_proofs = Vec::with_capacity(len); + let mut self_iter = self_sidecar + .column + .iter() + .zip(self_sidecar.kzg_proofs.iter()); + let mut other_iter = other_sidecar + .column + .iter() + .zip(other_sidecar.kzg_proofs.iter()); + + for presence_bits in self_sidecar + .cells_present_bitmap + .iter() + .zip(other_sidecar.cells_present_bitmap.iter()) + { + match presence_bits { + (false, false) => {} + (true, other) => { + let (cell, proof) = self_iter + .next() + .ok_or(PartialDataColumnSidecarError::UnexpectedBounds)?; + new_column.push(cell.clone()); + new_proofs.push(*proof); + if other { + other_iter + .next() + .ok_or(PartialDataColumnSidecarError::UnexpectedBounds)?; + } + } + (false, true) => { + let (cell, proof) = other_iter + .next() + .ok_or(PartialDataColumnSidecarError::UnexpectedBounds)?; + new_column.push(cell.clone()); + new_proofs.push(*proof); + } + } + } + + Ok(Self { + data: Arc::new(PartialDataColumn { + block_root: self.data.block_root, + index: self.data.index, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: new_bitmap, + column: new_column + .try_into() + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?, + kzg_proofs: new_proofs + .try_into() + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?, + header: if self_sidecar.header.is_some() { + self_sidecar.header.clone() + } else { + other_sidecar.header.clone() + }, + }, + }), + latest_cell_timestamp: self.latest_cell_timestamp.max(other.latest_cell_timestamp), + }) + } + + pub fn try_clone_full( + &self, + header: &PartialDataColumnHeader, + ) -> Option> { + self.data + .try_clone_full(header) + .map(|data| KzgVerifiedCustodyDataColumn { + data: Arc::new(data), + seen_timestamp: self.latest_cell_timestamp, + }) + } + + /// Try to convert the partial data column into a full one, returning None if the conversion + /// fails. + /// May clone the column if the Arc cannot be unwrapped. + pub fn try_into_full( + self, + header: &PartialDataColumnHeader, + ) -> Option> { + match Arc::try_unwrap(self.data) { + Ok(data) => data.try_into_full(header), + Err(data) => data.try_clone_full(header), + } + .map(|data| KzgVerifiedCustodyDataColumn { + data: Arc::new(data), + seen_timestamp: self.latest_cell_timestamp, + }) + } +} + /// Complete kzg verification for a `DataColumnSidecar`. /// /// Returns an error if the kzg verification check fails. #[instrument(skip_all, level = "debug")] pub fn verify_kzg_for_data_column( data_column: Arc>, + cells_to_verify: PartialDataColumnSidecarRef, kzg: &Kzg, seen_timestamp: Duration, ) -> Result, (Option, KzgError)> { let _timer = metrics::start_timer(&metrics::KZG_VERIFICATION_DATA_COLUMN_SINGLE_TIMES); - validate_data_columns(kzg, iter::once(&data_column))?; + let Ok(kzg_commitments) = data_column.kzg_commitments() else { + return Err(( + Some(*data_column.index()), + KzgError::InconsistentArrayLength("todo(gloas)".to_string()), + )); + }; + validate_partial_data_columns( + kzg, + iter::once((*data_column.index(), cells_to_verify)), + kzg_commitments, + )?; Ok(KzgVerifiedDataColumn { data: data_column, seen_timestamp, }) } +/// Complete kzg verification for a `VerifiablePartialDataColumn`. +/// +/// Returns an error if the kzg verification check fails. +#[instrument(skip_all, level = "debug")] +pub fn verify_kzg_for_partial_data_column( + data_column: Arc>, + cells_to_verify: PartialDataColumnSidecarRef, + header: &GossipVerifiedPartialDataColumnHeader, + kzg: &Kzg, + seen_timestamp: Duration, +) -> Result, GossipPartialDataColumnError> { + let _timer = metrics::start_timer(&metrics::KZG_VERIFICATION_DATA_COLUMN_SINGLE_TIMES); + validate_partial_data_columns( + kzg, + iter::once((data_column.index, cells_to_verify)), + header.header.kzg_commitments.as_ref(), + ) + .map_err(|(_, e)| GossipDataColumnError::InvalidKzgProof(e))?; + Ok(KzgVerifiedPartialDataColumn { + data: data_column, + latest_cell_timestamp: seen_timestamp, + }) +} + /// Complete kzg verification for a list of `DataColumnSidecar`s. /// Returns an error for the first `DataColumnSidecar`s that fails kzg verification. /// @@ -523,7 +897,7 @@ where I: Iterator>> + Clone, { let _timer = metrics::start_timer(&metrics::KZG_VERIFICATION_DATA_COLUMN_BATCH_TIMES); - validate_data_columns(kzg, data_column_iter)?; + validate_full_data_columns(kzg, data_column_iter)?; Ok(()) } @@ -549,30 +923,45 @@ pub fn validate_data_column_sidecar_for_gossip_fulu { + GossipDataColumnError::MismatchesCachedColumn + } + MissingCellsError::UnexpectedError(_) => todo!("handle unexpected error"), + })? + else { // Observe this data column so we don't process it again. if O::observe() { observe_gossip_data_column(&data_column, chain)?; } return Err(GossipDataColumnError::PriorKnownUnpublished); - } + }; verify_column_inclusion_proof(data_column_fulu)?; - let parent_block = verify_parent_block_and_finalized_descendant(data_column_fulu, chain)?; + let parent_block = verify_parent_block_and_finalized_descendant( + data_column_fulu.block_parent_root(), + column_slot, + chain, + )?; verify_slot_higher_than_parent(&parent_block, column_slot)?; - verify_proposer_and_signature(data_column_fulu, &parent_block, chain)?; + verify_proposer_and_signature(&data_column_fulu.signed_block_header, &parent_block, chain)?; let kzg = &chain.kzg; let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); - let kzg_verified_data_column = - verify_kzg_for_data_column(data_column.clone(), kzg, seen_timestamp) - .map_err(|(_, e)| GossipDataColumnError::InvalidKzgProof(e))?; + let kzg_verified_data_column = verify_kzg_for_data_column( + data_column.clone(), + cells_to_kzg_verify, + kzg, + seen_timestamp, + ) + .map_err(|(_, e)| GossipDataColumnError::InvalidKzgProof(e))?; chain .observed_slashable @@ -595,6 +984,137 @@ pub fn validate_data_column_sidecar_for_gossip_fulu( + mut column: Box>, + chain: &BeaconChain, + seen_timestamp: Duration, +) -> PartialColumnVerificationResult { + let block_root = column.block_root; + + // Remove the header (if any) to avoid wasted memory. + let header = column.sidecar.header.take(); + + let header = if let Some(header) = header { + // Header was sent, so it is required to be valid + match chain.verify_partial_data_column_header_for_gossip(block_root, header) { + Ok(verified) => verified, + Err(err) => { + return PartialColumnVerificationResult::Err(err); + } + } + } else { + let Some(assembler) = chain.data_availability_checker.partial_assembler() else { + return PartialColumnVerificationResult::Err( + GossipPartialDataColumnError::PartialColumnsDisabled, + ); + }; + + // There is no header, so we check if we have a cached one to use + let Some(header) = assembler + .get_header(&column.block_root) + .map(GossipVerifiedPartialDataColumnHeader::new_from_cached) + else { + return PartialColumnVerificationResult::Err( + GossipPartialDataColumnError::MissingHeader, + ); + }; + + // If there was no header, there must be at least one cell. + if column.sidecar.column.is_empty() { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::EmptyMessage, + header, + }; + } + + header + }; + + // The number of cells nad proofs must match the population count of the bitmap. + let bitmap_popcount = column.sidecar.cells_present_bitmap.num_set_bits(); + let cells_len = column.sidecar.column.len(); + let proofs_len = column.sidecar.kzg_proofs.len(); + if bitmap_popcount != cells_len || bitmap_popcount != proofs_len { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::InconsistentPresentCount { + bitmap_popcount, + cells_len, + proofs_len, + }, + header, + }; + } + + let bitmap_len = column.sidecar.cells_present_bitmap.len(); + let commitments_len = header.as_header().kzg_commitments.len(); + if bitmap_len != commitments_len { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::InconsistentCommitmentsLength { + bitmap_len, + commitments_len, + }, + header, + }; + } + + let column = Arc::from(column); + let cells_to_kzg_verify = match chain + .data_availability_checker + .missing_cells_for_partial_column_sidecar(&column) + { + Ok(Some(cells_to_kzg_verify)) => cells_to_kzg_verify, + Ok(None) => { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipDataColumnError::PriorKnownUnpublished.into(), + header, + }; + } + Err(MissingCellsError::MismatchesCachedColumn) => { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipDataColumnError::MismatchesCachedColumn.into(), + header, + }; + } + Err(MissingCellsError::UnexpectedError(e)) => todo!("handle unexpected error {:?}", e), + }; + + // We do not have to check block related data here, as we create the verifiable column from + // gossip accepted block + let kzg = &chain.kzg; + let column = match verify_kzg_for_partial_data_column( + column.clone(), + cells_to_kzg_verify, + &header, + kzg, + seen_timestamp, + ) { + Ok(column) => column, + Err(err) => { + return PartialColumnVerificationResult::ErrWithValidHeader { err, header }; + } + }; + + PartialColumnVerificationResult::Ok { column, header } +} + +/// The result of a `validate_partial_data_column_sidecar_for_gossip` call. Any headers returned +/// herein were cached during this call or previously cached. +pub enum PartialColumnVerificationResult { + /// Verification succeeded fully. + Ok { + column: KzgVerifiedPartialDataColumn, + header: GossipVerifiedPartialDataColumnHeader, + }, + /// Verification of the column failed, but the header is valid. + ErrWithValidHeader { + err: GossipPartialDataColumnError, + header: GossipVerifiedPartialDataColumnHeader, + }, + /// Verification of the column or header failed, and no valid header was cached previously. + Err(GossipPartialDataColumnError), +} + /// Verify if the data column sidecar is valid. fn verify_data_column_sidecar( data_column: &DataColumnSidecar, @@ -677,6 +1197,17 @@ fn verify_column_inclusion_proof( Ok(()) } +fn verify_partial_column_header_inclusion_proof( + header: &PartialDataColumnHeader, +) -> Result<(), GossipDataColumnError> { + let _timer = metrics::start_timer(&metrics::DATA_COLUMN_SIDECAR_INCLUSION_PROOF_VERIFICATION); + if !header.verify_inclusion_proof() { + return Err(GossipDataColumnError::InvalidInclusionProof); + } + + Ok(()) +} + fn verify_slot_higher_than_parent( parent_block: &Block, data_column_slot: Slot, @@ -691,17 +1222,18 @@ fn verify_slot_higher_than_parent( } fn verify_parent_block_and_finalized_descendant( - data_column: &DataColumnSidecarFulu, + block_parent_root: Hash256, + slot: Slot, chain: &BeaconChain, ) -> Result { let fork_choice = chain.canonical_head.fork_choice_read_lock(); // We have already verified that the column is past finalization, so we can // just check fork choice for the block's parent. - let block_parent_root = data_column.block_parent_root(); let Some(parent_block) = fork_choice.get_block(&block_parent_root) else { return Err(GossipDataColumnError::ParentUnknown { parent_root: block_parent_root, + slot, }); }; @@ -715,16 +1247,15 @@ fn verify_parent_block_and_finalized_descendant( } fn verify_proposer_and_signature( - data_column: &DataColumnSidecarFulu, + signed_block_header: &SignedBeaconBlockHeader, parent_block: &ProtoBlock, chain: &BeaconChain, ) -> Result<(), GossipDataColumnError> { - let column_slot = data_column.slot(); + let column_slot = signed_block_header.message.slot; let slots_per_epoch = T::EthSpec::slots_per_epoch(); let column_epoch = column_slot.epoch(slots_per_epoch); - let column_index = data_column.index; - let block_root = data_column.block_root(); - let block_parent_root = data_column.block_parent_root(); + let block_root = signed_block_header.message.tree_hash_root(); + let block_parent_root = signed_block_header.message.parent_root; let proposer_shuffling_root = parent_block.proposer_shuffling_root_for_child_block(column_epoch, &chain.spec); @@ -736,7 +1267,6 @@ fn verify_proposer_and_signature( || { debug!( %block_root, - index = %column_index, "Proposer shuffling cache miss for column verification" ); // We assume that the `Pending` state has the same shufflings as a `Full` state @@ -765,7 +1295,6 @@ fn verify_proposer_and_signature( let pubkey = pubkey_cache .get(proposer_index) .ok_or_else(|| GossipDataColumnError::UnknownValidator(proposer_index as u64))?; - let signed_block_header = &data_column.signed_block_header; signed_block_header.verify_signature::( pubkey, &fork, @@ -778,7 +1307,7 @@ fn verify_proposer_and_signature( return Err(GossipDataColumnError::ProposalSignatureInvalid); } - let column_proposer_index = data_column.block_proposer_index(); + let column_proposer_index = signed_block_header.message.proposer_index; if proposer_index != column_proposer_index as usize { return Err(GossipDataColumnError::ProposerIndexMismatch { sidecar: column_proposer_index as usize, @@ -875,20 +1404,29 @@ pub fn observe_gossip_data_column( #[cfg(test)] mod test { + use crate::ChainConfig; use crate::data_column_verification::{ - GossipDataColumnError, GossipVerifiedDataColumn, - validate_data_column_sidecar_for_gossip_fulu, + GossipDataColumnError, GossipPartialDataColumnError, GossipVerifiedDataColumn, + GossipVerifiedPartialDataColumnHeader, KzgVerifiedCustodyPartialDataColumn, + PartialColumnVerificationResult, validate_data_column_sidecar_for_gossip_fulu, + validate_partial_data_column_sidecar_for_gossip, }; use crate::observed_data_sidecars::Observe; use crate::test_utils::{ - BeaconChainHarness, EphemeralHarnessType, generate_data_column_sidecars_from_block, + BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, + generate_data_column_sidecars_from_block, test_spec, }; use eth2::types::BlobsBundle; use execution_layer::test_utils::generate_blobs; + use kzg::KzgProof; + use ssz::BitList; + use ssz_types::VariableList; use std::sync::Arc; + use std::time::UNIX_EPOCH; use types::{ - DataColumnSidecar, DataColumnSidecarFulu, DataColumnSubnetId, EthSpec, ForkName, - MainnetEthSpec, + Cell, CellBitmap, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSubnetId, EthSpec, + ForkName, MainnetEthSpec, PartialDataColumn, PartialDataColumnHeader, + PartialDataColumnSidecar, }; type E = MainnetEthSpec; @@ -1013,4 +1551,360 @@ mod test { Some(GossipDataColumnError::MaxBlobsPerBlockExceeded { .. }) )); } + + #[tokio::test] + async fn test_partial_message_verification_fulu() { + let spec = if fork_name_from_env().is_some() { + Arc::new(test_spec::()) + } else { + Arc::new(ForkName::Fulu.make_genesis_spec(E::default_spec())) + }; + + // Only run these tests if columns are enabled. + if !spec.is_fulu_scheduled() { + return; + } + // Gloas is not supported yet. + if spec.is_gloas_scheduled() { + return; + } + + let chain_config = ChainConfig { + enable_partial_columns: true, + ..Default::default() + }; + let harness = BeaconChainHarness::builder(E::default()) + .spec(spec) + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .chain_config(chain_config) + .build(); + + partial_empty_message_without_cells_returns_error(&harness).await; + partial_inconsistent_present_count_returns_error(&harness).await; + partial_inconsistent_max_count_returns_error(&harness).await; + partial_header_with_empty_commitments_fails(&harness).await; + partial_header_root_mismatch_fails(&harness).await; + partial_header_with_invalid_inclusion_proof_fails(&harness).await; + } + + /// Build a block containing 1 blob and pre-cache the header in the partial assembler. + async fn add_block_and_header( + harness: &BeaconChainHarness>, + ) -> (types::Hash256, Arc>) { + harness.advance_slot(); + // Generate a block with 1 blob so we have valid data columns. + let fork = harness + .spec + .fork_name_at_epoch(harness.get_current_slot().epoch(E::slots_per_epoch())); + let BlobsBundle:: { + commitments, + proofs: _, + blobs: _, + } = generate_blobs(1, fork).unwrap().0; + + let slot = harness.get_current_slot(); + let state = harness.get_current_state(); + let ((block, _blobs_opt), _state) = harness + .make_block_with_modifier(state, slot, |block| { + *block.body_mut().blob_kzg_commitments_mut().unwrap() = + vec![commitments[0]].try_into().unwrap(); + }) + .await; + + let block_root = block.canonical_root(); + let header: PartialDataColumnHeader = block.as_ref().try_into().unwrap(); + let header = Arc::new(header); + + // Pre-cache the header in the partial assembler so headerless partials can be verified. + harness + .chain + .data_availability_checker + .partial_assembler() + .unwrap() + .init(block_root, header.clone()); + + (block_root, header) + } + + async fn partial_empty_message_without_cells_returns_error( + harness: &BeaconChainHarness>, + ) { + let (block_root, header) = add_block_and_header(harness).await; + + // Create a headerless partial with no cells — should trigger EmptyMessage. + let num_commitments = header.kzg_commitments.len(); + let empty_bitmap = + BitList::<::MaxBlobCommitmentsPerBlock>::with_capacity(num_commitments) + .unwrap(); + + let column = PartialDataColumn { + block_root, + index: 0, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: empty_bitmap, + column: vec![].try_into().unwrap(), + kzg_proofs: vec![].try_into().unwrap(), + header: None.into(), + }, + }; + + let result = validate_partial_data_column_sidecar_for_gossip( + Box::new(column), + &harness.chain, + UNIX_EPOCH.elapsed().unwrap(), + ); + assert!( + matches!( + result, + PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::EmptyMessage, + .. + } + ), + "Expected EmptyMessage" + ); + } + + async fn partial_inconsistent_present_count_returns_error( + harness: &BeaconChainHarness>, + ) { + let (block_root, header) = add_block_and_header(harness).await; + + // Create a bitmap that says 2 bits are set, but only provide 1 cell/proof. + let num_commitments = header.kzg_commitments.len(); + let mut bitmap = + BitList::<::MaxBlobCommitmentsPerBlock>::with_capacity(num_commitments) + .unwrap(); + bitmap.set(0, true).unwrap(); + + let column = PartialDataColumn { + block_root, + index: 0, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column: vec![types::Cell::::default()].try_into().unwrap(), + // Provide 2 proofs but only 1 cell ← mismatch with popcount=1 + kzg_proofs: vec![types::KzgProof::empty(), types::KzgProof::empty()] + .try_into() + .unwrap(), + header: None.into(), + }, + }; + + let result = validate_partial_data_column_sidecar_for_gossip( + Box::new(column), + &harness.chain, + UNIX_EPOCH.elapsed().unwrap(), + ); + assert!( + matches!( + result, + PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::InconsistentPresentCount { .. }, + .. + } + ), + "Expected InconsistentPresentCount" + ); + } + + async fn partial_inconsistent_max_count_returns_error( + harness: &BeaconChainHarness>, + ) { + let (block_root, _header) = add_block_and_header(harness).await; + + // Create a bitmap with length different from the number of commitments in the header. + // Header has 1 commitment, but we use a bitmap with capacity 3. + let mut bitmap = + BitList::<::MaxBlobCommitmentsPerBlock>::with_capacity(3).unwrap(); + bitmap.set(0, true).unwrap(); + + let column = PartialDataColumn { + block_root, + index: 0, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column: vec![types::Cell::::default()].try_into().unwrap(), + kzg_proofs: vec![types::KzgProof::empty()].try_into().unwrap(), + header: None.into(), + }, + }; + + let result = validate_partial_data_column_sidecar_for_gossip( + Box::new(column), + &harness.chain, + UNIX_EPOCH.elapsed().unwrap(), + ); + assert!( + matches!( + result, + PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipPartialDataColumnError::InconsistentCommitmentsLength { .. }, + .. + } + ), + "Expected InconsistentMaxCount" + ); + } + + async fn partial_header_with_empty_commitments_fails( + harness: &BeaconChainHarness>, + ) { + let slot = harness.get_current_slot(); + let state = harness.get_current_state(); + let ((block, _), _) = harness + .make_block_with_modifier(state, slot, |block| { + *block.body_mut().blob_kzg_commitments_mut().unwrap() = vec![].try_into().unwrap(); + }) + .await; + + let block_root = block.canonical_root(); + let header: PartialDataColumnHeader = block.as_ref().try_into().unwrap(); + assert!(header.kzg_commitments.is_empty()); + + let result = + GossipVerifiedPartialDataColumnHeader::new(block_root, header, &*harness.chain); + assert!( + matches!( + result, + Err(GossipPartialDataColumnError::GossipDataColumnError( + GossipDataColumnError::UnexpectedDataColumn + )) + ), + "Expected UnexpectedDataColumn, got: {result:?}" + ); + } + + async fn partial_header_root_mismatch_fails( + harness: &BeaconChainHarness>, + ) { + let (_block_root, header) = add_block_and_header(harness).await; + + // Use a wrong group_id (not matching the header's block root) + let wrong_root = types::Hash256::repeat_byte(0xff); + let header = PartialDataColumnHeader::clone(&header); + + let result = + GossipVerifiedPartialDataColumnHeader::new(wrong_root, header, &*harness.chain); + assert!( + matches!( + result, + Err(GossipPartialDataColumnError::HeaderIncorrectRoot { .. }) + ), + "Expected HeaderIncorrectRoot, got: {result:?}" + ); + } + + async fn partial_header_with_invalid_inclusion_proof_fails( + harness: &BeaconChainHarness>, + ) { + let (block_root, header) = add_block_and_header(harness).await; + + // Corrupt the inclusion proof + let mut header = PartialDataColumnHeader::clone(&header); + header.kzg_commitments_inclusion_proof[0] = types::Hash256::repeat_byte(0xaa); + + let result = + GossipVerifiedPartialDataColumnHeader::new(block_root, header, &*harness.chain); + assert!( + matches!( + result, + Err(GossipPartialDataColumnError::GossipDataColumnError( + GossipDataColumnError::InvalidInclusionProof + )) + ), + "Expected InvalidInclusionProof, got: {result:?}" + ); + } + + // -- merge tests -- + + fn make_cell(marker: u8) -> Cell { + let mut cell = Cell::::default(); + cell[0] = marker; + cell + } + + fn make_partial_with_marker( + total_blobs: usize, + present_indices: &[usize], + marker_base: u8, + ) -> KzgVerifiedCustodyPartialDataColumn { + let mut bitmap = CellBitmap::::with_capacity(total_blobs).unwrap(); + for &idx in present_indices { + bitmap.set(idx, true).unwrap(); + } + + let column: VariableList<_, _> = present_indices + .iter() + .map(|&idx| make_cell(marker_base.wrapping_add(idx as u8))) + .collect::>() + .try_into() + .unwrap(); + let proofs: VariableList<_, _> = present_indices + .iter() + .map(|_| KzgProof::empty()) + .collect::>() + .try_into() + .unwrap(); + + KzgVerifiedCustodyPartialDataColumn { + data: Arc::new(PartialDataColumn { + block_root: Default::default(), + index: 0, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column, + kzg_proofs: proofs, + header: None.into(), + }, + }), + latest_cell_timestamp: Default::default(), + } + } + + fn make_partial( + total_blobs: usize, + present_indices: &[usize], + ) -> KzgVerifiedCustodyPartialDataColumn { + make_partial_with_marker(total_blobs, present_indices, 0) + } + + #[test] + fn merge_disjoint_partials() { + let a = make_partial(6, &[0, 2]); + let b = make_partial(6, &[1, 3]); + let merged = a.merge(&b).unwrap(); + assert_eq!(merged.data.sidecar.column.len(), 4); + assert_eq!(merged.data.sidecar.kzg_proofs.len(), 4); + for i in 0..4 { + assert!(merged.data.sidecar.cells_present_bitmap.get(i).unwrap()); + } + assert!(!merged.data.sidecar.cells_present_bitmap.get(4).unwrap()); + } + + #[test] + fn merge_overlapping_partials_prefers_self() { + let a = make_partial_with_marker(4, &[0, 1], 0); + let b = make_partial_with_marker(4, &[1, 2], 100); + let merged = a.merge(&b).unwrap(); + assert_eq!(merged.data.sidecar.column.len(), 3); + // Cell at bitmap index 1 is the second cell in the merged column. + // It should come from `a` (marker_base=0, so marker=0+1=1), not `b` (marker=100+1=101). + assert_eq!(merged.data.sidecar.column[1][0], 1); + } + + #[test] + fn merge_with_empty_other() { + let a = make_partial(4, &[0, 2]); + let b = make_partial(4, &[]); + let merged = a.merge(&b).unwrap(); + assert_eq!(merged.data.sidecar.column.len(), 2); + assert_eq!( + merged.data.sidecar.cells_present_bitmap, + a.data.sidecar.cells_present_bitmap + ); + } } diff --git a/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs b/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs index a5dc7d7f8b..c94fb036f8 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs @@ -1,7 +1,8 @@ use crate::fetch_blobs::{EngineGetBlobsOutput, FetchEngineBlobError}; use crate::observed_data_sidecars::ObservationKey; +use crate::partial_data_column_assembler::PartialDataColumnAssembler; use crate::{AvailabilityProcessingStatus, BeaconChain, BeaconChainTypes}; -use execution_layer::json_structures::{BlobAndProofV1, BlobAndProofV2}; +use execution_layer::json_structures::{BlobAndProofV1, BlobAndProofV2, BlobAndProofV3}; use kzg::Kzg; #[cfg(test)] use mockall::automock; @@ -35,6 +36,13 @@ impl FetchBlobsBeaconAdapter { &self.chain.task_executor } + pub(crate) fn partial_assembler(&self) -> Option>> { + self.chain + .data_availability_checker + .partial_assembler() + .cloned() + } + pub(crate) async fn get_blobs_v1( &self, versioned_hashes: Vec, @@ -67,6 +75,22 @@ impl FetchBlobsBeaconAdapter { .map_err(FetchEngineBlobError::RequestFailed) } + pub(crate) async fn get_blobs_v3( + &self, + versioned_hashes: Vec, + ) -> Result>>, FetchEngineBlobError> { + let execution_layer = self + .chain + .execution_layer + .as_ref() + .ok_or(FetchEngineBlobError::ExecutionLayerMissing)?; + + execution_layer + .get_blobs_v3(versioned_hashes) + .await + .map_err(FetchEngineBlobError::RequestFailed) + } + pub(crate) fn blobs_known_for_observation_key( &self, observation_key: ObservationKey, @@ -119,4 +143,18 @@ impl FetchBlobsBeaconAdapter { .fork_choice_read_lock() .contains_block(block_root) } + + pub(crate) async fn supports_get_blobs_v3(&self) -> Result { + let execution_layer = self + .chain + .execution_layer + .as_ref() + .ok_or(FetchEngineBlobError::ExecutionLayerMissing)?; + + execution_layer + .get_engine_capabilities(None) + .await + .map_err(FetchEngineBlobError::RequestFailed) + .map(|caps| caps.get_blobs_v3) + } } diff --git a/beacon_node/beacon_chain/src/fetch_blobs/mod.rs b/beacon_node/beacon_chain/src/fetch_blobs/mod.rs index ffc308f3d1..f7b4b8a29e 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/mod.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/mod.rs @@ -13,31 +13,28 @@ mod fetch_blobs_beacon_adapter; mod tests; use crate::blob_verification::{GossipBlobError, KzgVerifiedBlob}; -use crate::block_verification_types::AsBlock; -use crate::data_column_verification::{KzgVerifiedCustodyDataColumn, KzgVerifiedDataColumn}; +use crate::data_column_verification::{ + KzgVerifiedCustodyDataColumn, KzgVerifiedCustodyPartialDataColumn, KzgVerifiedPartialDataColumn, +}; #[cfg_attr(test, double)] use crate::fetch_blobs::fetch_blobs_beacon_adapter::FetchBlobsBeaconAdapter; -use crate::kzg_utils::blobs_to_data_column_sidecars; +use crate::kzg_utils::blobs_to_partial_data_columns; use crate::observed_data_sidecars::ObservationKey; use crate::{ AvailabilityProcessingStatus, BeaconChain, BeaconChainError, BeaconChainTypes, BlockError, metrics, }; use execution_layer::Error as ExecutionLayerError; -use execution_layer::json_structures::{BlobAndProofV1, BlobAndProofV2}; +use execution_layer::json_structures::{BlobAndProofV1, BlobAndProofV2, BlobAndProofV3}; use metrics::{TryExt, inc_counter}; #[cfg(test)] use mockall_double::double; use slot_clock::timestamp_now; -use ssz_types::FixedVector; use state_processing::per_block_processing::deneb::kzg_commitment_to_versioned_hash; use std::sync::Arc; use tracing::{debug, instrument, warn}; -use types::data::{BlobSidecarError, DataColumnSidecarError}; -use types::{ - BeaconStateError, Blob, BlobSidecar, ColumnIndex, EthSpec, FullPayload, Hash256, KzgProofs, - SignedBeaconBlock, SignedBeaconBlockHeader, VersionedHash, -}; +use types::data::{BlobSidecarError, ColumnIndex, DataColumnSidecarError, PartialDataColumnHeader}; +use types::{BeaconStateError, BlobSidecar, EthSpec, Hash256, VersionedHash}; /// Result from engine get blobs to be passed onto `DataAvailabilityChecker` and published to the /// gossip network. The blobs / data columns have not been marked as observed yet, as they may not @@ -71,14 +68,14 @@ pub enum FetchEngineBlobError { pub async fn fetch_and_process_engine_blobs( chain: Arc>, block_root: Hash256, - block: Arc>>, + header: Arc>, custody_columns: &[ColumnIndex], publish_fn: impl Fn(EngineGetBlobsOutput) + Send + 'static, ) -> Result, FetchEngineBlobError> { fetch_and_process_engine_blobs_inner( FetchBlobsBeaconAdapter::new(chain), block_root, - block, + header, custody_columns, publish_fn, ) @@ -90,22 +87,16 @@ pub async fn fetch_and_process_engine_blobs( async fn fetch_and_process_engine_blobs_inner( chain_adapter: FetchBlobsBeaconAdapter, block_root: Hash256, - block: Arc>>, + header: Arc>, custody_columns: &[ColumnIndex], publish_fn: impl Fn(EngineGetBlobsOutput) + Send + 'static, ) -> Result, FetchEngineBlobError> { - let versioned_hashes = if let Some(kzg_commitments) = block - .message() - .body() - .blob_kzg_commitments() - .ok() - .filter(|blobs| !blobs.is_empty()) - { - kzg_commitments - .iter() - .map(kzg_commitment_to_versioned_hash) - .collect::>() - } else { + let versioned_hashes = header + .kzg_commitments + .iter() + .map(kzg_commitment_to_versioned_hash) + .collect::>(); + if versioned_hashes.is_empty() { debug!("Fetch blobs not triggered - none required"); return Ok(None); }; @@ -117,12 +108,12 @@ async fn fetch_and_process_engine_blobs_inner( if chain_adapter .spec() - .is_peer_das_enabled_for_epoch(block.epoch()) + .is_peer_das_enabled_for_epoch(header.slot().epoch(T::EthSpec::slots_per_epoch())) { - fetch_and_process_blobs_v2( + fetch_and_process_blobs_v2_or_v3( chain_adapter, block_root, - block, + header, versioned_hashes, custody_columns, publish_fn, @@ -132,7 +123,7 @@ async fn fetch_and_process_engine_blobs_inner( fetch_and_process_blobs_v1( chain_adapter, block_root, - block, + &header, versioned_hashes, publish_fn, ) @@ -144,7 +135,7 @@ async fn fetch_and_process_engine_blobs_inner( async fn fetch_and_process_blobs_v1( chain_adapter: FetchBlobsBeaconAdapter, block_root: Hash256, - block: Arc>, + header: &PartialDataColumnHeader, versioned_hashes: Vec, publish_fn: impl Fn(EngineGetBlobsOutput) + Send + Sized, ) -> Result, FetchEngineBlobError> { @@ -182,19 +173,12 @@ async fn fetch_and_process_blobs_v1( return Ok(None); } - let (signed_block_header, kzg_commitments_proof) = block - .signed_block_header_and_kzg_commitments_proof() - .map_err(FetchEngineBlobError::BeaconStateError)?; + let mut blob_sidecar_list = build_blob_sidecars(header, response)?; - let mut blob_sidecar_list = build_blob_sidecars( - &block, - response, - signed_block_header, - &kzg_commitments_proof, - )?; - - let observation_key = - ObservationKey::new_proposer_key(block.message().proposer_index(), block.slot()); + let observation_key = ObservationKey::new_proposer_key( + header.signed_block_header.message.proposer_index, + header.slot(), + ); if let Some(observed_blobs) = chain_adapter.blobs_known_for_observation_key(observation_key) { blob_sidecar_list.retain(|blob| !observed_blobs.contains(&blob.blob_index())); @@ -225,7 +209,7 @@ async fn fetch_and_process_blobs_v1( let availability_processing_status = chain_adapter .process_engine_blobs( - block.slot(), + header.slot(), block_root, EngineGetBlobsOutput::Blobs(blob_sidecar_list), ) @@ -235,35 +219,53 @@ async fn fetch_and_process_blobs_v1( } #[instrument(skip_all, level = "debug")] -async fn fetch_and_process_blobs_v2( +async fn fetch_and_process_blobs_v2_or_v3( chain_adapter: FetchBlobsBeaconAdapter, block_root: Hash256, - block: Arc>, + header: Arc>, versioned_hashes: Vec, custody_columns_indices: &[ColumnIndex], publish_fn: impl Fn(EngineGetBlobsOutput) + Send + 'static, ) -> Result, FetchEngineBlobError> { let num_expected_blobs = versioned_hashes.len(); + let slot = header.slot(); metrics::observe(&metrics::BLOBS_FROM_EL_EXPECTED, num_expected_blobs as f64); - debug!(num_expected_blobs, "Fetching blobs from the EL"); - // Track request count and duration for standardized metrics - inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V2_REQUESTS_TOTAL); - let _timer = - metrics::start_timer(&metrics::BEACON_ENGINE_GET_BLOBS_V2_REQUEST_DURATION_SECONDS); + let get_blobs_v3 = chain_adapter.supports_get_blobs_v3().await?; + let response = if get_blobs_v3 { + debug!(num_expected_blobs, "Fetching available blobs from the EL"); + // Track request count and duration for standardized metrics + inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V3_REQUESTS_TOTAL); + let _timer = + metrics::start_timer(&metrics::BEACON_ENGINE_GET_BLOBS_V3_REQUEST_DURATION_SECONDS); - let response = chain_adapter - .get_blobs_v2(versioned_hashes) - .await - .inspect_err(|_| { - inc_counter(&metrics::BLOBS_FROM_EL_ERROR_TOTAL); - })?; + chain_adapter + .get_blobs_v3(versioned_hashes) + .await + .inspect_err(|_| { + inc_counter(&metrics::BLOBS_FROM_EL_ERROR_TOTAL); + })? + } else { + debug!(num_expected_blobs, "Fetching all blobs from the EL"); - drop(_timer); + // Track request count and duration for standardized metrics + inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V2_REQUESTS_TOTAL); + let _timer = + metrics::start_timer(&metrics::BEACON_ENGINE_GET_BLOBS_V2_REQUEST_DURATION_SECONDS); - // Track successful response - inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V2_RESPONSES_TOTAL); + let response = chain_adapter + .get_blobs_v2(versioned_hashes) + .await + .inspect_err(|_| { + inc_counter(&metrics::BLOBS_FROM_EL_ERROR_TOTAL); + })?; + + // Track successful response + inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V2_RESPONSES_TOTAL); + + response.map(|vec| vec.into_iter().map(Some).collect()) + }; let Some(blobs_and_proofs) = response else { debug!(num_expected_blobs, "No blobs fetched from the EL"); @@ -271,32 +273,35 @@ async fn fetch_and_process_blobs_v2( return Ok(None); }; - let (blobs, proofs): (Vec<_>, Vec<_>) = blobs_and_proofs - .into_iter() - .map(|blob_and_proof| { - let BlobAndProofV2 { blob, proofs } = blob_and_proof; - (blob, proofs) - }) - .unzip(); - - let num_fetched_blobs = blobs.len(); + let num_fetched_blobs = blobs_and_proofs.iter().filter(|opt| opt.is_some()).count(); metrics::observe(&metrics::BLOBS_FROM_EL_RECEIVED, num_fetched_blobs as f64); if num_fetched_blobs != num_expected_blobs { - // This scenario is not supposed to happen if the EL is spec compliant. - // It should either return all requested blobs or none, but NOT partial responses. - // If we attempt to compute columns with partial blobs, we'd end up with invalid columns. - warn!( - num_fetched_blobs, - num_expected_blobs, "The EL did not return all requested blobs" - ); - inc_counter(&metrics::BLOBS_FROM_EL_MISS_TOTAL); - return Ok(None); + if !get_blobs_v3 { + // This scenario is not supposed to happen if the EL is spec compliant. + // It should either return all requested blobs or none, but NOT partial responses. + // If we attempt to compute columns with partial blobs, we'd end up with invalid columns. + warn!( + num_fetched_blobs, + num_expected_blobs, "The EL did not return all requested blobs" + ); + inc_counter(&metrics::BLOBS_FROM_EL_MISS_TOTAL); + return Ok(None); + } else { + inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V3_PARTIAL_RESPONSES_TOTAL); + debug!( + num_fetched_blobs, + num_expected_blobs, "Blobs partially received from the EL" + ); + } + } else { + debug!(num_fetched_blobs, "All blobs received from the EL"); + inc_counter(&metrics::BLOBS_FROM_EL_HIT_TOTAL); + if get_blobs_v3 { + inc_counter(&metrics::BEACON_ENGINE_GET_BLOBS_V3_COMPLETE_RESPONSES_TOTAL); + } } - debug!(num_fetched_blobs, "All expected blobs received from the EL"); - inc_counter(&metrics::BLOBS_FROM_EL_HIT_TOTAL); - if chain_adapter.fork_choice_contains_block(&block_root) { // Avoid computing columns if the block has already been imported. debug!( @@ -310,9 +315,8 @@ async fn fetch_and_process_blobs_v2( let custody_columns_to_import = compute_custody_columns_to_import( &chain_adapter, block_root, - block.clone(), - blobs, - proofs, + &header, + blobs_and_proofs, custody_columns_indices, ) .await?; @@ -325,20 +329,49 @@ async fn fetch_and_process_blobs_v2( return Ok(None); } - // Up until this point we have not observed the data columns in the gossip cache, which allows - // them to arrive independently while this function is running. In publish_fn we will observe - // them and then publish any columns that had not already been observed. - publish_fn(EngineGetBlobsOutput::CustodyColumns( - custody_columns_to_import.clone(), - )); + let full_columns = match chain_adapter.partial_assembler() { + Some(assembler) => { + // Initialize the partial assembler with the columns from the engine and return any full + // columns for publishing + assembler + .merge_partials(block_root, custody_columns_to_import, header) + .ok_or_else(|| { + FetchEngineBlobError::InternalError( + "Failed to merge partials into assembler".to_string(), + ) + })? + .full_columns + } + None => { + // Partial columns are disabled, so let's try to directly convert the columns we got + // from the EL into full columns. + custody_columns_to_import + .into_iter() + .filter_map(|col| col.try_into_full(&header)) + .collect() + } + }; - let availability_processing_status = chain_adapter - .process_engine_blobs( - block.slot(), - block_root, - EngineGetBlobsOutput::CustodyColumns(custody_columns_to_import), - ) - .await?; + // Publish complete columns + if !full_columns.is_empty() { + publish_fn(EngineGetBlobsOutput::CustodyColumns(full_columns.clone())); + } + // We publish all partials at the calling site, regardless of result, as previous publishs + // have been blocked, waiting for the results of this call + + // Process complete columns through DA checker + let availability_processing_status = if !full_columns.is_empty() { + chain_adapter + .process_engine_blobs( + slot, + block_root, + EngineGetBlobsOutput::CustodyColumns(full_columns), + ) + .await? + } else { + // No complete columns yet, still missing components + AvailabilityProcessingStatus::MissingComponents(slot, block_root) + }; Ok(Some(availability_processing_status)) } @@ -347,28 +380,34 @@ async fn fetch_and_process_blobs_v2( async fn compute_custody_columns_to_import( chain_adapter: &Arc>, block_root: Hash256, - block: Arc>>, - blobs: Vec>, - proofs: Vec>, + header: &PartialDataColumnHeader, + blobs_and_proofs: Vec>, custody_columns_indices: &[ColumnIndex], -) -> Result>, FetchEngineBlobError> { +) -> Result>, FetchEngineBlobError> { let kzg = chain_adapter.kzg().clone(); let spec = chain_adapter.spec().clone(); let chain_adapter_cloned = chain_adapter.clone(); let custody_columns_indices = custody_columns_indices.to_vec(); + let header = header.clone(); chain_adapter .executor() .spawn_blocking_handle( move || { let mut timer = metrics::start_timer_vec( &metrics::DATA_COLUMN_SIDECAR_COMPUTATION, - &[&blobs.len().to_string()], + &[&blobs_and_proofs.len().to_string()], ); - let blob_refs = blobs.iter().collect::>(); - let cell_proofs = proofs.into_iter().flatten().collect(); + let blob_and_proof_refs = blobs_and_proofs + .iter() + .map(|option| { + option + .as_ref() + .map(|BlobAndProofV2 { blob, proofs }| (blob, proofs.as_ref())) + }) + .collect::>(); let data_columns_result = - blobs_to_data_column_sidecars(&blob_refs, cell_proofs, &block, &kzg, &spec) + blobs_to_partial_data_columns(blob_and_proof_refs, &header, &kzg, &spec) .discard_timer_on_break(&mut timer); drop(timer); @@ -379,10 +418,12 @@ async fn compute_custody_columns_to_import( .map(|data_columns| { data_columns .into_iter() - .filter(|col| custody_columns_indices.contains(col.index())) + .filter(|col| custody_columns_indices.contains(&col.index)) .map(|col| { - KzgVerifiedCustodyDataColumn::from_asserted_custody( - KzgVerifiedDataColumn::from_execution_verified(col), + KzgVerifiedCustodyPartialDataColumn::from_asserted_custody( + KzgVerifiedPartialDataColumn::from_execution_verified( + Arc::new(col), + ), ) }) .collect::>() @@ -390,7 +431,8 @@ async fn compute_custody_columns_to_import( .map_err(FetchEngineBlobError::DataColumnSidecarError)?; // Only consider columns that are not already observed on gossip. - let observation_key = ObservationKey::from_block(&block, block_root, &spec); + let observation_key = + ObservationKey::from_partial_column_header(&header, block_root, &spec); if let Some(observed_columns) = chain_adapter_cloned.data_column_known_for_observation_key(observation_key) @@ -421,10 +463,8 @@ async fn compute_custody_columns_to_import( } fn build_blob_sidecars( - block: &Arc>>, + header: &PartialDataColumnHeader, response: Vec>>, - signed_block_header: SignedBeaconBlockHeader, - kzg_commitments_inclusion_proof: &FixedVector, ) -> Result>, FetchEngineBlobError> { let mut sidecars = vec![]; for (index, blob_and_proof) in response @@ -435,9 +475,7 @@ fn build_blob_sidecars( let blob_sidecar = BlobSidecar::new_with_existing_proof( index, blob_and_proof.blob, - block, - signed_block_header.clone(), - kzg_commitments_inclusion_proof, + header.clone(), blob_and_proof.proof, ) .map_err(FetchEngineBlobError::BlobSidecarError)?; diff --git a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs index b3deffa4d7..ef282a3eaa 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs @@ -3,12 +3,14 @@ use crate::fetch_blobs::fetch_blobs_beacon_adapter::MockFetchBlobsBeaconAdapter; use crate::fetch_blobs::{ EngineGetBlobsOutput, FetchEngineBlobError, fetch_and_process_engine_blobs_inner, }; +use crate::partial_data_column_assembler::PartialDataColumnAssembler; use crate::test_utils::{EphemeralHarnessType, get_kzg}; use bls::Signature; use eth2::types::BlobsBundle; use execution_layer::json_structures::{BlobAndProof, BlobAndProofV1, BlobAndProofV2}; use execution_layer::test_utils::generate_blobs; use maplit::hashset; +use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; use task_executor::test_utils::TestRuntime; use types::{ @@ -21,11 +23,11 @@ type T = EphemeralHarnessType; mod get_blobs_v2 { use super::*; - use types::ColumnIndex; + use types::{ColumnIndex, PartialDataColumnHeader}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_no_blobs_in_block() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, _s) = mock_publish_fn(); let block = SignedBeaconBlock::::Fulu(SignedBeaconBlockFulu { message: BeaconBlockFulu::empty(mock_adapter.spec()), @@ -41,7 +43,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - Arc::new(block), + Arc::new((&block).try_into().unwrap()), &custody_columns, publish_fn, ) @@ -53,7 +55,7 @@ mod get_blobs_v2 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_no_blobs_returned() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, _) = mock_publish_fn(); let (block, _blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -66,7 +68,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -78,7 +80,7 @@ mod get_blobs_v2 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_partial_blobs_returned() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, mut blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -94,7 +96,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -111,7 +113,7 @@ mod get_blobs_v2 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_block_imported_after_el_response() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -127,7 +129,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -144,7 +146,7 @@ mod get_blobs_v2 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_no_new_columns_to_import() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -166,7 +168,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -184,7 +186,7 @@ mod get_blobs_v2 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v2_success() { - let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu); + let mut mock_adapter = mock_beacon_adapter(ForkName::Fulu, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -208,7 +210,7 @@ mod get_blobs_v2 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -253,17 +255,19 @@ mod get_blobs_v1 { use super::*; use crate::block_verification_types::AsBlock; use std::collections::HashSet; - use types::ColumnIndex; + use types::{ColumnIndex, FullPayload, PartialDataColumnHeader}; const ELECTRA_FORK: ForkName = ForkName::Electra; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_no_blobs_in_block() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let spec = mock_adapter.spec(); let (publish_fn, _s) = mock_publish_fn(); - let block_no_blobs = - SignedBeaconBlock::from_block(BeaconBlock::empty(spec), Signature::empty()); + let block_no_blobs = SignedBeaconBlock::>::from_block( + BeaconBlock::empty(spec), + Signature::empty(), + ); let block_root = block_no_blobs.canonical_root(); // Expectations: engine fetch blobs should not be triggered @@ -274,7 +278,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - Arc::new(block_no_blobs), + Arc::new(PartialDataColumnHeader::try_from(&block_no_blobs).unwrap()), &custody_columns, publish_fn, ) @@ -287,7 +291,7 @@ mod get_blobs_v1 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_no_blobs_returned() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let (publish_fn, _) = mock_publish_fn(); let (block, _blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -301,7 +305,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -314,7 +318,7 @@ mod get_blobs_v1 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_partial_blobs_returned() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let blob_count = 2; let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, blob_count); @@ -347,7 +351,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -372,7 +376,7 @@ mod get_blobs_v1 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_block_imported_after_el_response() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -387,7 +391,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -405,7 +409,7 @@ mod get_blobs_v1 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_no_new_blobs_to_import() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, 2); let block_root = block.canonical_root(); @@ -435,7 +439,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -453,7 +457,7 @@ mod get_blobs_v1 { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fetch_blobs_v1_success() { - let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK); + let mut mock_adapter = mock_beacon_adapter(ELECTRA_FORK, false); let (publish_fn, publish_fn_args) = mock_publish_fn(); let blob_count = 2; let (block, blobs_and_proofs) = create_test_block_and_blobs(&mock_adapter, blob_count); @@ -479,7 +483,7 @@ mod get_blobs_v1 { let processing_status = fetch_and_process_engine_blobs_inner( mock_adapter, block_root, - block, + Arc::new(PartialDataColumnHeader::try_from(block.as_ref()).unwrap()), &custody_columns, publish_fn, ) @@ -606,10 +610,11 @@ fn mock_publish_fn() -> ( (publish_fn, captured_args) } -fn mock_beacon_adapter(fork_name: ForkName) -> MockFetchBlobsBeaconAdapter { +fn mock_beacon_adapter(fork_name: ForkName, get_blobs_v3: bool) -> MockFetchBlobsBeaconAdapter { let test_runtime = TestRuntime::default(); let spec = Arc::new(fork_name.make_genesis_spec(E::default_spec())); let kzg = get_kzg(&spec); + let partial_assembler = PartialDataColumnAssembler::new(NonZeroUsize::new(32).unwrap()); let mut mock_adapter = MockFetchBlobsBeaconAdapter::default(); mock_adapter.expect_spec().return_const(spec.clone()); @@ -618,4 +623,10 @@ fn mock_beacon_adapter(fork_name: ForkName) -> MockFetchBlobsBeaconAdapter { .expect_executor() .return_const(test_runtime.task_executor.clone()); mock_adapter + .expect_supports_get_blobs_v3() + .returning(move || Ok(get_blobs_v3)); + mock_adapter + .expect_partial_assembler() + .return_const(Some(Arc::new(partial_assembler))); + mock_adapter } diff --git a/beacon_node/beacon_chain/src/kzg_utils.rs b/beacon_node/beacon_chain/src/kzg_utils.rs index 10cb208729..9641aec47d 100644 --- a/beacon_node/beacon_chain/src/kzg_utils.rs +++ b/beacon_node/beacon_chain/src/kzg_utils.rs @@ -6,7 +6,10 @@ use ssz_types::{FixedVector, VariableList}; use std::sync::Arc; use tracing::instrument; use tree_hash::TreeHash; -use types::data::{Cell, DataColumn, DataColumnSidecarError}; +use types::data::{ + Cell, CellBitmap, ColumnIndex, DataColumn, DataColumnSidecarError, PartialDataColumn, + PartialDataColumnHeader, PartialDataColumnSidecarRef, +}; use types::kzg_ext::KzgCommitments; use types::{ Blob, BlobSidecar, BlobSidecarList, ChainSpec, DataColumnSidecar, DataColumnSidecarFulu, @@ -45,14 +48,13 @@ pub fn validate_blob( kzg.verify_blob_kzg_proof(kzg_blob, kzg_commitment, kzg_proof) } -/// Validate a batch of `DataColumnSidecar`. -pub fn validate_data_columns<'a, E: EthSpec, I>( +/// Validate a batch of full `DataColumnSidecar`s. +/// +/// Full columns have all cells present, so we iterate over all cells directly. +pub fn validate_full_data_columns<'a, E: EthSpec>( kzg: &Kzg, - data_column_iter: I, -) -> Result<(), (Option, KzgError)> -where - I: Iterator>> + Clone, -{ + data_column_iter: impl Iterator>>, +) -> Result<(), (Option, KzgError)> { let mut cells = Vec::new(); let mut proofs = Vec::new(); let mut column_indices = Vec::new(); @@ -109,6 +111,59 @@ where kzg.verify_cell_proof_batch(&cells, &proofs, column_indices, &commitments) } +/// Validate a batch of partial `VerifiablePartialDataColumn`s. +/// +/// Partial columns may have missing cells, indicated by a bitmap. We only verify present cells. +pub fn validate_partial_data_columns<'a, E: EthSpec>( + kzg: &Kzg, + data_column_iter: impl Iterator)>, + kzg_commitments: &[KzgCommitment], +) -> Result<(), (Option, KzgError)> { + let mut cells = Vec::new(); + let mut proofs = Vec::new(); + let mut column_indices = Vec::new(); + let mut commitments = Vec::new(); + + for (col_index, sidecar) in data_column_iter { + if sidecar.column.is_empty() { + return Err((Some(col_index), KzgError::KzgVerificationFailed)); + } + + // Partial columns have a bitmap indicating present cells + // We iterate over the bitmap and only process present cells + let mut present_iterator = sidecar.column.iter().zip(sidecar.kzg_proofs.iter()); + for (present, commitment) in sidecar.cells_present_bitmap.iter().zip(kzg_commitments) { + if present { + let (cell, proof) = present_iterator.next().ok_or(( + Some(col_index), + KzgError::InconsistentArrayLength( + "Partial column has fewer cells than bitmap indicates".to_string(), + ), + ))?; + cells.push(ssz_cell_to_crypto_cell::(cell).map_err(|e| (Some(col_index), e))?); + column_indices.push(col_index); + proofs.push(proof.0); + commitments.push(commitment.0); + } + } + + let expected_len = column_indices.len(); + + // We make this check at each iteration so that the error is attributable to a specific column + if cells.len() != expected_len + || proofs.len() != expected_len + || commitments.len() != expected_len + { + return Err(( + Some(col_index), + KzgError::InconsistentArrayLength("Invalid data column".to_string()), + )); + } + } + + kzg.verify_cell_proof_batch(&cells, &proofs, column_indices, &commitments) +} + /// Validate a batch of blob-commitment-proof triplets from multiple `BlobSidecars`. pub fn validate_blobs( kzg: &Kzg, @@ -241,6 +296,46 @@ pub fn blobs_to_data_column_sidecars( } } +/// Build data column sidecars from a signed beacon block and its blobs. +#[instrument(skip_all, level = "debug", fields(blob_count = blobs_and_proofs.len()))] +pub fn blobs_to_partial_data_columns( + blobs_and_proofs: Vec, &[KzgProof])>>, + header: &PartialDataColumnHeader, + kzg: &Kzg, + spec: &ChainSpec, +) -> Result>, DataColumnSidecarError> { + if blobs_and_proofs.is_empty() { + return Ok(vec![]); + } + + let blob_cells_and_proofs_vec = blobs_and_proofs + .into_par_iter() + .map(|maybe_blob_and_proofs| { + let Some((blob, proofs)) = maybe_blob_and_proofs else { + return Ok(None); + }; + + let blob = blob.as_ref().try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "blob should have a guaranteed size due to FixedVector: {e:?}" + )) + })?; + + kzg.compute_cells(blob).and_then(|cells| { + let proofs = proofs.try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "proof chunks should have exactly `number_of_columns` proofs: {e:?}" + )) + })?; + Ok(Some((cells, proofs))) + }) + }) + .collect::, KzgError>>()?; + + build_partial_data_columns(header, blob_cells_and_proofs_vec, spec) + .map_err(DataColumnSidecarError::BuildSidecarFailed) +} + pub fn compute_cells(blobs: &[&Blob], kzg: &Kzg) -> Result, KzgError> { let cells_vec = blobs .into_par_iter() @@ -330,7 +425,6 @@ pub(crate) fn build_data_column_sidecars_fulu( sidecars } - pub(crate) fn build_data_column_sidecars_gloas( beacon_block_root: Hash256, slot: Slot, @@ -396,6 +490,87 @@ pub(crate) fn build_data_column_sidecars_gloas( sidecars } +pub(crate) fn build_partial_data_columns( + header: &PartialDataColumnHeader, + blob_cells_and_proofs_vec: Vec>, + spec: &ChainSpec, +) -> Result>, String> { + let number_of_columns = E::number_of_columns(); + let max_blobs_per_block = + spec.max_blobs_per_block(header.slot().epoch(E::slots_per_epoch())) as usize; + let mut bitmap = + CellBitmap::::with_capacity(blob_cells_and_proofs_vec.len()).map_err(|_| { + format!( + "Exceeded max committment count: {} (got {})", + E::max_blob_commitments_per_block(), + blob_cells_and_proofs_vec.len() + ) + })?; + let mut columns = vec![Vec::with_capacity(max_blobs_per_block); number_of_columns]; + let mut column_kzg_proofs = vec![Vec::with_capacity(max_blobs_per_block); number_of_columns]; + + for (idx, maybe_cells_and_proofs) in blob_cells_and_proofs_vec.into_iter().enumerate() { + let Some((blob_cells, blob_cell_proofs)) = maybe_cells_and_proofs else { + continue; + }; + + bitmap + .set(idx, true) + .expect("bitmap constructed from iterator length above"); + + // we iterate over each column, and we construct the column from "top to bottom", + // pushing on the cell and the corresponding proof at each column index. we do this for + // each blob (i.e. the outer loop). + for col in 0..number_of_columns { + let cell = blob_cells + .get(col) + .ok_or(format!("Missing blob cell at index {col}"))?; + let cell: Vec = cell.to_vec(); + let cell = + Cell::::try_from(cell).map_err(|e| format!("BytesPerCell exceeded: {e:?}"))?; + + let proof = blob_cell_proofs + .get(col) + .ok_or(format!("Missing blob cell KZG proof at index {col}"))?; + + let column = columns + .get_mut(col) + .ok_or(format!("Missing data column at index {col}"))?; + let column_proofs = column_kzg_proofs + .get_mut(col) + .ok_or(format!("Missing data column proofs at index {col}"))?; + + column.push(cell); + column_proofs.push(*proof); + } + } + + let block_root = header.signed_block_header.message.canonical_root(); + + let sidecars: Result>, String> = columns + .into_iter() + .zip(column_kzg_proofs) + .enumerate() + .map(|(index, (col, proofs))| { + let column = PartialDataColumn { + block_root, + index: index as u64, + sidecar: types::data::PartialDataColumnSidecar { + cells_present_bitmap: bitmap.clone(), + column: VariableList::try_from(col) + .map_err(|e| format!("MaxBlobCommitmentsPerBlock exceeded: {e:?}"))?, + kzg_proofs: VariableList::try_from(proofs) + .map_err(|e| format!("MaxBlobCommitmentsPerBlock exceeded: {e:?}"))?, + header: None.into(), + }, + }; + Ok(column) + }) + .collect(); + + sidecars +} + // TODO(gloas) blob reconstruction will fail post gloas. We should just return `Blob`s // instead of a `BlobSidecar`. This might require a beacon api spec change as well. /// Reconstruct blobs from a subset of data column sidecars (requires at least 50%). @@ -473,21 +648,9 @@ pub fn reconstruct_blobs( let blob = Blob::::new(blob_bytes).map_err(|e| format!("{e:?}"))?; let kzg_proof = KzgProof::empty(); - BlobSidecar::::new_with_existing_proof( - row_index, - blob, - signed_block, - first_data_column - .signed_block_header() - .map_err(|e| format!("{e:?}"))? - .clone(), - first_data_column - .kzg_commitments_inclusion_proof() - .map_err(|e| format!("{e:?}"))?, - kzg_proof, - ) - .map(Arc::new) - .map_err(|e| format!("{e:?}")) + BlobSidecar::::new_with_existing_proof(row_index, blob, signed_block, kzg_proof) + .map(Arc::new) + .map_err(|e| format!("{e:?}")) }) .collect::, _>>()?; @@ -566,7 +729,7 @@ pub fn reconstruct_data_columns( mod test { use crate::kzg_utils::{ blobs_to_data_column_sidecars, reconstruct_blobs, reconstruct_data_columns, - validate_data_columns, + validate_full_data_columns, }; use bls::Signature; use eth2::types::BlobsBundle; @@ -605,7 +768,7 @@ mod test { blobs_to_data_column_sidecars(&blob_refs, proofs.to_vec(), &signed_block, kzg, spec) .unwrap(); - let result = validate_data_columns::(kzg, column_sidecars.iter()); + let result = validate_full_data_columns(kzg, column_sidecars.iter()); assert!(result.is_ok()); } diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index a8a706d8bc..7631e6b904 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -43,6 +43,7 @@ pub mod observed_block_producers; pub mod observed_data_sidecars; pub mod observed_operations; mod observed_slashable; +pub mod partial_data_column_assembler; pub mod payload_bid_verification; pub mod payload_envelope_streamer; pub mod payload_envelope_verification; diff --git a/beacon_node/beacon_chain/src/metrics.rs b/beacon_node/beacon_chain/src/metrics.rs index 5485f0a9e3..ce136ef3fc 100644 --- a/beacon_node/beacon_chain/src/metrics.rs +++ b/beacon_node/beacon_chain/src/metrics.rs @@ -1686,6 +1686,56 @@ pub static DATA_COLUMN_SIDECAR_GOSSIP_VERIFICATION_TIMES: LazyLock> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_partial_data_column_sidecar_header_processing_requests_total", + "Count of all partial data column sidecars submitted for processing", + ) + }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_DUPES: LazyLock> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_partial_data_column_sidecar_header_processing_dupes_total", + "Number of partial data column sidecars verified for gossip (excluding dupes)", + ) + }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_HEADER_PROCESSING_SUCCESSES: LazyLock> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_partial_data_column_sidecar_header_processing_successes_total", + "Number of partial data column sidecar headers verified for gossip (excluding dupes)", + ) + }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_HEADER_GOSSIP_VERIFICATION_TIMES: LazyLock< + Result, +> = LazyLock::new(|| { + try_create_histogram( + "beacon_partial_data_column_sidecar_header_gossip_verification_seconds", + "Full runtime of partial data column sidecar headers gossip verification", + ) +}); +pub static PARTIAL_DATA_COLUMN_SIDECAR_PROCESSING_REQUESTS: LazyLock> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_partial_data_column_sidecar_processing_requests_total", + "Count of all partial data column sidecars submitted for processing", + ) + }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_PROCESSING_SUCCESSES: LazyLock> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_partial_data_column_sidecar_processing_successes_total", + "Number of partial data column sidecars verified for gossip", + ) + }); +pub static PARTIAL_DATA_COLUMN_SIDECAR_GOSSIP_VERIFICATION_TIMES: LazyLock> = + LazyLock::new(|| { + try_create_histogram( + "beacon_partial_data_column_sidecar_gossip_verification_seconds", + "Full runtime of partial data column sidecars gossip verification", + ) + }); pub static BLOBS_FROM_EL_HIT_TOTAL: LazyLock> = LazyLock::new(|| { try_create_int_counter( @@ -1755,6 +1805,70 @@ pub static BEACON_ENGINE_GET_BLOBS_V2_REQUEST_DURATION_SECONDS: LazyLock> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_engine_getBlobsV3_requests_total", + "Total number of engine_getBlobsV3 requests made to the execution layer", + ) + }); + +pub static BEACON_ENGINE_GET_BLOBS_V3_COMPLETE_RESPONSES_TOTAL: LazyLock> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_engine_getBlobsV3_complete_responses_total", + "Total number of successful engine_getBlobsV3 responses from the execution layer \ + with all blobs", + ) + }); + +pub static BEACON_ENGINE_GET_BLOBS_V3_PARTIAL_RESPONSES_TOTAL: LazyLock> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_engine_getBlobsV3_partial_responses_total", + "Total number of successful engine_getBlobsV3 responses from the execution layer \ + with at least one blob missing", + ) + }); + +pub static BEACON_ENGINE_GET_BLOBS_V3_REQUEST_DURATION_SECONDS: LazyLock> = + LazyLock::new(|| { + try_create_histogram( + "beacon_engine_getBlobsV3_request_duration_seconds", + "Duration of engine_getBlobsV3 requests to the execution layer in seconds", + ) + }); + +/* + * Standardized metrics for partial column efficiency + */ +pub static BEACON_PARTIAL_MESSAGE_USEFUL_CELLS_TOTAL: LazyLock> = + LazyLock::new(|| { + try_create_int_counter_vec( + "beacon_partial_message_useful_cells_total", + "Number of useful cells received via a partial message", + &["column_index"], + ) + }); + +pub static BEACON_PARTIAL_MESSAGE_CELLS_RECEIVED_TOTAL: LazyLock> = + LazyLock::new(|| { + try_create_int_counter_vec( + "beacon_partial_message_cells_received_total", + "Number of total cells received via a partial message", + &["column_index"], + ) + }); + +pub static BEACON_PARTIAL_MESSAGE_COLUMN_COMPLETIONS_TOTAL: LazyLock> = + LazyLock::new(|| { + try_create_int_counter_vec( + "beacon_partial_message_column_completions_total", + "How often the partial message first completed the column", + &["column_index"], + ) + }); + /* * Light server message verification */ diff --git a/beacon_node/beacon_chain/src/observed_data_sidecars.rs b/beacon_node/beacon_chain/src/observed_data_sidecars.rs index 894b8d3444..2461c8115d 100644 --- a/beacon_node/beacon_chain/src/observed_data_sidecars.rs +++ b/beacon_node/beacon_chain/src/observed_data_sidecars.rs @@ -6,7 +6,9 @@ use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; use std::sync::Arc; -use types::{BlobSidecar, ChainSpec, DataColumnSidecar, EthSpec, Hash256, SignedBeaconBlock, Slot}; +use types::{ + BlobSidecar, ChainSpec, DataColumnSidecar, EthSpec, Hash256, PartialDataColumnHeader, Slot, +}; type ValidatorIndex = u64; type BeaconBlockRoot = Hash256; @@ -102,17 +104,17 @@ impl ObservationKey { } } - pub fn from_block( - block: &SignedBeaconBlock, + pub fn from_partial_column_header( + header: &PartialDataColumnHeader, block_root: Hash256, spec: &ChainSpec, ) -> Self { - let slot = block.slot(); + let slot = header.slot(); if spec.fork_name_at_slot::(slot).gloas_enabled() { Self::new_block_root_key(block_root, slot) } else { - Self::new_proposer_key(block.message().proposer_index(), slot) + Self::new_proposer_key(header.signed_block_header.message.proposer_index, slot) } } diff --git a/beacon_node/beacon_chain/src/partial_data_column_assembler.rs b/beacon_node/beacon_chain/src/partial_data_column_assembler.rs new file mode 100644 index 0000000000..0ce754c8a0 --- /dev/null +++ b/beacon_node/beacon_chain/src/partial_data_column_assembler.rs @@ -0,0 +1,569 @@ +use crate::data_column_verification::{ + KzgVerifiedCustodyDataColumn, KzgVerifiedCustodyPartialDataColumn, +}; +use lru::LruCache; +use parking_lot::RwLock; +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::sync::Arc; +use tracing::error; +use types::core::{Epoch, EthSpec, Hash256}; +use types::data::{ColumnIndex, PartialDataColumnHeader}; + +/// Assembles partial data columns into complete columns +pub struct PartialDataColumnAssembler { + /// Cache of assemblies keyed by block root + assemblies: RwLock>>, +} + +/// Tracks partial columns being assembled for a single block +struct PartialAssembly { + header: Arc>, + has_local_blobs: bool, + /// Map of column_index -> partial column being assembled + columns: HashMap>, +} + +#[derive(Clone, Debug)] +pub enum AssemblyColumn { + // As the actual column is Arc'd inside, storing it redundantly here will not increase memory usage. + Complete(KzgVerifiedCustodyDataColumn), + Incomplete(KzgVerifiedCustodyPartialDataColumn), +} + +/// Result of merging a partial column +pub struct PartialMergeResult { + /// How many cells were added to the store + pub added_cells: usize, + /// Have local blobs been added yet + pub local_blobs: bool, + /// Merge that completed the column + pub full_columns: Vec>, + /// The updated partials for publishing + pub updated_partials: Vec>, +} + +impl PartialDataColumnAssembler { + pub fn new(capacity: NonZeroUsize) -> Self { + Self { + assemblies: RwLock::new(LruCache::new(capacity)), + } + } + + /// Insert a `header` for the given `block_root` into the assembler. + /// Returns true unless there already is a header for the block root. + pub fn init(&self, block_root: Hash256, header: Arc>) -> bool { + let mut assemblies = self.assemblies.write(); + + if assemblies.contains(&block_root) { + return false; + } + + let assembly = PartialAssembly { + header, + has_local_blobs: false, + columns: HashMap::new(), + }; + + assemblies.put(block_root, assembly); + + true + } + + /// Merge one or more received partial columns into the assembly. + /// Returns the merge result indicating if the columns are now complete. + pub fn merge_partials( + &self, + block_root: Hash256, + partials: Vec>, + header: Arc>, + ) -> Option> { + let mut assemblies = self.assemblies.write(); + let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { + header: header.clone(), + has_local_blobs: false, + columns: HashMap::new(), + }); + + let mut full_columns = Vec::new(); + let mut updated_partials = Vec::new(); + let mut added_cells = 0; + + for partial in partials { + let partial_column = partial.as_data_column(); + let column_index = partial_column.index; + + let merged = if let Some(existing) = assembly.columns.get(&column_index) { + let AssemblyColumn::Incomplete(existing) = existing else { + // Already complete. + continue; + }; + let column = existing.as_data_column(); + + let old_len = column.sidecar.column.len(); + + // Merge with existing partial + let merged = match existing.merge(&partial) { + Ok(merged) => merged, + Err(err) => { + error!("Unexpected error merging partial data column: {:?}", err); + continue; + } + }; + + let adding_cells = merged + .as_data_column() + .sidecar + .column + .len() + .saturating_sub(old_len); + + added_cells += adding_cells; + + if adding_cells == 0 { + continue; + } + + merged + } else { + added_cells += partial_column.sidecar.column.len(); + // First time seeing this column index for this block + partial + }; + + // Check if merged column is now complete by trying to convert into full + let column = if let Some(full_column) = merged.try_clone_full(&header) { + full_columns.push(full_column.clone()); + AssemblyColumn::Complete(full_column) + } else { + AssemblyColumn::Incomplete(merged.clone()) + }; + + // Update assembly with merged partial + assembly.columns.insert(column_index, column); + updated_partials.push(merged); + } + + Some(PartialMergeResult { + added_cells, + local_blobs: assembly.has_local_blobs, + full_columns, + updated_partials, + }) + } + + /// Mark a column as assembled. Returns true if the column was previously incomplete or not + /// in the assembly at all. + pub fn mark_as_complete( + &self, + block_root: Hash256, + column: &KzgVerifiedCustodyDataColumn, + ) -> bool { + // TODO(gloas): support partial messages + let Ok(fulu) = column.as_data_column().as_fulu() else { + return false; + }; + + let mut assemblies = self.assemblies.write(); + let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { + header: Arc::new(PartialDataColumnHeader { + kzg_commitments: fulu.kzg_commitments.clone(), + signed_block_header: fulu.signed_block_header.clone(), + kzg_commitments_inclusion_proof: fulu.kzg_commitments_inclusion_proof.clone(), + }), + has_local_blobs: false, + columns: Default::default(), + }); + let prev = assembly + .columns + .insert(column.index(), AssemblyColumn::Complete(column.clone())); + !matches!(prev, Some(AssemblyColumn::Complete(_))) + } + + /// Returns true if the given column is complete. + pub fn is_complete(&self, block_root: Hash256, column_index: ColumnIndex) -> bool { + self.assemblies.read().peek(&block_root).is_some_and(|a| { + matches!( + a.columns.get(&column_index), + Some(AssemblyColumn::Complete(_)) + ) + }) + } + + /// Get the current partial for a specific column if it exists in assembly + pub fn get_partial( + &self, + block_root: &Hash256, + column_index: ColumnIndex, + ) -> Option> { + self.assemblies + .read() + .peek(block_root)? + .columns + .get(&column_index) + .cloned() + } + + /// Get all current partials for a block for publishing after fetching local blobs. + /// To unlock future publishing, mark blobs as fetched locally. + /// We do this within one write lock to avoid useless double publishes. + pub fn get_partials_and_mark_as_local_fetched( + &self, + block_root: Hash256, + header: &Arc>, + ) -> Vec> { + let mut assemblies = self.assemblies.write(); + let assembly = assemblies.get_or_insert_mut(block_root, || PartialAssembly { + header: header.clone(), + has_local_blobs: true, + columns: Default::default(), + }); + + assembly.has_local_blobs = true; + + assembly + .columns + .values() + .filter_map(|value| { + if let AssemblyColumn::Incomplete(partial) = value { + Some(partial.clone()) + } else { + None + } + }) + .collect() + } + + /// Get header for a block if we have an active assembly + pub fn get_header(&self, block_root: &Hash256) -> Option>> { + self.assemblies + .read() + .peek(block_root) + .map(|a| a.header.clone()) + } + + /// Maintenance: remove assemblies older than cutoff epoch + pub fn do_maintenance(&self, cutoff_epoch: Epoch) { + let mut assemblies = self.assemblies.write(); + let mut to_remove = vec![]; + + for (root, assembly) in assemblies.iter() { + if assembly + .header + .signed_block_header + .message + .slot + .epoch(E::slots_per_epoch()) + < cutoff_epoch + { + to_remove.push(*root); + } + } + + for root in to_remove { + assemblies.pop(&root); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_column_verification::{ + KzgVerifiedCustodyPartialDataColumn, KzgVerifiedDataColumn, KzgVerifiedPartialDataColumn, + }; + use bls::{FixedBytesExtended, Signature}; + use kzg::{KzgCommitment, KzgProof}; + use ssz_types::{FixedVector, VariableList}; + use types::block::{BeaconBlockHeader, SignedBeaconBlockHeader}; + use types::core::{EthSpec, Hash256, MinimalEthSpec, Slot}; + use types::data::{ + Cell, CellBitmap, DataColumnSidecar, DataColumnSidecarFulu, PartialDataColumn, + PartialDataColumnSidecar, + }; + + type E = MinimalEthSpec; + + fn make_cell(marker: u8) -> Cell { + let mut cell = Cell::::default(); + cell[0] = marker; + cell + } + + fn make_header(num_commitments: usize) -> PartialDataColumnHeader { + PartialDataColumnHeader { + kzg_commitments: vec![KzgCommitment([0u8; 48]); num_commitments] + .try_into() + .unwrap(), + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot: Slot::new(1), + proposer_index: 0, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), + }, + kzg_commitments_inclusion_proof: FixedVector::new( + vec![Hash256::zero(); E::kzg_commitments_inclusion_proof_depth()], + ) + .unwrap(), + } + } + + fn make_partial( + block_root: Hash256, + column_index: ColumnIndex, + total_blobs: usize, + present_indices: &[usize], + ) -> KzgVerifiedCustodyPartialDataColumn { + make_partial_with_header(block_root, column_index, total_blobs, present_indices, true) + } + + fn make_partial_with_header( + block_root: Hash256, + column_index: ColumnIndex, + total_blobs: usize, + present_indices: &[usize], + include_header: bool, + ) -> KzgVerifiedCustodyPartialDataColumn { + let mut bitmap = CellBitmap::::with_capacity(total_blobs).unwrap(); + for &idx in present_indices { + bitmap.set(idx, true).unwrap(); + } + + let column: VariableList<_, _> = present_indices + .iter() + .map(|&idx| make_cell(idx as u8)) + .collect::>() + .try_into() + .unwrap(); + let proofs: VariableList<_, _> = present_indices + .iter() + .map(|_| KzgProof::empty()) + .collect::>() + .try_into() + .unwrap(); + + let header = include_header.then(|| make_header(total_blobs)).into(); + + let partial = PartialDataColumn { + block_root, + index: column_index, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column, + kzg_proofs: proofs, + header, + }, + }; + KzgVerifiedCustodyPartialDataColumn::from_asserted_custody( + KzgVerifiedPartialDataColumn::__new_for_testing(Arc::new(partial)), + ) + } + + fn make_full_column(fulu: DataColumnSidecarFulu) -> KzgVerifiedCustodyDataColumn { + KzgVerifiedCustodyDataColumn::from_asserted_custody( + KzgVerifiedDataColumn::__new_for_testing(Arc::new(DataColumnSidecar::Fulu(fulu))), + ) + } + + fn make_assembler() -> PartialDataColumnAssembler { + PartialDataColumnAssembler::new(NonZeroUsize::new(16).unwrap()) + } + + // -- init and get_header tests -- + + #[test] + fn init_stores_header() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = make_header(4); + assert!(assembler.init(root, Arc::new(header.clone()))); + let retrieved = assembler.get_header(&root).unwrap(); + assert_eq!(*retrieved, header); + } + + #[test] + fn init_returns_false_if_already_exists() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + assert!(assembler.init(root, header.clone())); + assert!(!assembler.init(root, header)); + } + + // -- merge_partials tests -- + + #[test] + fn merge_partials_tracks_added_cells() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + + let partial = make_partial(root, 0, 4, &[0, 1, 2]); + let result = assembler + .merge_partials(root, vec![partial], header.clone()) + .unwrap(); + assert_eq!(result.added_cells, 3); + + // Merge more cells for the same column + let partial2 = make_partial(root, 0, 4, &[2, 3]); + let result2 = assembler + .merge_partials(root, vec![partial2], header) + .unwrap(); + // Only cell 3 is new (cell 2 was already present) + assert_eq!(result2.added_cells, 1); + } + + #[test] + fn merge_partials_ignores_already_complete_column() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + + // Complete the column + let partial = make_partial(root, 0, 4, &[0, 1, 2, 3]); + let result = assembler + .merge_partials(root, vec![partial], header.clone()) + .unwrap(); + assert_eq!(result.added_cells, 4); + assert_eq!(result.full_columns.len(), 1); + + // Try to merge more — should be ignored + let partial2 = make_partial(root, 0, 4, &[0, 1]); + let result2 = assembler + .merge_partials(root, vec![partial2], header) + .unwrap(); + assert_eq!(result2.added_cells, 0); + assert!(result2.full_columns.is_empty()); + } + + #[test] + fn merge_partials_completes_column_progressively() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + + let partial1 = make_partial(root, 0, 4, &[0, 1]); + let result1 = assembler + .merge_partials(root, vec![partial1], header.clone()) + .unwrap(); + assert!(result1.full_columns.is_empty()); + + let partial2 = make_partial(root, 0, 4, &[2, 3]); + let result2 = assembler + .merge_partials(root, vec![partial2], header) + .unwrap(); + assert_eq!(result2.full_columns.len(), 1); + } + + #[test] + fn merge_partials_returns_updated_partials() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + + let partial = make_partial(root, 0, 4, &[0, 2]); + let result = assembler + .merge_partials(root, vec![partial], header) + .unwrap(); + assert_eq!(result.updated_partials.len(), 1); + assert_eq!(result.updated_partials[0].index(), 0); + } + + // -- mark_as_complete tests -- + + #[test] + fn mark_as_complete_replaces_incomplete() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + let header = Arc::new(make_header(4)); + + // Merge an incomplete partial first + let partial = make_partial(root, 0, 4, &[0, 1]); + assembler.merge_partials(root, vec![partial], header); + + let full_column = make_full_column(DataColumnSidecarFulu:: { + index: 0, + column: vec![Cell::::default(); 4].try_into().unwrap(), + kzg_commitments: vec![KzgCommitment([0u8; 48]); 4].try_into().unwrap(), + kzg_proofs: vec![KzgProof::empty(); 4].try_into().unwrap(), + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot: Slot::new(1), + proposer_index: 0, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), + }, + kzg_commitments_inclusion_proof: FixedVector::new( + vec![Hash256::zero(); E::kzg_commitments_inclusion_proof_depth()], + ) + .unwrap(), + }); + assert!(assembler.mark_as_complete(root, &full_column)); + } + + #[test] + fn mark_as_complete_returns_false_if_already_complete() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + + let full_column = make_full_column(DataColumnSidecarFulu:: { + index: 0, + column: vec![Cell::::default(); 4].try_into().unwrap(), + kzg_commitments: vec![KzgCommitment([0u8; 48]); 4].try_into().unwrap(), + kzg_proofs: vec![KzgProof::empty(); 4].try_into().unwrap(), + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot: Slot::new(1), + proposer_index: 0, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), + }, + kzg_commitments_inclusion_proof: FixedVector::new( + vec![Hash256::zero(); E::kzg_commitments_inclusion_proof_depth()], + ) + .unwrap(), + }); + assert!(assembler.mark_as_complete(root, &full_column)); + assert!(!assembler.mark_as_complete(root, &full_column)); + } + + // -- do_maintenance tests -- + + #[test] + fn do_maintenance_removes_old_assemblies() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + // Header at slot 0 → epoch 0 + let header = Arc::new(make_header(4)); + assembler.init(root, header); + assert!(assembler.get_header(&root).is_some()); + + // Cutoff epoch 1 removes epoch 0 + assembler.do_maintenance(Epoch::new(1)); + assert!(assembler.get_header(&root).is_none()); + } + + #[test] + fn do_maintenance_keeps_recent_assemblies() { + let assembler = make_assembler(); + let root = Hash256::repeat_byte(1); + // Header at slot 100 → epoch 100/8 = 12 for MinimalEthSpec (8 slots/epoch) + let mut header = make_header(4); + header.signed_block_header.message.slot = Slot::new(100); + let header = Arc::new(header); + assembler.init(root, header); + + assembler.do_maintenance(Epoch::new(1)); + assert!(assembler.get_header(&root).is_some()); + } +} diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 00a2ed64f1..e628a81459 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -239,6 +239,7 @@ pub fn test_da_checker( kzg, custody_context, spec, + true, ) .expect("should initialise data availability checker") } diff --git a/beacon_node/beacon_processor/src/lib.rs b/beacon_node/beacon_processor/src/lib.rs index a6c76beb31..ea87e9bc71 100644 --- a/beacon_node/beacon_processor/src/lib.rs +++ b/beacon_node/beacon_processor/src/lib.rs @@ -392,6 +392,7 @@ pub enum Work { GossipBlock(AsyncFn), GossipBlobSidecar(AsyncFn), GossipDataColumnSidecar(AsyncFn), + GossipPartialDataColumnSidecar(AsyncFn), DelayedImportBlock { beacon_block_slot: Slot, beacon_block_root: Hash256, @@ -470,6 +471,7 @@ pub enum WorkType { GossipBlock, GossipBlobSidecar, GossipDataColumnSidecar, + GossipPartialDataColumnSidecar, DelayedImportBlock, DelayedImportEnvelope, GossipVoluntaryExit, @@ -524,6 +526,7 @@ impl Work { Work::GossipBlock(_) => WorkType::GossipBlock, Work::GossipBlobSidecar(_) => WorkType::GossipBlobSidecar, Work::GossipDataColumnSidecar(_) => WorkType::GossipDataColumnSidecar, + Work::GossipPartialDataColumnSidecar(_) => WorkType::GossipPartialDataColumnSidecar, Work::DelayedImportBlock { .. } => WorkType::DelayedImportBlock, Work::DelayedImportEnvelope { .. } => WorkType::DelayedImportEnvelope, Work::GossipVoluntaryExit(_) => WorkType::GossipVoluntaryExit, @@ -836,6 +839,10 @@ impl BeaconProcessor { Some(item) } else if let Some(item) = work_queues.gossip_data_column_queue.pop() { Some(item) + } else if let Some(item) = + work_queues.gossip_partial_data_column_queue.pop() + { + Some(item) } else if let Some(item) = work_queues.column_reconstruction_queue.pop() { Some(item) // Check the priority 0 API requests after blocks and blobs, but before attestations. @@ -1146,6 +1153,9 @@ impl BeaconProcessor { Work::GossipDataColumnSidecar { .. } => { work_queues.gossip_data_column_queue.push(work, work_id) } + Work::GossipPartialDataColumnSidecar { .. } => work_queues + .gossip_partial_data_column_queue + .push(work, work_id), Work::DelayedImportBlock { .. } => { work_queues.delayed_block_queue.push(work, work_id) } @@ -1284,6 +1294,9 @@ impl BeaconProcessor { WorkType::GossipDataColumnSidecar => { work_queues.gossip_data_column_queue.len() } + WorkType::GossipPartialDataColumnSidecar => { + work_queues.gossip_partial_data_column_queue.len() + } WorkType::DelayedImportBlock => work_queues.delayed_block_queue.len(), WorkType::DelayedImportEnvelope => work_queues.delayed_envelope_queue.len(), WorkType::GossipVoluntaryExit => { @@ -1506,6 +1519,7 @@ impl BeaconProcessor { Work::GossipBlock(work) | Work::GossipBlobSidecar(work) | Work::GossipDataColumnSidecar(work) + | Work::GossipPartialDataColumnSidecar(work) | Work::GossipExecutionPayload(work) => task_spawner.spawn_async(async move { work.await; }), diff --git a/beacon_node/beacon_processor/src/scheduler/work_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_queue.rs index 363ec06097..f7163d538b 100644 --- a/beacon_node/beacon_processor/src/scheduler/work_queue.rs +++ b/beacon_node/beacon_processor/src/scheduler/work_queue.rs @@ -126,6 +126,7 @@ pub struct BeaconProcessorQueueLengths { gossip_block_queue: usize, gossip_blob_queue: usize, gossip_data_column_queue: usize, + gossip_partial_data_column_queue: usize, delayed_block_queue: usize, delayed_envelope_queue: usize, status_queue: usize, @@ -199,6 +200,7 @@ impl BeaconProcessorQueueLengths { gossip_block_queue: 1024, gossip_blob_queue: 1024, gossip_data_column_queue: 1024, + gossip_partial_data_column_queue: 1024, delayed_block_queue: 1024, delayed_envelope_queue: 1024, status_queue: 1024, @@ -255,6 +257,7 @@ pub struct WorkQueues { pub gossip_block_queue: FifoQueue>, pub gossip_blob_queue: FifoQueue>, pub gossip_data_column_queue: FifoQueue>, + pub gossip_partial_data_column_queue: FifoQueue>, pub delayed_block_queue: FifoQueue>, pub delayed_envelope_queue: FifoQueue>, pub status_queue: FifoQueue>, @@ -323,6 +326,8 @@ impl WorkQueues { let gossip_block_queue = FifoQueue::new(queue_lengths.gossip_block_queue); let gossip_blob_queue = FifoQueue::new(queue_lengths.gossip_blob_queue); let gossip_data_column_queue = FifoQueue::new(queue_lengths.gossip_data_column_queue); + let gossip_partial_data_column_queue = + FifoQueue::new(queue_lengths.gossip_partial_data_column_queue); let delayed_block_queue = FifoQueue::new(queue_lengths.delayed_block_queue); let delayed_envelope_queue = FifoQueue::new(queue_lengths.delayed_envelope_queue); @@ -388,6 +393,7 @@ impl WorkQueues { gossip_block_queue, gossip_blob_queue, gossip_data_column_queue, + gossip_partial_data_column_queue, delayed_block_queue, delayed_envelope_queue, status_queue, diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index 6566616c04..acf5f2778b 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -596,6 +596,7 @@ pub struct EngineCapabilities { pub get_client_version_v1: bool, pub get_blobs_v1: bool, pub get_blobs_v2: bool, + pub get_blobs_v3: bool, } impl EngineCapabilities { diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index b9f6289d05..110e155c77 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -64,6 +64,7 @@ pub const ENGINE_GET_CLIENT_VERSION_TIMEOUT: Duration = Duration::from_secs(1); pub const ENGINE_GET_BLOBS_V1: &str = "engine_getBlobsV1"; pub const ENGINE_GET_BLOBS_V2: &str = "engine_getBlobsV2"; +pub const ENGINE_GET_BLOBS_V3: &str = "engine_getBlobsV3"; pub const ENGINE_GET_BLOBS_TIMEOUT: Duration = Duration::from_secs(1); /// This error is returned during a `chainId` call by Geth. @@ -743,6 +744,20 @@ impl HttpJsonRpc { .await } + pub async fn get_blobs_v3( + &self, + versioned_hashes: Vec, + ) -> Result>>, Error> { + let params = json!([versioned_hashes]); + + self.rpc_request( + ENGINE_GET_BLOBS_V3, + params, + ENGINE_GET_BLOBS_TIMEOUT * self.execution_timeout_multiplier, + ) + .await + } + pub async fn get_block_by_number( &self, query: BlockByNumberQuery<'_>, @@ -1258,6 +1273,7 @@ impl HttpJsonRpc { get_client_version_v1: capabilities.contains(ENGINE_GET_CLIENT_VERSION_V1), get_blobs_v1: capabilities.contains(ENGINE_GET_BLOBS_V1), get_blobs_v2: capabilities.contains(ENGINE_GET_BLOBS_V2), + get_blobs_v3: capabilities.contains(ENGINE_GET_BLOBS_V3), }) } diff --git a/beacon_node/execution_layer/src/engine_api/json_structures.rs b/beacon_node/execution_layer/src/engine_api/json_structures.rs index a77861981f..cfff0b4d9f 100644 --- a/beacon_node/execution_layer/src/engine_api/json_structures.rs +++ b/beacon_node/execution_layer/src/engine_api/json_structures.rs @@ -864,6 +864,9 @@ pub struct BlobAndProof { pub proofs: KzgProofs, } +/// A BlobAndProofV3 is just a BlobAndProofV2 that may also be `null` if unknown by the EL. +pub type BlobAndProofV3 = Option>; + #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct JsonForkchoiceStateV1 { diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 90968fa213..4e4fe20e14 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -4,7 +4,7 @@ //! This crate only provides useful functionality for "The Merge", it does not provide any of the //! deposit-contract functionality that the `beacon_node/eth1` crate already provides. -use crate::json_structures::{BlobAndProofV1, BlobAndProofV2}; +use crate::json_structures::{BlobAndProofV1, BlobAndProofV2, BlobAndProofV3}; use crate::payload_cache::PayloadCache; use arc_swap::ArcSwapOption; use auth::{Auth, JwtKey, strip_prefix}; @@ -1741,6 +1741,23 @@ impl ExecutionLayer { } } + pub async fn get_blobs_v3( + &self, + query: Vec, + ) -> Result>>, Error> { + let capabilities = self.get_engine_capabilities(None).await?; + + if capabilities.get_blobs_v3 { + self.engine() + .request(|engine| async move { engine.api.get_blobs_v3(query).await }) + .await + .map_err(Box::new) + .map_err(Error::EngineError) + } else { + Err(Error::GetBlobsNotSupported) + } + } + pub async fn get_block_by_number( &self, query: BlockByNumberQuery<'_>, diff --git a/beacon_node/execution_layer/src/test_utils/mod.rs b/beacon_node/execution_layer/src/test_utils/mod.rs index c382d8abf5..4eb03778f8 100644 --- a/beacon_node/execution_layer/src/test_utils/mod.rs +++ b/beacon_node/execution_layer/src/test_utils/mod.rs @@ -59,6 +59,7 @@ pub const DEFAULT_ENGINE_CAPABILITIES: EngineCapabilities = EngineCapabilities { get_client_version_v1: true, get_blobs_v1: true, get_blobs_v2: true, + get_blobs_v3: true, }; pub static DEFAULT_CLIENT_VERSION: LazyLock = diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index 340b0bbbed..6b65995a73 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -16,6 +16,7 @@ use eth2::types::{ use execution_layer::{ProvenancedPayload, SubmitBlindedBlockResponse}; use futures::TryFutureExt; use lighthouse_network::PubsubMessage; +use logging::crit; use network::NetworkMessage; use rand::prelude::SliceRandom; use reqwest::StatusCode; @@ -29,8 +30,9 @@ use tracing::{Span, debug, debug_span, error, field, info, instrument, warn}; use tree_hash::TreeHash; use types::{ AbstractExecPayload, BeaconBlockRef, BlobSidecar, BlobsList, BlockImportSource, - DataColumnSubnetId, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, FullPayload, - FullPayloadBellatrix, Hash256, KzgProofs, SignedBeaconBlock, SignedBlindedBeaconBlock, + DataColumnSidecar, DataColumnSubnetId, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, + FullPayload, FullPayloadBellatrix, Hash256, KzgProofs, SignedBeaconBlock, + SignedBlindedBeaconBlock, }; use warp::{Rejection, Reply, reply::Response}; @@ -514,15 +516,53 @@ fn publish_column_sidecars( .collect::>(); debug!(indices = ?dropped_indices, "Dropping data columns from publishing"); } - let pubsub_messages = data_column_sidecars - .into_iter() - .map(|data_col| { - let subnet = DataColumnSubnetId::from_column_index(*data_col.index(), &chain.spec); - PubsubMessage::DataColumnSidecar(Box::new((subnet, data_col))) - }) - .collect::>(); - crate::utils::publish_pubsub_messages(sender_clone, pubsub_messages) - .map_err(|_| BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish))) + let mut full_messages = Vec::new(); + let mut partial_columns = Vec::new(); + let mut partial_header = None; + + for data_col in data_column_sidecars { + if chain.config.enable_partial_columns + && let DataColumnSidecar::Fulu(fulu_data_col) = data_col.as_ref() + { + let mut partial = fulu_data_col.to_partial(); + if let Some(header) = partial.sidecar.header.take() { + partial_header = Some(header); + } + partial_columns.push(Arc::new(partial)); + } + + let subnet = DataColumnSubnetId::from_column_index(*data_col.index(), &chain.spec); + full_messages.push(PubsubMessage::DataColumnSidecar(Box::new(( + subnet, data_col, + )))); + } + + // Publish full messages + if !full_messages.is_empty() { + crate::utils::publish_pubsub_messages(sender_clone, full_messages).map_err(|_| { + BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish)) + })?; + } + + // Publish partial messages + if !partial_columns.is_empty() { + if let Some(header) = partial_header { + crate::utils::publish_network_message( + sender_clone, + NetworkMessage::PublishPartialColumns { + columns: partial_columns, + header: Arc::new(header), + }, + ) + .map_err(|_| { + BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish)) + })?; + } else { + crit!("Unable to extract header from full columns") + } + } + + Ok(()) } async fn post_block_import_logging_and_response( diff --git a/beacon_node/lighthouse_network/Cargo.toml b/beacon_node/lighthouse_network/Cargo.toml index 659886f0f1..44af8d7006 100644 --- a/beacon_node/lighthouse_network/Cargo.toml +++ b/beacon_node/lighthouse_network/Cargo.toml @@ -21,6 +21,8 @@ ethereum_ssz_derive = { workspace = true } fixed_bytes = { workspace = true } fnv = { workspace = true } futures = { workspace = true } +# Enable partial messages feature +gossipsub = { package = "libp2p-gossipsub", git = "https://github.com/libp2p/rust-libp2p.git", features = ["partial_messages"] } hex = { workspace = true } if-addrs = "0.14" itertools = { workspace = true } diff --git a/beacon_node/lighthouse_network/src/config.rs b/beacon_node/lighthouse_network/src/config.rs index cb94bfff22..db42d0cfa8 100644 --- a/beacon_node/lighthouse_network/src/config.rs +++ b/beacon_node/lighthouse_network/src/config.rs @@ -140,6 +140,9 @@ pub struct Config { /// Flag for advertising a fake CGC to peers for testing ONLY. pub advertise_false_custody_group_count: Option, + + /// Whether to enable partial data column support. + pub enable_partial_columns: bool, } impl Config { @@ -364,6 +367,7 @@ impl Default for Config { inbound_rate_limiter_config: None, idontwant_message_size_threshold: DEFAULT_IDONTWANT_MESSAGE_SIZE_THRESHOLD, advertise_false_custody_group_count: None, + enable_partial_columns: false, } } } diff --git a/beacon_node/lighthouse_network/src/lib.rs b/beacon_node/lighthouse_network/src/lib.rs index 863a7a4a43..fdb6ff095e 100644 --- a/beacon_node/lighthouse_network/src/lib.rs +++ b/beacon_node/lighthouse_network/src/lib.rs @@ -99,7 +99,7 @@ impl std::fmt::Display for ClearDialError<'_> { pub use crate::types::{ Enr, EnrSyncCommitteeBitfield, GossipTopic, NetworkGlobals, PubsubMessage, Subnet, - SubnetDiscovery, + SubnetDiscovery, decode_partial, }; pub use prometheus_client; diff --git a/beacon_node/lighthouse_network/src/metrics.rs b/beacon_node/lighthouse_network/src/metrics.rs index 623d43a727..d5d1ed5053 100644 --- a/beacon_node/lighthouse_network/src/metrics.rs +++ b/beacon_node/lighthouse_network/src/metrics.rs @@ -83,6 +83,14 @@ pub static FAILED_PUBLISHES_PER_MAIN_TOPIC: LazyLock> = Lazy &["topic_hash"], ) }); +pub static FAILED_PARTIAL_PUBLISHES_PER_MAIN_TOPIC: LazyLock> = + LazyLock::new(|| { + try_create_int_gauge_vec( + "gossipsub_failed_partial_publishes_per_main_topic", + "Failed gossip partial message publishes", + &["topic_hash"], + ) + }); pub static TOTAL_RPC_ERRORS_PER_CLIENT: LazyLock> = LazyLock::new(|| { try_create_int_counter_vec( "libp2p_rpc_errors_per_client", diff --git a/beacon_node/lighthouse_network/src/service/mod.rs b/beacon_node/lighthouse_network/src/service/mod.rs index 56fcbb3bb6..f0c1567cb0 100644 --- a/beacon_node/lighthouse_network/src/service/mod.rs +++ b/beacon_node/lighthouse_network/src/service/mod.rs @@ -14,17 +14,19 @@ use crate::rpc::{ GoodbyeReason, HandlerErr, InboundRequestId, Protocol, RPC, RPCError, RPCMessage, RPCReceived, RequestType, ResponseTermination, RpcResponse, RpcSuccessResponse, }; +use crate::service::partial_column_header_tracker::PartialColumnHeaderTracker; use crate::types::{ - GossipEncoding, GossipKind, GossipTopic, SnappyTransform, Subnet, SubnetDiscovery, - all_topics_at_fork, core_topics_to_subscribe, is_fork_non_core_topic, subnet_from_topic_hash, + GossipEncoding, GossipKind, GossipTopic, OutgoingPartialColumn, SnappyTransform, Subnet, + SubnetDiscovery, all_topics_at_fork, core_topics_to_subscribe, is_fork_non_core_topic, + subnet_from_topic_hash, }; -use crate::{Enr, NetworkGlobals, PubsubMessage, TopicHash, metrics}; +use crate::{Enr, NetworkGlobals, PubsubMessage, TopicHash, decode_partial, metrics}; use api_types::{AppRequestId, Response}; use futures::stream::StreamExt; use gossipsub_scoring_parameters::{PeerScoreSettings, lighthouse_gossip_thresholds}; use libp2p::gossipsub::{ - self, IdentTopic as Topic, MessageAcceptance, MessageAuthenticity, MessageId, PublishError, - TopicScoreParams, + self, Event, IdentTopic as Topic, MessageAcceptance, MessageAuthenticity, MessageId, + PublishError, TopicScoreParams, }; use libp2p::identity::Keypair; use libp2p::multiaddr::{self, Multiaddr, Protocol as MProtocol}; @@ -40,16 +42,18 @@ use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use tracing::{debug, error, info, trace, warn}; -use types::{ChainSpec, ForkName}; use types::{ - EnrForkId, EthSpec, ForkContext, Slot, SubnetId, consts::altair::SYNC_COMMITTEE_SUBNET_COUNT, + ChainSpec, DataColumnSubnetId, EnrForkId, EthSpec, ForkContext, ForkName, PartialDataColumn, + PartialDataColumnHeader, Slot, SubnetId, consts::altair::SYNC_COMMITTEE_SUBNET_COUNT, }; use utils::{Context as ServiceContext, build_transport, strip_peer_id}; pub mod api_types; mod gossip_cache; pub mod gossipsub_scoring_parameters; +mod partial_column_header_tracker; pub mod utils; + /// The number of peers we target per subnet for discovery queries. pub const TARGET_SUBNET_PEERS: usize = 3; @@ -99,6 +103,15 @@ pub enum NetworkEvent { /// The message itself. message: PubsubMessage, }, + /// A partial data column sidecar received via gossipsub partial protocol. + PartialDataColumnSidecar { + /// The peer from which we received this message. + source: PeerId, + /// The partial column data. + column: Box>, + /// The topic that this message was sent on. + topic: GossipTopic, + }, /// Inform the network to send a Status to this peer. StatusPeer(PeerId), NewListenAddr(Multiaddr), @@ -162,6 +175,7 @@ pub struct Network { /// The interval for updating gossipsub scores update_gossipsub_scores: tokio::time::Interval, gossip_cache: GossipCache, + partial_column_header_tracker: PartialColumnHeaderTracker, /// This node's PeerId. pub local_peer_id: PeerId, } @@ -505,6 +519,7 @@ impl Network { score_settings, update_gossipsub_scores, gossip_cache, + partial_column_header_tracker: PartialColumnHeaderTracker::new(), local_peer_id, }; @@ -804,9 +819,18 @@ impl Network { .write() .insert(topic.clone()); + let partial = topic + .kind() + .use_partial_messages(self.network_globals.config.as_ref()); let topic: Topic = topic.into(); - match self.gossipsub_mut().subscribe(&topic) { + let subscribe_result = if partial { + self.gossipsub_mut().subscribe_partial(&topic, true) + } else { + self.gossipsub_mut().subscribe(&topic) + }; + + match subscribe_result { Err(e) => { warn!(%topic, error = ?e, "Failed to subscribe to topic"); false @@ -849,6 +873,16 @@ impl Network { "Attempted to publish duplicate message" ); } + PublishError::NoPeersSubscribedToTopic + if topic + .kind() + .use_partial_messages(self.network_globals.config.as_ref()) => + { + debug!( + kind = %topic.kind(), + "No peers supporting full messages" + ); + } ref e => { warn!( error = ?e, @@ -886,6 +920,66 @@ impl Network { } } + /// Publishes partial data column sidecars to the gossipsub network. + pub fn publish_partial( + &mut self, + columns: Vec>>, + header: Arc>, + ) { + if !self.network_globals.config.enable_partial_columns { + return; + } + + debug!( + count = columns.len(), + "Sending partial data column sidecars" + ); + + for column in columns { + let subnet = + DataColumnSubnetId::from_column_index(column.index, &self.fork_context.spec); + let topic = GossipTopic::new( + GossipKind::DataColumnSidecar(subnet), + GossipEncoding::default(), + self.enr_fork_id.fork_digest, + ); + let header_sent_set = self + .partial_column_header_tracker + .get_for_block(column.block_root); + let partial_message = OutgoingPartialColumn::new(column, &header, header_sent_set); + let publish_topic: Topic = topic.clone().into(); + + if let Err(e) = self + .gossipsub_mut() + .publish_partial(publish_topic, partial_message) + { + match e { + PublishError::NoPeersSubscribedToTopic => { + debug!( + kind = %topic.kind(), + "No peers supporting partial messages" + ); + } + ref e => { + warn!( + error = ?e, + kind = %topic.kind(), + "Could not publish partial message" + ); + } + } + + // add to metrics + if let Some(v) = metrics::get_int_gauge( + &metrics::FAILED_PARTIAL_PUBLISHES_PER_MAIN_TOPIC, + &[&format!("{:?}", topic.kind())], + ) { + v.inc() + }; + } + } + } + /// Informs the gossipsub about the result of a message validation. /// If the message is valid it will get propagated by gossipsub. pub fn report_message_validation_result( @@ -918,6 +1012,29 @@ impl Network { ); } + /// Informs the gossipsub about the failure of a partial message validation. + pub fn report_partial_message_validation_failure( + &mut self, + propagation_source: PeerId, + topic: GossipTopic, + ) { + if let Some(client) = self + .network_globals + .peers + .read() + .peer_info(&propagation_source) + .map(|info| info.client().kind.as_ref()) + { + metrics::inc_counter_vec( + &metrics::GOSSIP_UNACCEPTED_MESSAGES_PER_CLIENT, + &[client, "reject"], + ) + } + + self.gossipsub_mut() + .report_invalid_partial(propagation_source, &TopicHash::from(Topic::from(topic))); + } + /// Updates the current gossipsub scoring parameters based on the validator count and current /// slot. pub fn update_gossipsub_parameters( @@ -1290,6 +1407,56 @@ impl Network { } } } + Event::Partial { + topic_hash, + peer_id, + group_id, + message, + .. + } => { + let topic = GossipTopic::decode(topic_hash.as_str()) + .inspect_err(|error| { + debug!( + topic = ?topic_hash, + error, + "Could not decode gossipsub partial message topic" + ); + // punish the peer + self.gossipsub_mut() + .report_invalid_partial(peer_id, &topic_hash); + }) + .ok()?; + + if let Some(message) = message { + match decode_partial::(&topic, &group_id, &message) { + Err(error) => { + debug!( + topic = ?topic_hash, + error, + "Could not decode gossipsub partial message" + ); + //reject the message + self.gossipsub_mut() + .report_invalid_partial(peer_id, &topic_hash); + } + Ok(column) => { + debug!( + block_root = %column.block_root, + index = column.index, + %peer_id, + cells_present = %column.sidecar.cells_present_bitmap, + "Decoded partial message" + ); + // Notify the network + return Some(NetworkEvent::PartialDataColumnSidecar { + source: peer_id, + column: Box::new(column), + topic, + }); + } + } + } + } gossipsub::Event::Subscribed { peer_id, topic } => { if let Ok(topic) = GossipTopic::decode(topic.as_str()) { if let Some(subnet_id) = topic.subnet_id() { diff --git a/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs b/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs new file mode 100644 index 0000000000..bb588fe3d8 --- /dev/null +++ b/beacon_node/lighthouse_network/src/service/partial_column_header_tracker.rs @@ -0,0 +1,28 @@ +use crate::types::HeaderSentSet; +use lru::LruCache; +use parking_lot::Mutex; +use std::collections::HashSet; +use std::num::NonZeroUsize; +use std::sync::Arc; +use types::core::Hash256; + +const MAX_BLOCKS: NonZeroUsize = NonZeroUsize::new(4).unwrap(); + +pub struct PartialColumnHeaderTracker { + blocks: LruCache, +} + +impl PartialColumnHeaderTracker { + pub fn new() -> Self { + PartialColumnHeaderTracker { + blocks: LruCache::new(MAX_BLOCKS), + } + } + + pub fn get_for_block(&mut self, hash: Hash256) -> HeaderSentSet { + Arc::clone( + self.blocks + .get_or_insert(hash, || Arc::new(Mutex::new(HashSet::new()))), + ) + } +} diff --git a/beacon_node/lighthouse_network/src/types/mod.rs b/beacon_node/lighthouse_network/src/types/mod.rs index eea8782b2d..d0173e5b9a 100644 --- a/beacon_node/lighthouse_network/src/types/mod.rs +++ b/beacon_node/lighthouse_network/src/types/mod.rs @@ -1,4 +1,5 @@ mod globals; +mod partial; mod pubsub; mod subnet; mod topics; @@ -13,7 +14,9 @@ pub type Enr = discv5::enr::Enr; pub use eth2::lighthouse::sync_state::{BackFillState, CustodyBackFillState, SyncState}; pub use globals::NetworkGlobals; -pub use pubsub::{PubsubMessage, SnappyTransform}; +pub use partial::HeaderSentSet; +pub use partial::OutgoingPartialColumn; +pub use pubsub::{PubsubMessage, SnappyTransform, decode_partial}; pub use subnet::{Subnet, SubnetDiscovery}; pub use topics::{ GossipEncoding, GossipKind, GossipTopic, TopicConfig, all_topics_at_fork, diff --git a/beacon_node/lighthouse_network/src/types/partial.rs b/beacon_node/lighthouse_network/src/types/partial.rs new file mode 100644 index 0000000000..f25ce9ec36 --- /dev/null +++ b/beacon_node/lighthouse_network/src/types/partial.rs @@ -0,0 +1,503 @@ +use crate::PeerId; +use itertools::Itertools; +use libp2p::gossipsub::partial_messages::{Metadata, Partial, PartialAction, PartialError}; +use parking_lot::Mutex; +use ssz::{Decode, Encode}; +use std::collections::HashSet; +use std::fmt::Debug; +use std::sync::Arc; +use tracing::{debug, error}; +use types::core::{EthSpec, Hash256}; +use types::data::{ + CellBitmap, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnPartsMetadata, + PartialDataColumnSidecar, PartialDataColumnSidecarRef, +}; + +const PARTIAL_COLUMNS_VERSION_BYTE: u8 = 0; + +pub type HeaderSentSet = Arc>>; + +#[derive(Debug, Clone)] +pub struct OutgoingPartialColumn { + partial_column: Arc>, + metadata: MaybeKnownMetadata, + header_message: Vec, + header_sent_set: HeaderSentSet, +} + +impl OutgoingPartialColumn { + pub fn new( + partial_column: Arc>, + header: &PartialDataColumnHeader, + header_sent_set: HeaderSentSet, + ) -> Self { + // For now, always request all cells + let mut requests = partial_column.sidecar.cells_present_bitmap.clone(); + for idx in 0..requests.len() { + requests + .set(idx, true) + .expect("Bound asserted via `len` above"); + } + let metadata = PartialDataColumnPartsMetadata:: { + available: partial_column.sidecar.cells_present_bitmap.clone(), + requests, + } + .into(); + + let header_message = PartialDataColumnSidecarRef { + cells_present_bitmap: CellBitmap::::with_capacity( + partial_column.sidecar.cells_present_bitmap.len(), + ) + .expect("Taking length from bitmap with same bound"), + column: vec![], + kzg_proofs: vec![], + header: Some(header).into(), + } + .as_ssz_bytes(); + + OutgoingPartialColumn { + partial_column, + metadata, + header_message, + header_sent_set, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum MaybeKnownMetadata { + Unknown, + Known { + metadata: Box>, + encoded: Vec, + }, +} + +impl MaybeKnownMetadata { + fn do_update( + &mut self, + received: PartialDataColumnPartsMetadata, + ) -> Result { + let MaybeKnownMetadata::Known { metadata, encoded } = self else { + *self = MaybeKnownMetadata::Known { + encoded: received.as_ssz_bytes(), + metadata: Box::new(received), + }; + return Ok(true); + }; + + if ![ + received.available.len(), + received.requests.len(), + metadata.available.len(), + metadata.requests.len(), + ] + .into_iter() + .all_equal() + { + return Err(PartialError::OutOfRange); + } + let new_available = metadata.available.union(&received.available); + let new_request = metadata.requests.union(&received.requests); + if metadata.available == new_available && metadata.requests == new_request { + return Ok(false); + } + metadata.available = new_available; + metadata.requests = new_request; + *encoded = metadata.as_ssz_bytes(); + Ok(true) + } +} + +impl Metadata for MaybeKnownMetadata { + fn as_slice(&self) -> &[u8] { + match self { + MaybeKnownMetadata::Unknown => &[], + MaybeKnownMetadata::Known { encoded, .. } => encoded, + } + } + + fn update(&mut self, data: &[u8]) -> Result { + let received = PartialDataColumnPartsMetadata::from_ssz_bytes(data) + .map_err(|_| PartialError::InvalidFormat)?; + + self.do_update(received) + } + + fn update_from_data(&mut self, data: &[u8]) -> Result<(), PartialError> { + if data.is_empty() { + return Ok(()); + } + + let sidecar = PartialDataColumnSidecar::::from_ssz_bytes(data) + .map_err(|_| PartialError::InvalidFormat)?; + + self.do_update(PartialDataColumnPartsMetadata { + available: sidecar.cells_present_bitmap.clone(), + requests: sidecar.cells_present_bitmap, + }) + .map(|_| ()) + } +} + +impl From> for MaybeKnownMetadata { + fn from(metadata: PartialDataColumnPartsMetadata) -> Self { + Self::Known { + encoded: metadata.as_ssz_bytes(), + metadata: Box::new(metadata), + } + } +} + +impl Partial for OutgoingPartialColumn { + fn group_id(&self) -> Vec { + let mut group_id = Vec::with_capacity(Hash256::len_bytes() + 1); + group_id.push(PARTIAL_COLUMNS_VERSION_BYTE); + group_id.extend_from_slice(self.partial_column.block_root.as_slice()); + group_id + } + + fn metadata(&self) -> Box { + Box::new(self.metadata.clone()) + } + + fn partial_action_from_metadata( + &self, + peer_id: PeerId, + metadata: Option<&[u8]>, + ) -> Result { + match metadata { + None => { + // send the header-only messsage to the peer if we have not yet + let send = self.header_sent_set.lock().insert(peer_id).then(|| { + ( + self.header_message.clone(), + Box::new(MaybeKnownMetadata::::Unknown) as Box, + ) + }); + debug!( + peer=%peer_id, + group_id=%self.partial_column.block_root, + column_index=self.partial_column.index, + sending_header=send.is_some(), + "Partial send: No metadata" + ); + + Ok(PartialAction { need: false, send }) + } + Some([]) => Ok(PartialAction { + need: false, + send: None, + }), + Some(metadata) => { + // The peer is apparently aware of the header, make sure we track that: + self.header_sent_set.lock().insert(peer_id); + + let peer_metadata = PartialDataColumnPartsMetadata::::from_ssz_bytes(metadata) + .map_err(|_| PartialError::InvalidFormat)?; + let expected_len = self.partial_column.sidecar.cells_present_bitmap.len(); + if peer_metadata.available.len() != expected_len + || peer_metadata.requests.len() != expected_len + { + return Err(PartialError::InvalidFormat); + } + + let need = !peer_metadata + .available + .is_subset(&self.partial_column.sidecar.cells_present_bitmap); + let want = peer_metadata.requests.difference(&peer_metadata.available); + + let send = self + .partial_column + .sidecar + .filter(|idx| want.get(idx).expect("Bound checked above")) + .map_err(|err| { + error!(?err, "Unexpected error filtering sidecar"); + PartialError::InvalidFormat + })? + .map(|sidecar| { + debug!( + peer=%peer_id, + group_id=%self.partial_column.block_root, + column_index=self.partial_column.index, + metadata=%peer_metadata, + sending=%sidecar.cells_present_bitmap, + "Partial send: Sending" + ); + ( + sidecar.as_ssz_bytes(), + Box::new(MaybeKnownMetadata::::from( + PartialDataColumnPartsMetadata { + available: peer_metadata + .available + .union(&sidecar.cells_present_bitmap), + requests: peer_metadata + .requests + .union(&sidecar.cells_present_bitmap), + }, + )) as Box, + ) + }); + + if send.is_none() { + debug!( + peer=%peer_id, + group_id=%self.partial_column.block_root, + column_index=self.partial_column.index, + metadata=%peer_metadata, + "Partial send: Nothing to send" + ); + } + + Ok(PartialAction { need, send }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bls::Signature; + use fixed_bytes::FixedBytesExtended; + use libp2p::identity::Keypair; + use ssz_types::FixedVector; + use types::block::{BeaconBlockHeader, SignedBeaconBlockHeader}; + use types::core::{MinimalEthSpec, Slot}; + use types::data::PartialDataColumnHeader; + + type E = MinimalEthSpec; + + fn make_cell(marker: u8) -> types::Cell { + let mut cell = types::Cell::::default(); + cell[0] = marker; + cell + } + + fn make_header(num_commitments: usize) -> PartialDataColumnHeader { + PartialDataColumnHeader { + kzg_commitments: vec![types::KzgCommitment([0u8; 48]); num_commitments] + .try_into() + .unwrap(), + signed_block_header: SignedBeaconBlockHeader { + message: BeaconBlockHeader { + slot: Slot::new(1), + proposer_index: 0, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), + }, + kzg_commitments_inclusion_proof: FixedVector::new( + vec![Hash256::zero(); E::kzg_commitments_inclusion_proof_depth()], + ) + .unwrap(), + } + } + + fn make_partial_column( + block_root: Hash256, + total_blobs: usize, + present_indices: &[usize], + ) -> Arc> { + let mut bitmap = CellBitmap::::with_capacity(total_blobs).unwrap(); + for &idx in present_indices { + bitmap.set(idx, true).unwrap(); + } + + Arc::new(PartialDataColumn { + block_root, + index: 0, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column: present_indices + .iter() + .map(|&idx| make_cell(idx as u8)) + .collect::>() + .try_into() + .unwrap(), + kzg_proofs: present_indices + .iter() + .map(|_| types::KzgProof::empty()) + .collect::>() + .try_into() + .unwrap(), + header: None.into(), + }, + }) + } + + fn random_peer_id() -> PeerId { + let keypair = Keypair::generate_ed25519(); + PeerId::from(keypair.public()) + } + + // -- MaybeKnownMetadata tests -- + + #[test] + fn update_from_unknown_initializes() { + let mut meta = MaybeKnownMetadata::::Unknown; + let mut bitmap = CellBitmap::::with_capacity(4).unwrap(); + bitmap.set(0, true).unwrap(); + let received = PartialDataColumnPartsMetadata { + available: bitmap.clone(), + requests: bitmap, + }; + let changed = meta.do_update(received).unwrap(); + assert!(changed); + assert!(matches!(meta, MaybeKnownMetadata::Known { .. })); + } + + #[test] + fn update_unions_bitmaps() { + let mut bitmap1 = CellBitmap::::with_capacity(4).unwrap(); + bitmap1.set(0, true).unwrap(); + let mut meta: MaybeKnownMetadata = PartialDataColumnPartsMetadata { + available: bitmap1.clone(), + requests: bitmap1, + } + .into(); + + let mut bitmap2 = CellBitmap::::with_capacity(4).unwrap(); + bitmap2.set(1, true).unwrap(); + let changed = meta + .do_update(PartialDataColumnPartsMetadata { + available: bitmap2.clone(), + requests: bitmap2, + }) + .unwrap(); + assert!(changed); + + if let MaybeKnownMetadata::Known { metadata, .. } = &meta { + assert!(metadata.available.get(0).unwrap()); + assert!(metadata.available.get(1).unwrap()); + assert!(!metadata.available.get(2).unwrap()); + } else { + panic!("Expected Known metadata"); + } + } + + #[test] + fn update_returns_false_when_no_change() { + let mut bitmap = CellBitmap::::with_capacity(4).unwrap(); + bitmap.set(0, true).unwrap(); + bitmap.set(1, true).unwrap(); + let mut meta: MaybeKnownMetadata = PartialDataColumnPartsMetadata { + available: bitmap.clone(), + requests: bitmap.clone(), + } + .into(); + + // Update with a subset + let mut subset = CellBitmap::::with_capacity(4).unwrap(); + subset.set(0, true).unwrap(); + let changed = meta + .do_update(PartialDataColumnPartsMetadata { + available: subset.clone(), + requests: subset, + }) + .unwrap(); + assert!(!changed); + } + + #[test] + fn update_rejects_mismatched_lengths() { + let mut bitmap4 = CellBitmap::::with_capacity(4).unwrap(); + bitmap4.set(0, true).unwrap(); + let mut meta: MaybeKnownMetadata = PartialDataColumnPartsMetadata { + available: bitmap4.clone(), + requests: bitmap4, + } + .into(); + + let mut bitmap6 = CellBitmap::::with_capacity(6).unwrap(); + bitmap6.set(0, true).unwrap(); + let result = meta.do_update(PartialDataColumnPartsMetadata { + available: bitmap6.clone(), + requests: bitmap6, + }); + assert!(result.is_err()); + } + + // -- OutgoingPartialColumn::partial_action_from_metadata tests -- + + #[test] + fn no_metadata_sends_header_once() { + let root = Hash256::repeat_byte(1); + let header = make_header(4); + let partial = make_partial_column(root, 4, &[0, 1]); + let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + + let peer = random_peer_id(); + + // First call with no metadata → sends header + let action = outgoing.partial_action_from_metadata(peer, None).unwrap(); + assert!(action.send.is_some()); + + // Second call for same peer → no send + let action2 = outgoing.partial_action_from_metadata(peer, None).unwrap(); + assert!(action2.send.is_none()); + } + + #[test] + fn metadata_filters_cells_to_send() { + let root = Hash256::repeat_byte(1); + let header = make_header(4); + // We have cells [0, 2, 3] + let partial = make_partial_column(root, 4, &[0, 2, 3]); + let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + + let peer = random_peer_id(); + + // Peer has [0, 1], wants [0, 1, 2, 3] + let mut peer_available = CellBitmap::::with_capacity(4).unwrap(); + peer_available.set(0, true).unwrap(); + peer_available.set(1, true).unwrap(); + let mut peer_request = CellBitmap::::with_capacity(4).unwrap(); + for i in 0..4 { + peer_request.set(i, true).unwrap(); + } + let peer_meta = PartialDataColumnPartsMetadata:: { + available: peer_available, + requests: peer_request, + }; + let encoded = peer_meta.as_ssz_bytes(); + + let action = outgoing + .partial_action_from_metadata(peer, Some(&encoded)) + .unwrap(); + // We should send cells [2, 3] (want = request - available = [2,3], and we have [0,2,3]) + assert!(action.send.is_some()); + } + + #[test] + fn metadata_sets_need_when_peer_has_unknown_cells() { + let root = Hash256::repeat_byte(1); + let header = make_header(4); + // We have cells [0] + let partial = make_partial_column(root, 4, &[0]); + let header_sent_set: HeaderSentSet = Arc::new(Mutex::new(HashSet::new())); + let outgoing = OutgoingPartialColumn::new(partial, &header, header_sent_set); + + let peer = random_peer_id(); + + // Peer has [0, 1, 2] — cells [1, 2] are unknown to us + let mut peer_available = CellBitmap::::with_capacity(4).unwrap(); + peer_available.set(0, true).unwrap(); + peer_available.set(1, true).unwrap(); + peer_available.set(2, true).unwrap(); + let peer_meta = PartialDataColumnPartsMetadata:: { + available: peer_available.clone(), + requests: peer_available, + }; + let encoded = peer_meta.as_ssz_bytes(); + + let action = outgoing + .partial_action_from_metadata(peer, Some(&encoded)) + .unwrap(); + assert!(action.need); + } +} diff --git a/beacon_node/lighthouse_network/src/types/pubsub.rs b/beacon_node/lighthouse_network/src/types/pubsub.rs index 12567907f6..9875d4b0c4 100644 --- a/beacon_node/lighthouse_network/src/types/pubsub.rs +++ b/beacon_node/lighthouse_network/src/types/pubsub.rs @@ -1,23 +1,23 @@ //! Handles the encoding and decoding of pubsub messages. -use crate::TopicHash; use crate::types::{GossipEncoding, GossipKind, GossipTopic}; -use libp2p::gossipsub; +use gossipsub::TopicHash; use snap::raw::{Decoder, Encoder, decompress_len}; use ssz::{Decode, Encode}; use std::io::{Error, ErrorKind}; use std::sync::Arc; use types::{ AttesterSlashing, AttesterSlashingBase, AttesterSlashingElectra, BlobSidecar, - DataColumnSidecar, DataColumnSubnetId, EthSpec, ForkContext, ForkName, - LightClientFinalityUpdate, LightClientOptimisticUpdate, PayloadAttestationMessage, - ProposerSlashing, SignedAggregateAndProof, SignedAggregateAndProofBase, - SignedAggregateAndProofElectra, SignedBeaconBlock, SignedBeaconBlockAltair, - SignedBeaconBlockBase, SignedBeaconBlockBellatrix, SignedBeaconBlockCapella, - SignedBeaconBlockDeneb, SignedBeaconBlockElectra, SignedBeaconBlockFulu, - SignedBeaconBlockGloas, SignedBlsToExecutionChange, SignedContributionAndProof, - SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, SignedProposerPreferences, - SignedVoluntaryExit, SingleAttestation, SubnetId, SyncCommitteeMessage, SyncSubnetId, + DataColumnSidecar, DataColumnSubnetId, EthSpec, ForkContext, ForkName, Hash256, + LightClientFinalityUpdate, LightClientOptimisticUpdate, PartialDataColumn, + PartialDataColumnSidecar, PayloadAttestationMessage, ProposerSlashing, SignedAggregateAndProof, + SignedAggregateAndProofBase, SignedAggregateAndProofElectra, SignedBeaconBlock, + SignedBeaconBlockAltair, SignedBeaconBlockBase, SignedBeaconBlockBellatrix, + SignedBeaconBlockCapella, SignedBeaconBlockDeneb, SignedBeaconBlockElectra, + SignedBeaconBlockFulu, SignedBeaconBlockGloas, SignedBlsToExecutionChange, + SignedContributionAndProof, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, + SignedProposerPreferences, SignedVoluntaryExit, SingleAttestation, SubnetId, + SyncCommitteeMessage, SyncSubnetId, }; #[derive(Debug, Clone, PartialEq)] @@ -464,6 +464,35 @@ impl PubsubMessage { } } +/// Decodes incoming partial data column sidecar from gossipsub partial protocol. +/// Note: Currently, data columns are the only supported partial messages. In future this could +/// return an enum. +pub fn decode_partial( + topic: &GossipTopic, + group: &[u8], + data: &[u8], +) -> Result, String> { + match topic.kind() { + GossipKind::DataColumnSidecar(id) => { + if group.first() != Some(&0) { + return Err(format!("Unknown data column format: {:?}", group.first())); + } + let block_root = Hash256::from_ssz_bytes(&group[1..]) + .map_err(|e| format!("Error decoding group: {:?}", e))?; + let sidecar = PartialDataColumnSidecar::from_ssz_bytes(data) + .map_err(|e| format!("Error decoding sidecar: {:?}", e))?; + let data_column = PartialDataColumn { + block_root, + // Partial messages are spec'd under the assumption that there is one column per subnet. + index: **id, + sidecar, + }; + Ok(data_column) + } + other => Err(format!("Partial message unsupported for topic: {other}")), + } +} + impl std::fmt::Display for PubsubMessage { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/beacon_node/lighthouse_network/src/types/topics.rs b/beacon_node/lighthouse_network/src/types/topics.rs index a3ea4babce..b51c459a80 100644 --- a/beacon_node/lighthouse_network/src/types/topics.rs +++ b/beacon_node/lighthouse_network/src/types/topics.rs @@ -11,7 +11,7 @@ use types::{ sync_committee::SyncSubnetId, }; -use crate::Subnet; +use crate::{NetworkConfig, Subnet}; /// The gossipsub topic names. // These constants form a topic name of the form /TOPIC_PREFIX/TOPIC/ENCODING_POSTFIX @@ -200,6 +200,15 @@ pub enum GossipKind { LightClientOptimisticUpdate, } +impl GossipKind { + pub fn use_partial_messages(&self, config: &NetworkConfig) -> bool { + match self { + GossipKind::DataColumnSidecar(_) => config.enable_partial_columns, + _ => false, + } + } +} + impl std::fmt::Display for GossipKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/beacon_node/network/src/metrics.rs b/beacon_node/network/src/metrics.rs index 2119acf946..b09dc95db4 100644 --- a/beacon_node/network/src/metrics.rs +++ b/beacon_node/network/src/metrics.rs @@ -143,6 +143,22 @@ pub static BEACON_PROCESSOR_GOSSIP_DATA_COLUMN_SIDECAR_VERIFIED_TOTAL: LazyLock< "Total number of gossip data column sidecar verified for propagation.", ) }); +pub static BEACON_PROCESSOR_GOSSIP_PARTIAL_DATA_COLUMN_SIDECAR_VERIFIED_TOTAL: LazyLock< + Result, +> = LazyLock::new(|| { + try_create_int_counter( + "beacon_processor_gossip_partial_data_column_verified_total", + "Total number of gossip partial data column sidecar verified for propagation.", + ) +}); +pub static BEACON_PROCESSOR_GOSSIP_PARTIAL_DATA_COLUMN_SIDECAR_MISSING_HEADER_TOTAL: LazyLock< + Result, +> = LazyLock::new(|| { + try_create_int_counter( + "beacon_processor_gossip_partial_data_column_missing_header_total", + "Total number of gossip partial data column sidecar received without a (cached) header.", + ) +}); // Gossip Exits. pub static BEACON_PROCESSOR_EXIT_VERIFIED_TOTAL: LazyLock> = LazyLock::new(|| { @@ -601,6 +617,16 @@ pub static BEACON_DATA_COLUMN_GOSSIP_PROPAGATION_VERIFICATION_DELAY_TIME: LazyLo decimal_buckets(-3, -1), ) }); +pub static BEACON_PARTIAL_DATA_COLUMN_GOSSIP_PROPAGATION_VERIFICATION_DELAY_TIME: LazyLock< + Result, +> = LazyLock::new(|| { + try_create_histogram_with_buckets( + "beacon_partial_data_column_gossip_propagation_verification_delay_time", + "Duration between when the partial data column sidecar is received over gossip and when it is verified for propagation.", + // [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5] + decimal_buckets(-3, -1), + ) +}); pub static BEACON_DATA_COLUMN_GOSSIP_SLOT_START_DELAY_TIME: LazyLock> = LazyLock::new(|| { try_create_histogram_with_buckets( @@ -615,6 +641,28 @@ pub static BEACON_DATA_COLUMN_GOSSIP_SLOT_START_DELAY_TIME: LazyLock> = + LazyLock::new(|| { + try_create_histogram_with_buckets( + "beacon_partial_data_column_gossip_slot_start_delay_time", + "Duration between when the partial data column sidecar is received over gossip and the start of the slot it belongs to.", + // Create a custom bucket list for greater granularity in block delay + Ok(vec![ + 0.1, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, + 6.0, 7.0, 8.0, 9.0, 10.0, 15.0, 20.0, + ]), // NOTE: Previous values, which we may want to switch back to. + // [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50] + //decimal_buckets(-1,2) + ) + }); +pub static BEACON_USEFUL_FULL_COLUMNS_RECEIVED_TOTAL: LazyLock> = + LazyLock::new(|| { + try_create_int_counter_vec( + "beacon_useful_full_columns_received_total", + "Number of useful full columns (any cell being useful) received", + &["column_index"], + ) + }); pub static BEACON_BLOB_DELAY_GOSSIP_VERIFICATION: LazyLock> = LazyLock::new( || { diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 2fe5aec347..ea1a2286a0 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4,6 +4,14 @@ use crate::{ service::NetworkMessage, sync::SyncMessage, }; +use beacon_chain::block_verification_types::AsBlock; +use beacon_chain::data_column_verification::{ + GossipDataColumnError, GossipPartialDataColumnError, GossipVerifiedDataColumn, + GossipVerifiedPartialDataColumnHeader, KzgVerifiedPartialDataColumn, + PartialColumnVerificationResult, +}; +use beacon_chain::payload_bid_verification::PayloadBidError; +use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; use beacon_chain::store::Error; use beacon_chain::{ AvailabilityProcessingStatus, BeaconChainError, BeaconChainTypes, BlockError, ForkChoiceError, @@ -22,13 +30,11 @@ use beacon_chain::{ EnvelopeError, gossip_verified_envelope::GossipVerifiedEnvelope, }, }; -use beacon_chain::{block_verification_types::AsBlock, payload_bid_verification::PayloadBidError}; -use beacon_chain::{ - data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}, - proposer_preferences_verification::ProposerPreferencesError, -}; use beacon_processor::{Work, WorkEvent}; -use lighthouse_network::{Client, MessageAcceptance, MessageId, PeerAction, PeerId, ReportSource}; +use lighthouse_network::{ + Client, GossipTopic, MessageAcceptance, MessageId, PeerAction, PeerId, PubsubMessage, + ReportSource, +}; use logging::crit; use operation_pool::ReceivedPreCapella; use slot_clock::SlotClock; @@ -41,13 +47,14 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use store::hot_cold_store::HotColdDBError; use tracing::{Instrument, Span, debug, error, info, instrument, trace, warn}; use types::{ - Attestation, AttestationData, AttestationRef, AttesterSlashing, BlobSidecar, DataColumnSidecar, - DataColumnSubnetId, EthSpec, Hash256, IndexedAttestation, LightClientFinalityUpdate, - LightClientOptimisticUpdate, PayloadAttestationMessage, ProposerSlashing, - SignedAggregateAndProof, SignedBeaconBlock, SignedBlsToExecutionChange, - SignedContributionAndProof, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, - SignedProposerPreferences, SignedVoluntaryExit, SingleAttestation, Slot, SubnetId, - SyncCommitteeMessage, SyncSubnetId, block::BlockImportSource, + Attestation, AttestationData, AttestationRef, AttesterSlashing, BlobSidecar, ColumnIndex, + DataColumnSidecar, DataColumnSubnetId, EthSpec, Hash256, IndexedAttestation, + LightClientFinalityUpdate, LightClientOptimisticUpdate, PartialDataColumn, + PartialDataColumnHeader, PayloadAttestationMessage, ProposerSlashing, SignedAggregateAndProof, + SignedBeaconBlock, SignedBlsToExecutionChange, SignedContributionAndProof, + SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, SignedProposerPreferences, + SignedVoluntaryExit, SingleAttestation, Slot, SubnetId, SyncCommitteeMessage, SyncSubnetId, + block::BlockImportSource, }; use beacon_processor::work_reprocessing_queue::QueuedColumnReconstruction; @@ -196,6 +203,19 @@ impl NetworkBeaconProcessor { }) } + /// Send a message on `message_tx` that `peer_id` has sent an invalid partial message and should + /// be penalized. + pub(crate) fn propagate_partial_validation_failure( + &self, + propagation_source: PeerId, + gossip_topic: GossipTopic, + ) { + self.send_network_message(NetworkMessage::PartialValidationFailure { + propagation_source, + gossip_topic, + }) + } + /* Processing functions */ /// Process the unaggregated attestation received from the gossip network and: @@ -697,7 +717,7 @@ impl NetworkBeaconProcessor { MessageAcceptance::Accept, ); } - GossipDataColumnError::ParentUnknown { parent_root } => { + GossipDataColumnError::ParentUnknown { parent_root, .. } => { debug!( action = "requesting parent", %block_root, @@ -723,6 +743,7 @@ impl NetworkBeaconProcessor { | GossipDataColumnError::InvalidSubnetId { .. } | GossipDataColumnError::InvalidInclusionProof | GossipDataColumnError::InvalidKzgProof { .. } + | GossipDataColumnError::MismatchesCachedColumn | GossipDataColumnError::UnexpectedDataColumn | GossipDataColumnError::InvalidColumnIndex(_) | GossipDataColumnError::MaxBlobsPerBlockExceeded { .. } @@ -784,6 +805,261 @@ impl NetworkBeaconProcessor { } } + #[instrument( + name = "lh_process_gossip_partial_data_column", + parent = None, + level = "debug", + skip_all, + fields(block_root = ?column.block_root, index = column.index), + )] + pub async fn process_gossip_partial_data_column_sidecar( + self: &Arc, + peer_id: PeerId, + column: Box>, + seen_duration: Duration, + topic: GossipTopic, + ) { + let block_root = column.block_root; + let index = column.index; + + let result = self + .chain + .verify_partial_data_column_sidecar_for_gossip(column, seen_duration); + + let header = match result { + PartialColumnVerificationResult::Ok { header, column } => { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_GOSSIP_PARTIAL_DATA_COLUMN_SIDECAR_VERIFIED_TOTAL, + ); + + let slot = header.as_header().slot(); + + debug!( + %slot, + %block_root, + %index, + "Successfully verified gossip partial data column sidecar" + ); + + // Log metrics to keep track of propagation delay times. + if let Some(duration) = UNIX_EPOCH + .elapsed() + .ok() + .and_then(|now| now.checked_sub(seen_duration)) + { + metrics::observe_duration( + &metrics::BEACON_PARTIAL_DATA_COLUMN_GOSSIP_PROPAGATION_VERIFICATION_DELAY_TIME, + duration, + ); + } + + self.process_gossip_verified_partial_data_column( + peer_id, + column, + header.clone(), + slot, + ) + .await; + Some(header) + } + PartialColumnVerificationResult::ErrWithValidHeader { header, err } => { + self.handle_partial_verification_error(peer_id, err, block_root, index, topic); + Some(header) + } + PartialColumnVerificationResult::Err(err) => { + self.handle_partial_verification_error(peer_id, err, block_root, index, topic); + None + } + }; + + if let Some(header) = header { + let slot = header.as_header().slot(); + let delay = get_slot_delay_ms(seen_duration, slot, &self.chain.slot_clock); + // Log metrics to track delay from other nodes on the network. + metrics::observe_duration( + &metrics::BEACON_PARTIAL_DATA_COLUMN_GOSSIP_SLOT_START_DELAY_TIME, + delay, + ); + + if !header.was_cached() { + debug!(block = %block_root, "Triggering getBlobs after receiving partial header"); + // We want to publish immediately when this finishes + let publish_blobs = true; + self.fetch_engine_blobs_and_publish(header.into_header(), block_root, publish_blobs) + .await + } + } + } + + fn handle_partial_verification_error( + self: &Arc, + peer_id: PeerId, + err: GossipPartialDataColumnError, + block_root: Hash256, + index: ColumnIndex, + topic: GossipTopic, + ) { + match err { + GossipPartialDataColumnError::GossipDataColumnError(err) => match err { + GossipDataColumnError::InvalidVariant => { + // TODO(gloas) we should probably penalize the peer here + debug!( + %block_root, + %index, + "Invalid gossip partial data column variant." + ) + } + GossipDataColumnError::PriorKnownUnpublished => { + debug!( + %block_root, + %index, + "Gossip partial data column already processed via the EL." + ); + } + GossipDataColumnError::ParentUnknown { parent_root, slot } => { + debug!( + action = "requesting parent", + %block_root, + %parent_root, + "Unknown parent hash for partial column" + ); + self.send_sync_message(SyncMessage::UnknownParentPartialDataColumn { + peer_id, + block_root, + parent_root, + slot, + }); + } + GossipDataColumnError::PubkeyCacheTimeout + | GossipDataColumnError::BeaconChainError(_) => { + crit!( + error = ?err, + "Internal error when verifying partial column sidecar" + ) + } + GossipDataColumnError::ProposalSignatureInvalid + | GossipDataColumnError::UnknownValidator(_) + | GossipDataColumnError::ProposerIndexMismatch { .. } + | GossipDataColumnError::IsNotLaterThanParent { .. } + | GossipDataColumnError::InvalidSubnetId { .. } + | GossipDataColumnError::InvalidInclusionProof + | GossipDataColumnError::InvalidKzgProof { .. } + | GossipDataColumnError::MismatchesCachedColumn + | GossipDataColumnError::UnexpectedDataColumn + | GossipDataColumnError::InvalidColumnIndex(_) + | GossipDataColumnError::MaxBlobsPerBlockExceeded { .. } + | GossipDataColumnError::InconsistentCommitmentsLength { .. } + | GossipDataColumnError::InconsistentProofsLength { .. } + | GossipDataColumnError::NotFinalizedDescendant { .. } => { + debug!( + error = ?err, + %block_root, + %index, + "Could not verify partial column for gossip. Rejecting the column sidecar" + ); + // Prevent recurring behaviour by penalizing the peer slightly. + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_partial_data_column_low", + ); + self.propagate_partial_validation_failure(peer_id, topic); + } + GossipDataColumnError::PriorKnown { .. } => { + // Data column is available via either the EL or reconstruction. + // Do not penalise the peer. + // Gossip filter should filter any duplicates received after this. + debug!( + %block_root, + %index, + "Received already available column sidecar. Ignoring the partial column sidecar" + ) + } + GossipDataColumnError::FutureSlot { .. } + | GossipDataColumnError::PastFinalizedSlot { .. } => { + debug!( + error = ?err, + %block_root, + %index, + "Could not verify column sidecar for gossip. Ignoring the partial column sidecar" + ); + // Prevent recurring behaviour by penalizing the peer slightly. + self.gossip_penalize_peer( + peer_id, + PeerAction::HighToleranceError, + "gossip_partial_data_column_high", + ); + } + }, + GossipPartialDataColumnError::MissingHeader => { + metrics::inc_counter( + &metrics::BEACON_PROCESSOR_GOSSIP_PARTIAL_DATA_COLUMN_SIDECAR_MISSING_HEADER_TOTAL, + ); + warn!( + error = ?err, + %block_root, + %index, + "Received partial column while not having header stored" + ); + self.gossip_penalize_peer( + peer_id, + PeerAction::HighToleranceError, + "gossip_partial_data_column_high", + ); + } + GossipPartialDataColumnError::HeaderMismatches + | GossipPartialDataColumnError::HeaderIncorrectRoot { .. } => { + debug!( + error = ?err, + %block_root, + %index, + "Could not verify partial column header" + ); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_partial_data_column_low", + ); + } + GossipPartialDataColumnError::EmptyMessage + | GossipPartialDataColumnError::InconsistentPresentCount { .. } + | GossipPartialDataColumnError::InconsistentCommitmentsLength { .. } => { + debug!( + error = ?err, + %block_root, + %index, + "Could not verify partial column" + ); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_partial_data_column_low", + ); + } + GossipPartialDataColumnError::PartialColumnsDisabled => { + error!( + error = ?err, + %block_root, + %index, + "Received partial column while disabled" + ); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "gossip_partial_data_column_low", + ); + } + GossipPartialDataColumnError::InternalError(_) => { + error!( + error = ?err, + %block_root, + %index, + "Internal error while processing partial column" + ); + } + } + } + #[allow(clippy::too_many_arguments)] #[instrument( name = "lh_process_gossip_blob", @@ -1030,6 +1306,8 @@ impl NetworkBeaconProcessor { } } + /// Process a gossip-verified full data column (not partial). + /// Partials are handled by process_gossip_verified_partial_data_column. async fn process_gossip_verified_data_column( self: &Arc, peer_id: PeerId, @@ -1042,6 +1320,30 @@ impl NetworkBeaconProcessor { let data_column_slot = verified_data_column.slot(); let data_column_index = verified_data_column.index(); + if let DataColumnSidecar::Fulu(col) = verified_data_column.as_data_column() + && self + .chain + .data_availability_checker + .partial_assembler() + .is_some_and(|a| !a.is_complete(block_root, verified_data_column.index())) + { + metrics::inc_counter_vec( + &metrics::BEACON_USEFUL_FULL_COLUMNS_RECEIVED_TOTAL, + &[&data_column_index.to_string()], + ); + + let mut column = col.to_partial(); + let header = column.sidecar.header.take(); + if let Some(header) = header { + self.send_network_message(NetworkMessage::PublishPartialColumns { + columns: vec![Arc::new(column)], + header: Arc::new(header), + }); + } else { + crit!("Converting from full to partial yielded headerless partial") + }; + } + let result = self .chain .process_gossip_data_columns(vec![verified_data_column], || Ok(())) @@ -1070,44 +1372,7 @@ impl NetworkBeaconProcessor { "Processed data column, waiting for other components" ); - if self - .chain - .data_availability_checker - .custody_context() - .should_attempt_reconstruction( - slot.epoch(T::EthSpec::slots_per_epoch()), - &self.chain.spec, - ) - { - // Instead of triggering reconstruction immediately, schedule it to be run. If - // another column arrives, it either completes availability or pushes - // reconstruction back a bit. - let cloned_self = Arc::clone(self); - let block_root = *block_root; - - if self - .beacon_processor_send - .try_send(WorkEvent { - drop_during_sync: false, - work: Work::Reprocess( - ReprocessQueueMessage::DelayColumnReconstruction( - QueuedColumnReconstruction { - block_root, - slot: *slot, - process_fn: Box::pin(async move { - cloned_self - .attempt_data_column_reconstruction(block_root) - .await; - }), - }, - ), - ), - }) - .is_err() - { - warn!("Unable to send reconstruction to reprocessing"); - } - } + self.check_reconstruction_trigger(*slot, block_root).await; } }, Err(BlockError::DuplicateFullyImported(_)) => { @@ -1143,6 +1408,183 @@ impl NetworkBeaconProcessor { } } + /// Process a gossip-verified partial data column by merging it in the assembler + async fn process_gossip_verified_partial_data_column( + self: &Arc, + _peer_id: PeerId, + verified_partial: KzgVerifiedPartialDataColumn, + verified_header: GossipVerifiedPartialDataColumnHeader, + slot: Slot, + ) { + let processing_start_time = Instant::now(); + let block_root = verified_partial.block_root(); + let data_column_index = verified_partial.index(); + + let result = self + .chain + .process_gossip_partial_data_column(verified_partial, verified_header.clone(), slot) + .await; + + // First, handle merge results (if any) + let result = match result { + Ok(Some((avail, merge_result))) => { + if !merge_result.full_columns.is_empty() { + debug!( + %block_root, + index = data_column_index, + "Partial data column completed to full column" + ); + + self.send_network_message(NetworkMessage::Publish { + messages: merge_result + .full_columns + .into_iter() + .map(|col| { + let subnet = DataColumnSubnetId::from_column_index( + col.index(), + &self.chain.spec, + ); + PubsubMessage::DataColumnSidecar(Box::new(( + subnet, + col.into_inner(), + ))) + }) + .collect(), + }); + } + + let only_send_completed_partials = + merge_result.local_blobs || self.chain.config.disable_get_blobs; + let columns = merge_result + .updated_partials + .into_iter() + .map(|partial| partial.into_inner()) + .filter(|partial| { + !only_send_completed_partials || partial.sidecar.is_complete() + }) + .collect::>(); + + if !columns.is_empty() { + if only_send_completed_partials { + debug!( + block = %block_root, + "Not publishing incomplete partials before getBlobs" + ); + } + self.send_network_message(NetworkMessage::PublishPartialColumns { + columns, + header: verified_header.into_header(), + }); + } + Ok(avail) + } + Ok(None) => { + // Column was not merged because it is not a custody column. + return; + } + Err(err) => Err(err), + }; + + register_process_result_metrics( + &result, + metrics::BlockSource::Gossip, + "partial_data_column", + ); + + match &result { + Ok(availability) => match availability { + AvailabilityProcessingStatus::Imported(block_root) => { + debug!( + %block_root, + "Data column from partial processed, imported fully available block" + ); + self.chain.recompute_head_at_current_slot().await; + + metrics::set_gauge( + &metrics::BEACON_BLOB_DELAY_FULL_VERIFICATION, + processing_start_time.elapsed().as_millis() as i64, + ); + } + AvailabilityProcessingStatus::MissingComponents(slot, block_root) => { + trace!( + %slot, + %data_column_index, + %block_root, + "Processed data column from partial, waiting for other components" + ); + + self.check_reconstruction_trigger(*slot, block_root).await; + } + }, + Err(BlockError::DuplicateFullyImported(_)) => { + debug!( + ?block_root, + data_column_index, "Ignoring completed gossip column already imported" + ); + } + Err(err) => { + debug!( + outcome = ?err, + ?block_root, + block_slot = %slot, + data_column_index, + "Invalid completed gossip data column" + ); + // We can't really penalize here, as the error might be the fault of another peer + // contributing to the partial. + } + } + + // If a block is in the da_checker, sync maybe awaiting for an event when block is finally + // imported. A block can become imported both after processing a block or data column. If a + // importing a block results in `Imported`, notify. Do not notify of data column errors. + if matches!(result, Ok(AvailabilityProcessingStatus::Imported(_))) { + self.send_sync_message(SyncMessage::GossipBlockProcessResult { + block_root, + imported: true, + }); + } + } + + async fn check_reconstruction_trigger(self: &Arc, slot: Slot, block_root: &Hash256) { + if self + .chain + .data_availability_checker + .custody_context() + .should_attempt_reconstruction( + slot.epoch(T::EthSpec::slots_per_epoch()), + &self.chain.spec, + ) + { + // Instead of triggering reconstruction immediately, schedule it to be run. If + // another column arrives, it either completes availability or pushes + // reconstruction back a bit. + let cloned_self = Arc::clone(self); + let block_root = *block_root; + + if self + .beacon_processor_send + .try_send(WorkEvent { + drop_during_sync: false, + work: Work::Reprocess(ReprocessQueueMessage::DelayColumnReconstruction( + QueuedColumnReconstruction { + block_root, + slot, + process_fn: Box::pin(async move { + cloned_self + .attempt_data_column_reconstruction(block_root) + .await; + }), + }, + )), + }) + .is_err() + { + warn!("Unable to send reconstruction to reprocessing"); + } + } + } + /// Process the beacon block received from the gossip network and: /// /// - If it passes gossip propagation criteria, tell the network thread to forward it. @@ -1499,23 +1941,21 @@ impl NetworkBeaconProcessor { // Block is gossip valid. Attempt to fetch blobs from the EL using versioned hashes derived // from kzg commitments, without having to wait for all blobs to be sent from the peers. - // TODO(gloas) we'll want to use this same optimization, but we need to refactor the - // `fetch_and_process_engine_blobs` flow to support gloas. - if !block.fork_name_unchecked().gloas_enabled() { - let publish_blobs = true; - let self_clone = self.clone(); - let block_clone = block.clone(); - let current_span = Span::current(); - self.executor.spawn( - async move { + let publish_blobs = true; + let self_clone = self.clone(); + let block_clone = block.clone(); + let current_span = Span::current(); + self.executor.spawn( + async move { + if let Ok(header) = PartialDataColumnHeader::try_from(block_clone.as_ref()) { self_clone - .fetch_engine_blobs_and_publish(block_clone, block_root, publish_blobs) + .fetch_engine_blobs_and_publish(Arc::new(header), block_root, publish_blobs) .await } - .instrument(current_span), - "fetch_blobs_gossip", - ); - } + } + .instrument(current_span), + "fetch_blobs_gossip", + ); let result = self .chain diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index 2b354aaa20..015b6a616e 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -20,7 +20,7 @@ use lighthouse_network::rpc::methods::{ }; use lighthouse_network::service::api_types::CustodyBackfillBatchId; use lighthouse_network::{ - Client, MessageId, NetworkGlobals, PeerId, PubsubMessage, + Client, GossipTopic, MessageId, NetworkGlobals, PeerId, PubsubMessage, rpc::{BlocksByRangeRequest, BlocksByRootRequest, LightClientBootstrapRequest, StatusMessage}, }; use rand::prelude::SliceRandom; @@ -251,6 +251,32 @@ impl NetworkBeaconProcessor { }) } + /// Create a new `Work` event for some partial data column sidecar. + pub fn send_gossip_partial_data_column_sidecar( + self: &Arc, + peer_id: PeerId, + column_sidecar: Box>, + seen_timestamp: Duration, + topic: GossipTopic, + ) -> Result<(), Error> { + let processor = self.clone(); + let process_fn = async move { + processor + .process_gossip_partial_data_column_sidecar( + peer_id, + column_sidecar, + seen_timestamp, + topic, + ) + .await + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: false, + work: Work::GossipPartialDataColumnSidecar(Box::pin(process_fn)), + }) + } + /// Create a new `Work` event for some sync committee signature. pub fn send_gossip_sync_signature( self: &Arc, @@ -894,14 +920,14 @@ impl NetworkBeaconProcessor { pub async fn fetch_engine_blobs_and_publish( self: &Arc, - block: Arc>>, + header: Arc>, block_root: Hash256, publish_blobs: bool, ) { if self.chain.config.disable_get_blobs { return; } - let epoch = block.slot().epoch(T::EthSpec::slots_per_epoch()); + let epoch = header.slot().epoch(T::EthSpec::slots_per_epoch()); let custody_columns = self.chain.sampling_columns_for_epoch(epoch); let self_cloned = self.clone(); let publish_fn = move |blobs_or_data_column| { @@ -926,7 +952,7 @@ impl NetworkBeaconProcessor { match fetch_and_process_engine_blobs( self.chain.clone(), block_root, - block.clone(), + header.clone(), custody_columns, publish_fn, ) @@ -970,6 +996,23 @@ impl NetworkBeaconProcessor { ); } } + + // Publish partial columns without eager send + if let Some(assembler) = self.chain.data_availability_checker.partial_assembler() { + let columns = assembler.get_partials_and_mark_as_local_fetched(block_root, &header); + if !columns.is_empty() { + debug!(block = %block_root, "Publishing all partials after getBlobs"); + self.send_network_message(NetworkMessage::PublishPartialColumns { + columns: columns + .into_iter() + .map(|partial| partial.into_inner()) + .collect(), + header, + }); + } else { + debug!(block = %block_root, "No partials to publish after getBlobs"); + } + } } /// Attempts to reconstruct all data columns if the conditions checked in 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 f7fbce8e56..8f89b66948 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -218,9 +218,15 @@ impl NetworkBeaconProcessor { // Block is valid, we can now attempt fetching blobs from EL using version hashes // derived from kzg commitments from the block, without having to wait for all blobs // to be sent from the peers if we already have them. - let publish_blobs = false; - self.fetch_engine_blobs_and_publish(signed_beacon_block, block_root, publish_blobs) + if let Ok(header) = signed_beacon_block.as_ref().try_into() { + let publish_blobs = false; + self.fetch_engine_blobs_and_publish( + Arc::new(header), + block_root, + publish_blobs, + ) .await; + } } _ => {} } diff --git a/beacon_node/network/src/router.rs b/beacon_node/network/src/router.rs index 3f0e329e91..443fa51cc6 100644 --- a/beacon_node/network/src/router.rs +++ b/beacon_node/network/src/router.rs @@ -14,7 +14,7 @@ use beacon_processor::{BeaconProcessorSend, DuplicateCache}; use futures::prelude::*; use lighthouse_network::rpc::*; use lighthouse_network::{ - MessageId, NetworkGlobals, PeerId, PubsubMessage, Response, + GossipTopic, MessageId, NetworkGlobals, PeerId, PubsubMessage, Response, service::api_types::{AppRequestId, SyncRequestId}, }; use logging::TimeLatch; @@ -24,7 +24,9 @@ use std::sync::Arc; use tokio::sync::mpsc; use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::{debug, error, trace, warn}; -use types::{BlobSidecar, DataColumnSidecar, EthSpec, ForkContext, SignedBeaconBlock}; +use types::{ + BlobSidecar, DataColumnSidecar, EthSpec, ForkContext, PartialDataColumn, SignedBeaconBlock, +}; /// Handles messages from the network and routes them to the appropriate service to be handled. pub struct Router { @@ -69,6 +71,8 @@ pub enum RouterMessage { /// message, the message itself and a bool which indicates if the message should be processed /// by the beacon chain after successful verification. PubsubMessage(MessageId, PeerId, PubsubMessage, bool), + /// A partial data column sidecar has been received via gossipsub partial protocol. + PartialDataColumnSidecar(PeerId, Box>, GossipTopic), /// The peer manager has requested we re-status a peer. StatusPeer(PeerId), /// The peer has an updated custody group count from METADATA. @@ -180,6 +184,16 @@ impl Router { RouterMessage::PubsubMessage(id, peer_id, gossip, should_process) => { self.handle_gossip(id, peer_id, gossip, should_process); } + RouterMessage::PartialDataColumnSidecar(peer_id, column, topic) => self + .handle_beacon_processor_send_result( + self.network_beacon_processor + .send_gossip_partial_data_column_sidecar( + peer_id, + column, + self.chain.slot_clock.now_duration().unwrap_or_default(), + topic, + ), + ), } } diff --git a/beacon_node/network/src/service.rs b/beacon_node/network/src/service.rs index af56b80822..ce54ffc38f 100644 --- a/beacon_node/network/src/service.rs +++ b/beacon_node/network/src/service.rs @@ -39,8 +39,8 @@ use tokio::time::Sleep; use tracing::{debug, error, info, trace, warn}; use typenum::Unsigned; use types::{ - EthSpec, ForkContext, Slot, SubnetId, SyncCommitteeSubscription, SyncSubnetId, - ValidatorSubscription, + EthSpec, ForkContext, PartialDataColumn, PartialDataColumnHeader, Slot, SubnetId, + SyncCommitteeSubscription, SyncSubnetId, ValidatorSubscription, }; mod tests; @@ -83,6 +83,11 @@ pub enum NetworkMessage { }, /// Publish a list of messages to the gossipsub protocol. Publish { messages: Vec> }, + /// Publish partial data column sidecars via the partial gossipsub protocol. + PublishPartialColumns { + columns: Vec>>, + header: Arc>, + }, /// Validates a received gossipsub message. This will propagate the message on the network. ValidationResult { /// The peer that sent us the message. We don't send back to this peer. @@ -92,6 +97,13 @@ pub enum NetworkMessage { /// The result of the validation validation_result: MessageAcceptance, }, + /// Reports validation failure of a partial message. + PartialValidationFailure { + /// The peer that sent us the message. + propagation_source: PeerId, + /// The topic of the message. + gossip_topic: GossipTopic, + }, /// Reports a peer to the peer manager for performing an action. ReportPeer { peer_id: PeerId, @@ -540,7 +552,7 @@ impl NetworkService { let subnet_id = subnet_and_attestation.0; let attestation = &subnet_and_attestation.1; // checks if we have an aggregator for the slot. If so, we should process - // the attestation, else we just just propagate the Attestation. + // the attestation, else we just propagate the Attestation. let should_process = self.subnet_service.should_process_attestation( Subnet::Attestation(subnet_id), &attestation.data, @@ -560,6 +572,15 @@ impl NetworkService { } } } + NetworkEvent::PartialDataColumnSidecar { + source, + column, + topic, + } => { + self.send_to_router(RouterMessage::PartialDataColumnSidecar( + source, column, topic, + )); + } NetworkEvent::NewListenAddr(multiaddr) => { self.network_globals .listen_multiaddrs @@ -640,11 +661,19 @@ impl NetworkService { validation_result, ); } + NetworkMessage::PartialValidationFailure { + propagation_source, + gossip_topic, + } => { + self.libp2p + .report_partial_message_validation_failure(propagation_source, gossip_topic); + } NetworkMessage::Publish { messages } => { let mut topic_kinds = Vec::new(); for message in &messages { - if !topic_kinds.contains(&message.kind()) { - topic_kinds.push(message.kind()); + let kind = message.kind(); + if !topic_kinds.contains(&kind) { + topic_kinds.push(kind); } } debug!( @@ -654,6 +683,9 @@ impl NetworkService { ); self.libp2p.publish(messages); } + NetworkMessage::PublishPartialColumns { columns, header } => { + self.libp2p.publish_partial(columns, header); + } NetworkMessage::ReportPeer { peer_id, action, diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index 394f2fc37d..3929f74aa0 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -45,7 +45,7 @@ use std::sync::Arc; use std::time::Duration; use store::Hash256; use tracing::{debug, error, warn}; -use types::{BlobSidecar, DataColumnSidecar, EthSpec, SignedBeaconBlock}; +use types::{EthSpec, SignedBeaconBlock}; pub mod common; pub mod parent_chain; @@ -77,22 +77,21 @@ const LOOKUP_MAX_DURATION_NO_PEERS_SECS: u64 = 10; /// take at most 2 GB. 200 lookups allow 3 parallel chains of depth 64 (current maximum). const MAX_LOOKUPS: usize = 200; +/// The values for `Blob`, `DataColumn` and `PartialDataColumn` is the parent root of the column. pub enum BlockComponent { Block(DownloadResult>>), - Blob(DownloadResult>>), - DataColumn(DownloadResult>>), + Blob(DownloadResult), + DataColumn(DownloadResult), + PartialDataColumn(DownloadResult), } impl BlockComponent { fn parent_root(&self) -> Hash256 { match self { BlockComponent::Block(block) => block.value.parent_root(), - BlockComponent::Blob(blob) => blob.value.block_parent_root(), - BlockComponent::DataColumn(column) => match column.value.as_ref() { - DataColumnSidecar::Fulu(column) => column.block_parent_root(), - // TODO(gloas) we don't have a parent root post gloas, not sure what to do here - DataColumnSidecar::Gloas(column) => column.beacon_block_root, - }, + BlockComponent::Blob(parent_root) + | BlockComponent::DataColumn(parent_root) + | BlockComponent::PartialDataColumn(parent_root) => parent_root.value, } } fn get_type(&self) -> &'static str { @@ -100,6 +99,7 @@ impl BlockComponent { BlockComponent::Block(_) => "block", BlockComponent::Blob(_) => "blob", BlockComponent::DataColumn(_) => "data_column", + BlockComponent::PartialDataColumn(_) => "partial_data_column", } } } 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 919526c238..23bfd531f0 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 @@ -156,7 +156,9 @@ impl SingleBlockLookup { .block_request_state .state .insert_verified_response(block), - BlockComponent::Blob(_) | BlockComponent::DataColumn(_) => { + BlockComponent::Blob(_) + | BlockComponent::DataColumn(_) + | BlockComponent::PartialDataColumn(_) => { // For now ignore single blobs and columns, as the blob request state assumes all blobs are // attributed to the same peer = the peer serving the remaining blobs. Ignoring this // block component has a minor effect, causing the node to re-request this blob diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 60dcc3efc7..734295ac1d 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -141,6 +141,14 @@ pub enum SyncMessage { /// A data column with an unknown parent has been received. UnknownParentDataColumn(PeerId, Arc>), + /// A partial data column with an unknown parent has been received. + UnknownParentPartialDataColumn { + peer_id: PeerId, + block_root: Hash256, + parent_root: Hash256, + slot: Slot, + }, + /// A peer has sent an attestation that references a block that is unknown. This triggers the /// manager to attempt to find the block matching the unknown hash. UnknownBlockHashFromAttestation(PeerId, Hash256), @@ -866,7 +874,7 @@ impl SyncManager { parent_root, blob_slot, BlockComponent::Blob(DownloadResult { - value: blob, + value: parent_root, block_root, seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), peer_group: PeerGroup::from_single(peer_id), @@ -886,7 +894,7 @@ impl SyncManager { parent_root, data_column_slot, BlockComponent::DataColumn(DownloadResult { - value: data_column, + value: parent_root, block_root, seen_timestamp: self .chain @@ -903,6 +911,26 @@ impl SyncManager { } } } + SyncMessage::UnknownParentPartialDataColumn { + peer_id, + block_root, + parent_root, + slot, + } => { + debug!(%block_root, %parent_root, "Received unknown parent partial column message"); + self.handle_unknown_parent( + peer_id, + block_root, + parent_root, + slot, + BlockComponent::PartialDataColumn(DownloadResult { + value: parent_root, + block_root, + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), + peer_group: PeerGroup::from_single(peer_id), + }), + ); + } SyncMessage::UnknownBlockHashFromAttestation(peer_id, block_root) => { if !self.notified_unknown_roots.contains(&(peer_id, block_root)) { self.notified_unknown_roots.insert((peer_id, block_root)); diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index 61dccc9674..51cda0fac3 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -670,6 +670,15 @@ pub fn cli_app() -> Command { .hide(true) .display_order(0) ) + .arg( + Arg::new("enable-partial-columns") + .long("enable-partial-columns") + .help("Enable partial messages for data columns. This can reduce the amount of \ + data sent over the network.") + .action(ArgAction::SetTrue) + .help_heading(FLAG_HEADER) + .display_order(0) + ) /* * Monitoring metrics */ diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index 0a52bcef06..8ba2c0f321 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -110,6 +110,21 @@ pub fn get_config( set_network_config(&mut client_config.network, cli_args, &data_dir_ref)?; + if parse_flag(cli_args, "enable-partial-columns") { + // Partial messages assume that each subnet maps to exactly one column. + // Check this here to avoid weird issues on networks where this is not the case. + if spec.data_column_sidecar_subnet_count == E::number_of_columns() as u64 { + client_config.network.enable_partial_columns = true; + client_config.chain.enable_partial_columns = true; + } else { + warn!( + subnets = spec.data_column_sidecar_subnet_count, + columns = E::number_of_columns(), + "Not enabling partial columns on networks with multiple columns per subnet" + ) + } + } + // Parse custody mode from CLI flags let is_supernode = parse_flag(cli_args, "supernode"); let is_semi_supernode = parse_flag(cli_args, "semi-supernode"); diff --git a/book/src/help_bn.md b/book/src/help_bn.md index cad21a3e78..b580bcae52 100644 --- a/book/src/help_bn.md +++ b/book/src/help_bn.md @@ -497,6 +497,9 @@ Flags: Sets the local ENR IP address and port to match those set for lighthouse. Specifically, the IP address will be the value of --listen-address and the UDP port will be --discovery-port. + --enable-partial-columns + Enable partial messages for data columns. This can reduce the amount + of data sent over the network. --enable-private-discovery Lighthouse by default does not discover private IP addresses. Set this flag to enable connection attempts to local addresses. diff --git a/consensus/types/src/block/beacon_block_body.rs b/consensus/types/src/block/beacon_block_body.rs index cd3f4dcaba..25695dbdda 100644 --- a/consensus/types/src/block/beacon_block_body.rs +++ b/consensus/types/src/block/beacon_block_body.rs @@ -3,14 +3,14 @@ use std::marker::PhantomData; use bls::Signature; use context_deserialize::{ContextDeserialize, context_deserialize}; use educe::Educe; -use merkle_proof::{MerkleTree, MerkleTreeError}; +use merkle_proof::MerkleTree; use metastruct::metastruct; use serde::{Deserialize, Deserializer, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::{FixedVector, VariableList}; use superstruct::superstruct; use test_random_derive::TestRandom; -use tree_hash::{BYTES_PER_CHUNK, TreeHash}; +use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use crate::{ @@ -18,6 +18,7 @@ use crate::{ attestation::{ AttestationBase, AttestationElectra, AttestationRef, AttestationRefMut, PayloadAttestation, }, + complete_kzg_commitment_merkle_proof, core::{EthSpec, Graffiti, Hash256}, deposit::Deposit, execution::{ @@ -272,46 +273,11 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> BeaconBlockBodyRef<'a, E, | Self::Capella(_) | Self::Gloas(_) => Err(BeaconStateError::IncorrectStateVariant), Self::Deneb(_) | Self::Electra(_) | Self::Fulu(_) => { - // We compute the branches by generating 2 merkle trees: - // 1. Merkle tree for the `blob_kzg_commitments` List object - // 2. Merkle tree for the `BeaconBlockBody` container - // We then merge the branches for both the trees all the way up to the root. - - // Part1 (Branches for the subtree rooted at `blob_kzg_commitments`) - // - // Branches for `blob_kzg_commitments` without length mix-in - let blob_leaves = self - .blob_kzg_commitments()? - .iter() - .map(|commitment| commitment.tree_hash_root()) - .collect::>(); - let depth = E::max_blob_commitments_per_block() - .next_power_of_two() - .ilog2(); - let tree = MerkleTree::create(&blob_leaves, depth as usize); - let (_, mut proof) = tree - .generate_proof(index, depth as usize) - .map_err(BeaconStateError::MerkleTreeError)?; - - // Add the branch corresponding to the length mix-in. - let length = blob_leaves.len(); - let usize_len = std::mem::size_of::(); - let mut length_bytes = [0; BYTES_PER_CHUNK]; - length_bytes - .get_mut(0..usize_len) - .ok_or(BeaconStateError::MerkleTreeError( - MerkleTreeError::PleaseNotifyTheDevs, - ))? - .copy_from_slice(&length.to_le_bytes()); - let length_root = Hash256::from_slice(length_bytes.as_slice()); - proof.push(length_root); - - // Part 2 - // Branches for `BeaconBlockBody` container - // Join the proofs for the subtree and the main tree - proof.extend_from_slice(kzg_commitments_proof); - - Ok(FixedVector::new(proof)?) + complete_kzg_commitment_merkle_proof::( + self.blob_kzg_commitments()?, + index, + kzg_commitments_proof, + ) } } } diff --git a/consensus/types/src/data/blob_sidecar.rs b/consensus/types/src/data/blob_sidecar.rs index 2774176190..70b95615e5 100644 --- a/consensus/types/src/data/blob_sidecar.rs +++ b/consensus/types/src/data/blob_sidecar.rs @@ -19,9 +19,9 @@ use crate::{ block::{ BLOB_KZG_COMMITMENTS_INDEX, BeaconBlockHeader, SignedBeaconBlock, SignedBeaconBlockHeader, }, + complete_kzg_commitment_merkle_proof, core::{ChainSpec, Epoch, EthSpec, Hash256, Slot}, - data::Blob, - execution::AbstractExecPayload, + data::{Blob, PartialDataColumnHeader}, fork::ForkName, kzg_ext::KzgProofs, state::BeaconStateError, @@ -140,33 +140,29 @@ impl BlobSidecar { }) } - pub fn new_with_existing_proof>( + pub fn new_with_existing_proof>>( index: usize, blob: Blob, - signed_block: &SignedBeaconBlock, - signed_block_header: SignedBeaconBlockHeader, - kzg_commitments_inclusion_proof: &[Hash256], + header: T, kzg_proof: KzgProof, ) -> Result { - let expected_kzg_commitments = signed_block - .message() - .body() - .blob_kzg_commitments() - .map_err(|_e| BlobSidecarError::PreDeneb)?; - let kzg_commitment = *expected_kzg_commitments + let header = header.try_into().map_err(|_| BlobSidecarError::PreDeneb)?; + let kzg_commitment = *header + .kzg_commitments .get(index) .ok_or(BlobSidecarError::MissingKzgCommitment)?; - let kzg_commitment_inclusion_proof = signed_block - .message() - .body() - .complete_kzg_commitment_merkle_proof(index, kzg_commitments_inclusion_proof)?; + let kzg_commitment_inclusion_proof = complete_kzg_commitment_merkle_proof::( + &header.kzg_commitments, + index, + &header.kzg_commitments_inclusion_proof, + )?; Ok(Self { index: index as u64, blob, kzg_commitment, kzg_proof, - signed_block_header, + signed_block_header: header.signed_block_header, kzg_commitment_inclusion_proof, }) } diff --git a/consensus/types/src/data/data_column_sidecar.rs b/consensus/types/src/data/data_column_sidecar.rs index c8a49e346a..109c9472a5 100644 --- a/consensus/types/src/data/data_column_sidecar.rs +++ b/consensus/types/src/data/data_column_sidecar.rs @@ -19,6 +19,10 @@ use tree_hash_derive::TreeHash; use crate::{ block::{BLOB_KZG_COMMITMENTS_INDEX, BeaconBlockHeader, SignedBeaconBlockHeader}, core::{Epoch, EthSpec, Hash256, Slot}, + data::{ + CellBitmap, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnSidecar, + PartialDataColumnSidecarError, PartialDataColumnSidecarRef, + }, fork::ForkName, kzg_ext::{KzgCommitments, KzgError}, state::BeaconStateError, @@ -136,6 +140,49 @@ impl DataColumnSidecar { )), } } + + /// Convert this full data column into a partial data column reference for KZG verification. + /// The header will NOT be set. + /// + /// Uses the supplied filter to determine which cells to include in the partial sidecar. + pub fn try_filter_to_partial_ref( + &self, + filter: F, + ) -> Result>, Err> + where + F: Fn(usize, &Cell, &KzgProof) -> Result, + Err: From, + { + let len = self.column().len(); + let mut new_bitmap = CellBitmap::::with_capacity(len) + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?; + let mut new_column = Vec::with_capacity(len); + let mut new_proofs = Vec::with_capacity(len); + let iter = self.column().iter().zip(self.kzg_proofs().iter()); + + for (blob_idx, (cell, proof)) in iter.enumerate() { + if filter(blob_idx, cell, proof)? { + // Keep this cell + new_column.push(cell); + new_proofs.push(proof); + // Mark as present + new_bitmap + .set(blob_idx, true) + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?; + } + } + + if new_column.is_empty() { + return Ok(None); + } + + Ok(Some(PartialDataColumnSidecarRef { + cells_present_bitmap: new_bitmap, + column: new_column, + kzg_proofs: new_proofs, + header: None.into(), + })) + } } impl DataColumnSidecarFulu { @@ -204,6 +251,36 @@ impl DataColumnSidecarFulu { .as_ssz_bytes() .len() } + + /// Convert this full data column into a verifiable partial data column. + pub fn to_partial(&self) -> PartialDataColumn { + let cell_count = self.column.len(); + let mut bitmap = + CellBitmap::::with_capacity(cell_count).expect("our column has the same bound"); + for idx in 0..cell_count { + bitmap + .set(idx, true) + .expect("The correct size is initialized right above"); + } + + let block_root = self.block_root(); + + PartialDataColumn { + block_root, + index: self.index, + sidecar: PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column: self.column.clone(), + kzg_proofs: self.kzg_proofs.clone(), + header: Some(PartialDataColumnHeader { + kzg_commitments: self.kzg_commitments.clone(), + signed_block_header: self.signed_block_header.clone(), + kzg_commitments_inclusion_proof: self.kzg_commitments_inclusion_proof.clone(), + }) + .into(), + }, + } + } } impl DataColumnSidecarGloas { diff --git a/consensus/types/src/data/mod.rs b/consensus/types/src/data/mod.rs index 4125b6072b..9c7eb42626 100644 --- a/consensus/types/src/data/mod.rs +++ b/consensus/types/src/data/mod.rs @@ -2,6 +2,7 @@ mod blob_sidecar; mod data_column_custody_group; mod data_column_sidecar; mod data_column_subnet_id; +mod partial_data_column_sidecar; pub use blob_sidecar::{ BlobIdentifier, BlobSidecar, BlobSidecarError, BlobSidecarList, BlobsList, FixedBlobSidecarList, @@ -17,6 +18,10 @@ pub use data_column_sidecar::{ DataColumnsByRootIdentifier, }; pub use data_column_subnet_id::{DataColumnSubnetId, all_data_column_sidecar_subnets_from_spec}; +pub use partial_data_column_sidecar::{ + CellBitmap, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnPartsMetadata, + PartialDataColumnSidecar, PartialDataColumnSidecarError, PartialDataColumnSidecarRef, +}; use crate::core::EthSpec; use ssz_types::FixedVector; diff --git a/consensus/types/src/data/partial_data_column_sidecar.rs b/consensus/types/src/data/partial_data_column_sidecar.rs new file mode 100644 index 0000000000..df65be1ae3 --- /dev/null +++ b/consensus/types/src/data/partial_data_column_sidecar.rs @@ -0,0 +1,429 @@ +use crate::{ + block::{BLOB_KZG_COMMITMENTS_INDEX, SignedBeaconBlock, SignedBeaconBlockHeader}, + core::{EthSpec, Hash256, Slot}, + data::{Cell, ColumnIndex, DataColumnSidecar, DataColumnSidecarFulu}, + execution::AbstractExecPayload, + kzg_ext::KzgCommitments, + state::BeaconStateError, + test_utils::TestRandom, +}; +use educe::Educe; +use kzg::KzgProof; +use merkle_proof::verify_merkle_proof; +use ssz::BitList; +use ssz_derive::{Decode, Encode}; +use ssz_types::{FixedVector, ListEncodedOption, VariableList}; +use std::fmt::Display; +use test_random_derive::TestRandom; +use tree_hash::TreeHash; +use tree_hash_derive::TreeHash; + +pub type CellBitmap = BitList<::MaxBlobCommitmentsPerBlock>; + +#[cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") +)] +#[derive(Debug, Clone, Encode, Decode, TreeHash, Educe)] +#[educe(PartialEq, Eq, Hash(bound = "E: EthSpec"))] +pub struct PartialDataColumnSidecar { + pub cells_present_bitmap: CellBitmap, + pub column: VariableList, E::MaxBlobCommitmentsPerBlock>, + pub kzg_proofs: VariableList, + pub header: ListEncodedOption>, +} + +/// Equivalent to `PartialDataColumnSidecar`, but containing references to the cells. This is done +/// so that we can get a part of a sidecar without expensively cloning all the contents. +#[derive(Debug, Clone, Encode)] +pub struct PartialDataColumnSidecarRef<'a, E: EthSpec> { + pub cells_present_bitmap: CellBitmap, + // It is fine to use `Vec` here as we never decode directly into this type, and only create + // this from the `PartialDataColumnSidecar` type above. This avoids a few ugly `expect` calls. + pub column: Vec<&'a Cell>, + pub kzg_proofs: Vec<&'a KzgProof>, + pub header: ListEncodedOption<&'a PartialDataColumnHeader>, +} + +#[derive(Debug, Clone, Copy)] +pub enum PartialDataColumnSidecarError { + UnexpectedBounds, + InternallyInconsistent, + DifferingLengths { lhs_len: usize, rhs_len: usize }, + ConflictingData, +} + +impl PartialDataColumnSidecar { + pub fn is_complete(&self) -> bool { + self.cells_present_bitmap.num_set_bits() == self.cells_present_bitmap.len() + } + + pub fn get(&self, idx: usize) -> Option<(&Cell, &KzgProof)> { + if !self.cells_present_bitmap.get(idx).unwrap_or(false) { + return None; + } + let storage_idx = self + .cells_present_bitmap + .iter() + .take(idx) + .filter(|b| *b) + .count(); + self.column + .get(storage_idx) + .and_then(|cell| self.kzg_proofs.get(storage_idx).map(|proof| (cell, proof))) + } + + /// Creates a reference to this sidecar containing only the blob indices for which the passed + /// closure returns `true` and is present in `self`. Will return `None` if there is no overlap. + pub fn filter( + &self, + filter: F, + ) -> Result>, PartialDataColumnSidecarError> + where + F: Fn(usize) -> bool, + { + let len = self.verify_len()?; + + let mut new_bitmap = self.cells_present_bitmap.clone(); + let mut new_column = Vec::with_capacity(len); + let mut new_proofs = Vec::with_capacity(len); + let mut iter = self.column.iter().zip(self.kzg_proofs.iter()); + + for (blob_idx, present) in self.cells_present_bitmap.iter().enumerate() { + if present { + let (cell, proof) = iter + .next() + .ok_or(PartialDataColumnSidecarError::UnexpectedBounds)?; + if filter(blob_idx) { + // Keep this cell + new_column.push(cell); + new_proofs.push(proof); + } else { + // Mark as not present + new_bitmap + .set(blob_idx, false) + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?; + } + } + } + + if new_column.is_empty() { + return Ok(None); + } + + Ok(Some(PartialDataColumnSidecarRef { + cells_present_bitmap: new_bitmap, + column: new_column, + kzg_proofs: new_proofs, + header: self.header.as_ref().into(), + })) + } + + pub fn verify_len(&self) -> Result { + let len = self.cells_present_bitmap.num_set_bits(); + if len != self.kzg_proofs.len() || len != self.column.len() { + return Err(PartialDataColumnSidecarError::InternallyInconsistent); + } + Ok(len) + } +} + +#[cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") +)] +#[derive(Debug, Clone, Encode, Decode, TreeHash, TestRandom, Educe)] +#[educe(PartialEq, Eq, Hash(bound = "E: EthSpec"))] +pub struct PartialDataColumnHeader { + pub kzg_commitments: KzgCommitments, + pub signed_block_header: SignedBeaconBlockHeader, + pub kzg_commitments_inclusion_proof: FixedVector, +} + +impl PartialDataColumnHeader { + pub fn slot(&self) -> Slot { + self.signed_block_header.message.slot + } + + pub fn verify_inclusion_proof(&self) -> bool { + let blob_kzg_commitments_root = self.kzg_commitments.tree_hash_root(); + + verify_merkle_proof( + blob_kzg_commitments_root, + &self.kzg_commitments_inclusion_proof, + E::kzg_commitments_inclusion_proof_depth(), + BLOB_KZG_COMMITMENTS_INDEX, + self.signed_block_header.message.body_root, + ) + } +} + +impl> TryFrom<&SignedBeaconBlock> + for PartialDataColumnHeader +{ + type Error = BeaconStateError; + + fn try_from(block: &SignedBeaconBlock) -> Result { + Ok(Self { + kzg_commitments: block.message().body().blob_kzg_commitments()?.clone(), + signed_block_header: block.signed_block_header(), + kzg_commitments_inclusion_proof: block + .message() + .body() + .kzg_commitments_merkle_proof()?, + }) + } +} + +#[derive(Debug, Clone, Encode, Decode, PartialEq, Eq)] +pub struct PartialDataColumnPartsMetadata { + pub available: CellBitmap, + pub requests: CellBitmap, +} + +impl Display for PartialDataColumnPartsMetadata { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "(available: {}, requested: {})", + self.available, self.requests + ) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PartialDataColumn { + pub block_root: Hash256, + pub index: ColumnIndex, + pub sidecar: PartialDataColumnSidecar, +} + +impl PartialDataColumn { + /// Equivalent to a call to `clone` followed by `try_into_full`, but returns early if conversion + /// is not possible. + pub fn try_clone_full( + &self, + header: &PartialDataColumnHeader, + ) -> Option> { + if !self.sidecar.is_complete() { + return None; + } + + Some(DataColumnSidecar::Fulu(DataColumnSidecarFulu { + index: self.index, + column: self.sidecar.column.clone(), + kzg_commitments: header.kzg_commitments.clone(), + kzg_proofs: self.sidecar.kzg_proofs.clone(), + signed_block_header: header.signed_block_header.clone(), + kzg_commitments_inclusion_proof: header.kzg_commitments_inclusion_proof.clone(), + })) + } + + pub fn try_into_full( + self, + header: &PartialDataColumnHeader, + ) -> Option> { + if !self.sidecar.is_complete() { + return None; + } + + Some(DataColumnSidecar::Fulu(DataColumnSidecarFulu { + index: self.index, + column: self.sidecar.column, + kzg_commitments: header.kzg_commitments.clone(), + kzg_proofs: self.sidecar.kzg_proofs, + signed_block_header: header.signed_block_header.clone(), + kzg_commitments_inclusion_proof: header.kzg_commitments_inclusion_proof.clone(), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MinimalEthSpec; + use bls::Signature; + use fixed_bytes::FixedBytesExtended; + use kzg::KzgCommitment; + use ssz::Encode; + + type E = MinimalEthSpec; + + fn make_cell(marker: u8) -> Cell { + let mut cell = Cell::::default(); + cell[0] = marker; + cell + } + + fn make_sidecar_with_marker( + total_blobs: usize, + present_indices: &[usize], + marker_base: u8, + ) -> PartialDataColumnSidecar { + let mut bitmap = CellBitmap::::with_capacity(total_blobs).unwrap(); + for &idx in present_indices { + bitmap.set(idx, true).unwrap(); + } + + let column: VariableList<_, _> = present_indices + .iter() + .map(|&idx| make_cell(marker_base.wrapping_add(idx as u8))) + .collect::>() + .try_into() + .unwrap(); + let proofs: VariableList<_, _> = present_indices + .iter() + .map(|_| KzgProof::empty()) + .collect::>() + .try_into() + .unwrap(); + + PartialDataColumnSidecar { + cells_present_bitmap: bitmap, + column, + kzg_proofs: proofs, + header: None.into(), + } + } + + fn make_sidecar(total_blobs: usize, present_indices: &[usize]) -> PartialDataColumnSidecar { + make_sidecar_with_marker(total_blobs, present_indices, 0) + } + + fn make_header(num_commitments: usize) -> PartialDataColumnHeader { + PartialDataColumnHeader { + kzg_commitments: vec![KzgCommitment([0u8; 48]); num_commitments] + .try_into() + .unwrap(), + signed_block_header: SignedBeaconBlockHeader { + message: crate::BeaconBlockHeader { + slot: Slot::new(0), + proposer_index: 0, + parent_root: Hash256::zero(), + state_root: Hash256::zero(), + body_root: Hash256::zero(), + }, + signature: Signature::empty(), + }, + kzg_commitments_inclusion_proof: FixedVector::new( + vec![Hash256::zero(); E::kzg_commitments_inclusion_proof_depth()], + ) + .unwrap(), + } + } + + // -- filter tests -- + + #[test] + fn filter_keeps_matching_cells() { + let sidecar = make_sidecar(6, &[0, 2, 4]); + let filtered = sidecar.filter(|idx| idx == 0 || idx == 4).unwrap().unwrap(); + assert_eq!(filtered.column.len(), 2); + assert_eq!(filtered.kzg_proofs.len(), 2); + assert!(filtered.cells_present_bitmap.get(0).unwrap()); + assert!(!filtered.cells_present_bitmap.get(2).unwrap()); + assert!(filtered.cells_present_bitmap.get(4).unwrap()); + } + + #[test] + fn filter_returns_none_when_no_overlap() { + let sidecar = make_sidecar(6, &[0, 2, 4]); + assert!( + sidecar + .filter(|idx| idx == 1 || idx == 3) + .unwrap() + .is_none() + ); + } + + #[test] + fn filter_preserves_all_when_all_match() { + let sidecar = make_sidecar(6, &[0, 2, 4]); + let filtered = sidecar.filter(|_| true).unwrap().unwrap(); + assert_eq!(filtered.column.len(), 3); + assert_eq!(filtered.kzg_proofs.len(), 3); + assert_eq!(filtered.cells_present_bitmap, sidecar.cells_present_bitmap); + + // Also, check that the encoded version matches + assert_eq!(filtered.as_ssz_bytes(), sidecar.as_ssz_bytes()); + } + + // -- is_complete tests -- + + #[test] + fn is_complete_true_when_all_bits_set() { + let sidecar = make_sidecar(4, &[0, 1, 2, 3]); + assert!(sidecar.is_complete()); + } + + #[test] + fn is_complete_false_when_partial() { + let sidecar = make_sidecar(4, &[0, 2]); + assert!(!sidecar.is_complete()); + } + + // -- try_clone_full tests (on PartialDataColumn) -- + + #[test] + fn try_clone_full_succeeds_when_complete() { + let sidecar = make_sidecar(3, &[0, 1, 2]); + let header = make_header(3); + let partial = PartialDataColumn { + block_root: Hash256::zero(), + index: 5, + sidecar, + }; + let full = partial.try_clone_full(&header).unwrap(); + assert_eq!(*full.index(), 5); + assert_eq!(full.column().len(), 3); + } + + #[test] + fn try_clone_full_returns_none_when_incomplete() { + let sidecar = make_sidecar(4, &[0, 2]); + let header = make_header(4); + let partial = PartialDataColumn { + block_root: Hash256::zero(), + index: 0, + sidecar, + }; + assert!(partial.try_clone_full(&header).is_none()); + } + + // -- get tests -- + + #[test] + fn get_sparse_bitmap_maps_to_correct_storage_position() { + // bitmap: [false, true, false, true] → column: [cell_1, cell_3] + let sidecar = make_sidecar_with_marker(4, &[1, 3], 0); + let (cell, _) = sidecar.get(1).expect("cell at blob index 1 should exist"); + assert_eq!(cell[0], 1); + let (cell, _) = sidecar.get(3).expect("cell at blob index 3 should exist"); + assert_eq!(cell[0], 3); + } + + #[test] + fn get_absent_blob_index_returns_none() { + let sidecar = make_sidecar(4, &[1, 3]); + assert!(sidecar.get(0).is_none()); + assert!(sidecar.get(2).is_none()); + } + + #[test] + fn get_out_of_range_returns_none() { + let sidecar = make_sidecar(4, &[0, 2]); + assert!(sidecar.get(4).is_none()); + assert!(sidecar.get(100).is_none()); + } + + #[test] + fn get_dense_bitmap_matches_direct_index() { + let sidecar = make_sidecar_with_marker(4, &[0, 1, 2, 3], 10); + for i in 0..4 { + let (cell, _) = sidecar.get(i).expect("all cells should be present"); + assert_eq!(cell[0], 10 + i as u8); + } + } +} diff --git a/consensus/types/src/kzg_ext/mod.rs b/consensus/types/src/kzg_ext/mod.rs index e0ec9dd956..09305716ab 100644 --- a/consensus/types/src/kzg_ext/mod.rs +++ b/consensus/types/src/kzg_ext/mod.rs @@ -2,9 +2,11 @@ pub mod consts; pub use kzg::{Error as KzgError, Kzg, KzgCommitment, KzgProof}; -use ssz_types::VariableList; - use crate::core::EthSpec; +use crate::{BeaconStateError, Hash256}; +use merkle_proof::{MerkleTree, MerkleTreeError}; +use ssz_types::{FixedVector, VariableList}; +use tree_hash::{BYTES_PER_CHUNK, TreeHash}; // Note on List limit: // - Deneb to Electra: `MaxBlobCommitmentsPerBlock` @@ -25,3 +27,49 @@ pub fn format_kzg_commitments(commitments: &[KzgCommitment]) -> String { let surrounded_commitments = format!("[{}]", commitments_joined); surrounded_commitments } + +pub fn complete_kzg_commitment_merkle_proof( + kzg_commitments: &KzgCommitments, + index: usize, + kzg_commitments_proof: &[Hash256], +) -> Result, BeaconStateError> { + // We compute the branches by generating 2 merkle trees: + // 1. Merkle tree for the `blob_kzg_commitments` List object + // 2. Merkle tree for the `BeaconBlockBody` container + // We then merge the branches for both the trees all the way up to the root. + + // Part1 (Branches for the subtree rooted at `blob_kzg_commitments`) + // + // Branches for `blob_kzg_commitments` without length mix-in + let blob_leaves = kzg_commitments + .iter() + .map(|commitment| commitment.tree_hash_root()) + .collect::>(); + let depth = E::max_blob_commitments_per_block() + .next_power_of_two() + .ilog2(); + let tree = MerkleTree::create(&blob_leaves, depth as usize); + let (_, mut proof) = tree + .generate_proof(index, depth as usize) + .map_err(BeaconStateError::MerkleTreeError)?; + + // Add the branch corresponding to the length mix-in. + let length = blob_leaves.len(); + let usize_len = std::mem::size_of::(); + let mut length_bytes = [0; BYTES_PER_CHUNK]; + length_bytes + .get_mut(0..usize_len) + .ok_or(BeaconStateError::MerkleTreeError( + MerkleTreeError::PleaseNotifyTheDevs, + ))? + .copy_from_slice(&length.to_le_bytes()); + let length_root = Hash256::from_slice(length_bytes.as_slice()); + proof.push(length_root); + + // Part 2 + // Branches for `BeaconBlockBody` container + // Join the proofs for the subtree and the main tree + proof.extend_from_slice(kzg_commitments_proof); + + Ok(FixedVector::new(proof)?) +} diff --git a/consensus/types/src/test_utils/generate_random_block_and_blobs.rs b/consensus/types/src/test_utils/generate_random_block_and_blobs.rs index 4e875341a0..2a38b5be1f 100644 --- a/consensus/types/src/test_utils/generate_random_block_and_blobs.rs +++ b/consensus/types/src/test_utils/generate_random_block_and_blobs.rs @@ -97,20 +97,8 @@ mod test { .. } = blob_sidecars.pop().unwrap(); - // Compute the commitments inclusion proof and use it for building blob sidecar. - let (signed_block_header, kzg_commitments_inclusion_proof) = block - .signed_block_header_and_kzg_commitments_proof() - .unwrap(); - - let blob_sidecar = BlobSidecar::new_with_existing_proof( - index as usize, - blob, - &block, - signed_block_header, - &kzg_commitments_inclusion_proof, - kzg_proof, - ) - .unwrap(); + let blob_sidecar = + BlobSidecar::new_with_existing_proof(index as usize, blob, &block, kzg_proof).unwrap(); assert!(blob_sidecar.verify_blob_sidecar_inclusion_proof()); } diff --git a/lighthouse/tests/beacon_node.rs b/lighthouse/tests/beacon_node.rs index ded1f2b765..0c5d9a5933 100644 --- a/lighthouse/tests/beacon_node.rs +++ b/lighthouse/tests/beacon_node.rs @@ -2864,3 +2864,21 @@ fn invalid_block_roots_default_mainnet() { assert!(config.chain.invalid_block_roots.is_empty()); }) } + +#[test] +fn partial_columns() { + CommandLineTest::new() + .flag("enable-partial-columns", None) + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_partial_columns); + assert!(config.chain.enable_partial_columns); + }); + // And disabled by default: + CommandLineTest::new() + .run_with_zero_port() + .with_config(|config| { + assert!(!config.network.enable_partial_columns); + assert!(!config.chain.enable_partial_columns); + }) +} From df764ffa9aa794bb5b12901123c8acdf38fb407f Mon Sep 17 00:00:00 2001 From: hopinheimer <48147533+hopinheimer@users.noreply.github.com> Date: Sat, 25 Apr 2026 04:04:09 -0400 Subject: [PATCH 136/189] Re-issue `ForkchoiceUpdate` based on updated `PayloadStatus` (#9102) Co-Authored-By: hopinheimer Co-Authored-By: Michael Sproul Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/beacon_chain.rs | 89 ++- .../beacon_chain/src/canonical_head.rs | 9 +- beacon_node/beacon_chain/src/test_utils.rs | 30 + beacon_node/beacon_chain/tests/main.rs | 1 + .../beacon_chain/tests/prepare_payload.rs | 575 ++++++++++++++++++ beacon_node/client/src/builder.rs | 8 +- .../src/engine_api/json_structures.rs | 30 +- beacon_node/execution_layer/src/lib.rs | 14 +- .../test_utils/execution_block_generator.rs | 28 +- .../src/test_utils/handle_rpc.rs | 19 +- .../src/test_utils/mock_builder.rs | 13 +- .../src/test_utils/mock_execution_layer.rs | 13 +- .../src/proto_array_fork_choice.rs | 2 +- .../src/test_rig.rs | 18 +- 14 files changed, 808 insertions(+), 41 deletions(-) create mode 100644 beacon_node/beacon_chain/tests/prepare_payload.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index f3861ac727..98dc9cd7fd 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -117,8 +117,8 @@ use state_processing::{ epoch_cache::initialize_epoch_cache, per_block_processing, per_block_processing::{ - VerifySignatures, errors::AttestationValidationError, get_expected_withdrawals, - verify_attestation_for_block_inclusion, + VerifySignatures, apply_parent_execution_payload, errors::AttestationValidationError, + get_expected_withdrawals, verify_attestation_for_block_inclusion, }, per_slot_processing, state_advance::{complete_state_advance, partial_state_advance}, @@ -4858,16 +4858,20 @@ impl BeaconChain { proposal_slot: Slot, ) -> Result, Error> { let cached_head = self.canonical_head.cached_head(); + let head_block = &cached_head.snapshot.beacon_block; + let head_block_root = cached_head.head_block_root(); let head_state = &cached_head.snapshot.beacon_state; let parent_block_root = forkchoice_update_params.head_root; - let (unadvanced_state, unadvanced_state_root) = - if cached_head.head_block_root() == parent_block_root { - (Cow::Borrowed(head_state), cached_head.head_state_root()) + let (unadvanced_state, unadvanced_state_root, parent_bid_block_hash) = + if parent_block_root == head_block_root { + ( + Cow::Borrowed(head_state), + cached_head.head_state_root(), + head_block.payload_bid_block_hash().ok(), + ) } else { - // TODO(gloas): this function needs updating to be envelope-aware - // See: https://github.com/sigp/lighthouse/issues/8957 let block = self .get_blinded_block(&parent_block_root)? .ok_or(Error::MissingBeaconBlock(parent_block_root))?; @@ -4875,20 +4879,27 @@ impl BeaconChain { .store .get_advanced_hot_state(parent_block_root, proposal_slot, block.state_root())? .ok_or(Error::MissingBeaconState(block.state_root()))?; - (Cow::Owned(state), state_root) + ( + Cow::Owned(state), + state_root, + block.payload_bid_block_hash().ok(), + ) }; - // Parent state epoch is the same as the proposal, we don't need to advance because the - // list of expected withdrawals can only change after an epoch advance or a - // block application. - let proposal_epoch = proposal_slot.epoch(T::EthSpec::slots_per_epoch()); - if head_state.current_epoch() == proposal_epoch { - return get_expected_withdrawals(&unadvanced_state, &self.spec) - .map(Into::into) - .map_err(Error::PrepareProposerFailed); - } + let parent_payload_status = if let Some(block_hash) = parent_bid_block_hash + && block_hash != ExecutionBlockHash::default() + && forkchoice_update_params.head_hash == Some(block_hash) + { + fork_choice::PayloadStatus::Full + } else { + fork_choice::PayloadStatus::Empty + }; // Advance the state using the partial method. + // TODO(gloas): we might want to optimise this further by using: + // - `get_advanced_hot_state` instead of the cached head + // - restoring the pre-Gloas optimisation to avoid advancing further than the epoch + // boundary debug!( %proposal_slot, ?parent_block_root, @@ -4898,9 +4909,33 @@ impl BeaconChain { partial_state_advance( &mut advanced_state, Some(unadvanced_state_root), - proposal_epoch.start_slot(T::EthSpec::slots_per_epoch()), + proposal_slot, &self.spec, )?; + + // For Gloas, when the head payload is Full, we need to apply the parent's + // execution requests to the state to get the correct withdrawals. + if parent_payload_status == fork_choice::PayloadStatus::Full { + let envelope = if parent_block_root == head_block_root { + cached_head.snapshot.execution_envelope.clone() + } else { + self.store + .get_payload_envelope(&parent_block_root)? + .map(Arc::new) + } + .ok_or(Error::MissingExecutionPayloadEnvelope(parent_block_root))?; + + let parent_bid = advanced_state.latest_execution_payload_bid()?.clone(); + + apply_parent_execution_payload( + &mut advanced_state, + &parent_bid, + &envelope.message.execution_requests, + &self.spec, + ) + .map_err(Error::PrepareProposerFailed)?; + } + get_expected_withdrawals(&advanced_state, &self.spec) .map(Into::into) .map_err(Error::PrepareProposerFailed) @@ -6112,13 +6147,20 @@ impl BeaconChain { fcu_params.head_root, &cached_head, )?; - Ok::<_, Error>(Some((fcu_params, pre_payload_attributes))) + let head_payload_status = cached_head.head_payload_status(); + Ok::<_, Error>(Some(( + fcu_params, + pre_payload_attributes, + head_payload_status, + ))) }, "prepare_beacon_proposer_head_read", ) .await??; - let Some((forkchoice_update_params, Some(pre_payload_attributes))) = maybe_prep_data else { + let Some((forkchoice_update_params, Some(pre_payload_attributes), head_payload_status)) = + maybe_prep_data + else { // Appropriate log messages have already been logged above and in // `get_pre_payload_attributes`. return Ok(None); @@ -6140,7 +6182,7 @@ impl BeaconChain { // considerable time to compute if a state load is required. let head_root = forkchoice_update_params.head_root; let payload_attributes = if let Some(payload_attributes) = execution_layer - .payload_attributes(prepare_slot, head_root) + .payload_attributes(prepare_slot, head_root, head_payload_status) .await { payload_attributes @@ -6187,6 +6229,7 @@ impl BeaconChain { .insert_proposer( prepare_slot, head_root, + head_payload_status, proposer, payload_attributes.clone(), ) @@ -6198,6 +6241,7 @@ impl BeaconChain { %prepare_slot, validator = proposer, parent_root = ?head_root, + payload_status = ?head_payload_status, "Prepared beacon proposer" ); payload_attributes @@ -6250,6 +6294,7 @@ impl BeaconChain { self.update_execution_engine_forkchoice( current_slot, forkchoice_update_params, + head_payload_status, OverrideForkchoiceUpdate::AlreadyApplied, ) .await?; @@ -6262,6 +6307,7 @@ impl BeaconChain { self: &Arc, current_slot: Slot, input_params: ForkchoiceUpdateParameters, + head_payload_status: fork_choice::PayloadStatus, override_forkchoice_update: OverrideForkchoiceUpdate, ) -> Result<(), Error> { let execution_layer = self @@ -6322,6 +6368,7 @@ impl BeaconChain { finalized_hash, current_slot, head_block_root, + head_payload_status, ) .await .map_err(Error::ExecutionForkChoiceUpdateFailed); diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 74670b02d7..04c18c88e0 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -827,8 +827,11 @@ impl BeaconChain { // The execution layer updates might attempt to take a write-lock on fork choice, so it's // important to ensure the fork-choice lock isn't being held. - let el_update_handle = - spawn_execution_layer_updates(self.clone(), new_forkchoice_update_parameters)?; + let el_update_handle = spawn_execution_layer_updates( + self.clone(), + new_forkchoice_update_parameters, + new_payload_status, + )?; // We have completed recomputing the head and it's now valid for another process to do the // same. @@ -1186,6 +1189,7 @@ fn perform_debug_logging( fn spawn_execution_layer_updates( chain: Arc>, forkchoice_update_params: ForkchoiceUpdateParameters, + head_payload_status: PayloadStatus, ) -> Result>, Error> { let current_slot = chain .slot_clock @@ -1208,6 +1212,7 @@ fn spawn_execution_layer_updates( .update_execution_engine_forkchoice( current_slot, forkchoice_update_params, + head_payload_status, OverrideForkchoiceUpdate::Yes, ) .await diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index e628a81459..b657f81b1f 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -771,6 +771,36 @@ where .execution_block_generator() } + /// Create a switch-to-compounding `ConsolidationRequest` for the given validator. + /// + /// Panics if the validator doesn't exist, doesn't have eth1 withdrawal credentials, + /// or doesn't have an execution withdrawal address. + pub fn make_switch_to_compounding_request( + &self, + validator_index: usize, + ) -> ConsolidationRequest { + let head = self.chain.canonical_head.cached_head(); + let head_state = &head.snapshot.beacon_state; + let validator = head_state + .get_validator(validator_index) + .expect("validator should exist"); + + assert!( + validator.has_eth1_withdrawal_credential(&self.spec), + "validator {validator_index} should have eth1 withdrawal credentials" + ); + + let source_address = validator + .get_execution_withdrawal_address(&self.spec) + .expect("validator should have execution withdrawal address"); + + ConsolidationRequest { + source_address, + source_pubkey: validator.pubkey, + target_pubkey: validator.pubkey, + } + } + pub fn set_mock_builder( &mut self, beacon_url: SensitiveUrl, diff --git a/beacon_node/beacon_chain/tests/main.rs b/beacon_node/beacon_chain/tests/main.rs index e02c488ac6..d31db128c5 100644 --- a/beacon_node/beacon_chain/tests/main.rs +++ b/beacon_node/beacon_chain/tests/main.rs @@ -6,6 +6,7 @@ mod column_verification; mod events; mod op_verification; mod payload_invalidation; +mod prepare_payload; mod rewards; mod schema_stability; mod store_tests; diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs new file mode 100644 index 0000000000..dc4f999eb2 --- /dev/null +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -0,0 +1,575 @@ +#![cfg(not(debug_assertions))] +#![allow(clippy::result_large_err)] + +use beacon_chain::test_utils::{ + AttestationStrategy, BeaconChainHarness, BlockStrategy, DiskHarnessType, test_spec, +}; +use beacon_chain::{ChainConfig, custody_context::NodeCustodyType}; +use bls::Keypair; +use eth2::types::ProposerPreparationData; +use fork_choice::PayloadStatus; +use logging::create_test_tracing_subscriber; +use ssz_types::VariableList; +use state_processing::{ + per_block_processing::{apply_parent_execution_payload, withdrawals::get_expected_withdrawals}, + state_advance::complete_state_advance, +}; +use std::sync::{Arc, LazyLock}; +use store::database::interface::BeaconNodeBackend; +use store::{HotColdDB, StoreConfig}; +use tempfile::{TempDir, tempdir}; +use types::*; + +// Should ideally be divisible by 3. +pub const LOW_VALIDATOR_COUNT: usize = 32; +pub const HIGH_VALIDATOR_COUNT: usize = 64; + +/// A cached set of keys. +static KEYPAIRS: LazyLock> = + LazyLock::new(|| types::test_utils::generate_deterministic_keypairs(HIGH_VALIDATOR_COUNT)); + +type E = MinimalEthSpec; +type TestHarness = BeaconChainHarness>; + +fn get_store( + db_path: &TempDir, + spec: Arc, +) -> Arc, BeaconNodeBackend>> { + let store_config = StoreConfig { + prune_payloads: false, + ..StoreConfig::default() + }; + get_store_generic(db_path, store_config, spec) +} + +fn get_store_generic( + db_path: &TempDir, + config: StoreConfig, + spec: Arc, +) -> Arc, BeaconNodeBackend>> { + create_test_tracing_subscriber(); + let hot_path = db_path.path().join("chain_db"); + let cold_path = db_path.path().join("freezer_db"); + let blobs_path = db_path.path().join("blobs_db"); + + HotColdDB::open( + &hot_path, + &cold_path, + &blobs_path, + |_, _, _| Ok(()), + config, + spec, + ) + .expect("disk store should initialize") +} + +fn get_harness( + store: Arc, BeaconNodeBackend>>, + validator_count: usize, +) -> TestHarness { + // Most tests expect to retain historic states, so we use this as the default. + let chain_config = ChainConfig { + archive: true, + ..ChainConfig::default() + }; + get_harness_generic( + store, + validator_count, + chain_config, + NodeCustodyType::Fullnode, + ) +} + +fn get_harness_generic( + store: Arc, BeaconNodeBackend>>, + validator_count: usize, + chain_config: ChainConfig, + node_custody_type: NodeCustodyType, +) -> TestHarness { + let harness = TestHarness::builder(MinimalEthSpec) + .spec(store.get_chain_spec().clone()) + .keypairs(KEYPAIRS[0..validator_count].to_vec()) + .fresh_disk_store(store) + .mock_execution_layer() + .chain_config(chain_config) + .node_custody_type(node_custody_type) + .build(); + harness.advance_slot(); + harness +} + +#[tokio::test] +async fn prepare_payload_on_full_parent_next_slot() { + prepare_payload_generic( + PayloadStatus::Full, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(3 * E::slots_per_epoch() + 2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_full_parent_one_epoch_skip() { + prepare_payload_generic( + PayloadStatus::Full, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(4 * E::slots_per_epoch()), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_full_parent_uneven_one_epoch_skip() { + prepare_payload_generic( + PayloadStatus::Full, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(5 * E::slots_per_epoch() - 1), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_empty_parent_next_slot() { + prepare_payload_generic( + PayloadStatus::Empty, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(3 * E::slots_per_epoch() + 2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_empty_parent_one_epoch_skip() { + prepare_payload_generic( + PayloadStatus::Empty, + Slot::new(3 * E::slots_per_epoch() + 1), + Slot::new(4 * E::slots_per_epoch()), + ) + .await; +} + +async fn prepare_payload_generic( + parent_payload_status: PayloadStatus, + parent_block_slot: Slot, + prepare_slot: Slot, +) { + assert!(parent_block_slot > 0); + + // Post-Gloas test. + let spec = Arc::new(test_spec::()); + if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { + return; + } + + let num_blocks_produced = parent_block_slot.as_u64() - 1; + let db_path = tempdir().unwrap(); + let store = get_store(&db_path, spec.clone()); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + harness + .extend_chain( + num_blocks_produced as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Advance the slot so the next extend_chain produces at a fresh slot. + harness.advance_slot(); + + // Produce a block with a payload that affects withdrawals for the next slot. + // A switch-to-compounding consolidation changes withdrawal credentials from 0x01 to 0x02, + // which queues the validator's excess balance as a pending deposit and removes it from the + // partial withdrawal sweep. We target an odd-indexed validator since odd validators are + // created with eth1 withdrawal credentials in the interop genesis builder. + let consolidation_request = harness.make_switch_to_compounding_request(1); + + let execution_requests = ExecutionRequests:: { + deposits: VariableList::empty(), + withdrawals: VariableList::empty(), + consolidations: VariableList::new(vec![consolidation_request]).unwrap(), + }; + + // Inject the execution requests into the mock EL so the next payload includes them. + harness + .execution_block_generator() + .set_next_execution_requests(execution_requests); + + // Produce and import one more block. Its envelope will contain the consolidation request. + // TODO(gloas): all this ugly plumbing could be avoided with some more "implicit" context + // methods + let state = harness.get_current_state(); + let (block_contents, opt_envelope, parent_block_state) = harness + .make_block_with_envelope(state, parent_block_slot) + .await; + let envelope = opt_envelope.unwrap(); + let block_root = harness + .process_block( + parent_block_slot, + block_contents.0.canonical_root(), + block_contents.clone(), + ) + .await + .unwrap(); + + // TODO(gloas): try a case where head is empty even though envelope is processed + if parent_payload_status == PayloadStatus::Full { + harness + .process_envelope( + block_root.into(), + envelope.clone(), + &parent_block_state, + block_contents.0.state_root(), + ) + .await; + } + + // Verify that the withdrawals computed from the block's state differ from the withdrawals + // computed from the block's state with its payload applied by + // `apply_parent_execution_payload`. + let cached_head = harness.chain.canonical_head.cached_head(); + let unadvanced_empty_state = &cached_head.snapshot.beacon_state; + let parent_bid = unadvanced_empty_state + .latest_execution_payload_bid() + .unwrap(); + + let mut advanced_empty_state = unadvanced_empty_state.clone(); + complete_state_advance(&mut advanced_empty_state, None, prepare_slot, &spec).unwrap(); + + let mut unadvanced_full_state = unadvanced_empty_state.clone(); + apply_parent_execution_payload( + &mut unadvanced_full_state, + parent_bid, + &envelope.message.execution_requests, + &spec, + ) + .unwrap(); + + let mut advanced_full_state = advanced_empty_state.clone(); + apply_parent_execution_payload( + &mut advanced_full_state, + parent_bid, + &envelope.message.execution_requests, + &spec, + ) + .unwrap(); + + let withdrawals_unadvanced_empty: Withdrawals = + get_expected_withdrawals(unadvanced_empty_state, &spec) + .unwrap() + .into(); + let withdrawals_advanced_empty: Withdrawals = + get_expected_withdrawals(&advanced_empty_state, &spec) + .unwrap() + .into(); + let withdrawals_unadvanced_full: Withdrawals = + get_expected_withdrawals(&unadvanced_full_state, &spec) + .unwrap() + .into(); + let withdrawals_advanced_full: Withdrawals = + get_expected_withdrawals(&advanced_full_state, &spec) + .unwrap() + .into(); + + assert_ne!( + withdrawals_advanced_empty, withdrawals_advanced_full, + "Applying execution requests should change the expected withdrawals" + ); + + let expect_state_advance_to_change_withdrawals = + prepare_slot.epoch(E::slots_per_epoch()) > parent_block_slot.epoch(E::slots_per_epoch()); + if expect_state_advance_to_change_withdrawals { + if parent_payload_status == fork_choice::PayloadStatus::Full { + assert_ne!( + withdrawals_unadvanced_full, withdrawals_advanced_full, + "Advancing the state should change the withdrawals" + ); + } else { + assert_ne!( + withdrawals_unadvanced_empty, withdrawals_advanced_empty, + "Advancing the state should change the withdrawals" + ); + } + } + + // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution + // layer payload attributes cache with the correct withdrawals (the ones taking into account + // the applied execution_requests). + let current_slot = prepare_slot - 1; + let proposer_index = advanced_empty_state + .get_beacon_proposer_index(prepare_slot, &spec) + .expect("should get proposer index"); + + // Register the proposer so prepare_beacon_proposer doesn't skip it. + let el = harness.chain.execution_layer.as_ref().unwrap(); + el.update_proposer_preparation( + prepare_slot.epoch(E::slots_per_epoch()), + [( + &ProposerPreparationData { + validator_index: proposer_index as u64, + fee_recipient: Address::repeat_byte(42), + }, + &None, + )], + ) + .await; + + // Advance the slot clock to just before the prepare slot so the lookahead check passes. + harness.advance_to_slot_lookahead(prepare_slot, harness.chain.config.prepare_payload_lookahead); + + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .expect("prepare_beacon_proposer should succeed"); + + // Read the payload attributes from the EL cache and verify the withdrawals. + let el = harness.chain.execution_layer.as_ref().unwrap(); + let head_root = harness.head_block_root(); + let attributes = el + .payload_attributes(prepare_slot, head_root, parent_payload_status) + .await + .expect("should have cached payload attributes for prepare_slot"); + + let actual_withdrawals = attributes.withdrawals().unwrap(); + let expected_withdrawals: Vec = if parent_payload_status == PayloadStatus::Full { + withdrawals_advanced_full.to_vec() + } else { + withdrawals_advanced_empty.to_vec() + }; + + assert_eq!( + actual_withdrawals, &expected_withdrawals, + "prepare_beacon_proposer should use withdrawals computed from the \ + {parent_payload_status:?} state" + ); +} + +#[tokio::test] +async fn prepare_payload_on_genesis_next_slot() { + prepare_payload_on_genesis_generic(Slot::new(1)).await; +} + +#[tokio::test] +async fn prepare_payload_on_genesis_skip_two_epochs() { + prepare_payload_on_genesis_generic(Slot::new(2 * E::slots_per_epoch())).await; +} + +async fn prepare_payload_on_genesis_generic(prepare_slot: Slot) { + // Post-Gloas test. + let spec = Arc::new(test_spec::()); + if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { + return; + } + + // Genesis is always considered Empty. + let parent_payload_status = PayloadStatus::Empty; + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path, spec.clone()); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + // At genesis withdrawals are empty (because nothing has happened yet), so we don't assert + // anything about the advanced vs unadvanced state. This test just exists to test that + // calculating payload attributes at genesis works and doesn't error. + let cached_head = harness.chain.canonical_head.cached_head(); + let unadvanced_state = &cached_head.snapshot.beacon_state; + + let mut advanced_state = unadvanced_state.clone(); + complete_state_advance(&mut advanced_state, None, prepare_slot, &spec).unwrap(); + + let withdrawals_advanced: Withdrawals = get_expected_withdrawals(&advanced_state, &spec) + .unwrap() + .into(); + + // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution + // layer payload attributes cache with the correct withdrawals (the ones taking into account + // the state advance). + let current_slot = prepare_slot - 1; + let proposer_index = advanced_state + .get_beacon_proposer_index(prepare_slot, &spec) + .unwrap(); + + // Register the proposer so prepare_beacon_proposer doesn't skip it. + let el = harness.chain.execution_layer.as_ref().unwrap(); + el.update_proposer_preparation( + prepare_slot.epoch(E::slots_per_epoch()), + [( + &ProposerPreparationData { + validator_index: proposer_index as u64, + fee_recipient: Address::repeat_byte(42), + }, + &None, + )], + ) + .await; + + // Advance the slot clock to just before the prepare slot so the lookahead check passes. + harness.advance_to_slot_lookahead(prepare_slot, harness.chain.config.prepare_payload_lookahead); + + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + // Read the payload attributes from the EL cache and verify the withdrawals. + let el = harness.chain.execution_layer.as_ref().unwrap(); + let head_root = harness.head_block_root(); + let attributes = el + .payload_attributes(prepare_slot, head_root, parent_payload_status) + .await + .unwrap(); + + let actual_withdrawals = attributes.withdrawals().unwrap(); + let expected_withdrawals: Vec = withdrawals_advanced.to_vec(); + + assert_eq!( + actual_withdrawals, &expected_withdrawals, + "prepare_beacon_proposer should use withdrawals computed from the \ + {parent_payload_status:?} advanced genesis state" + ); + assert!(actual_withdrawals.is_empty()); +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_no_skip() { + prepare_payload_on_fork_boundary( + Slot::new(2 * E::slots_per_epoch()) - 1, + Slot::new(2 * E::slots_per_epoch()), + Epoch::new(2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_skip_one_prior() { + prepare_payload_on_fork_boundary( + Slot::new(2 * E::slots_per_epoch()) - 2, + Slot::new(2 * E::slots_per_epoch()), + Epoch::new(2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_skip_one_after() { + prepare_payload_on_fork_boundary( + Slot::new(2 * E::slots_per_epoch()) - 1, + Slot::new(2 * E::slots_per_epoch()) + 1, + Epoch::new(2), + ) + .await; +} + +#[tokio::test] +async fn prepare_payload_on_fork_boundary_skip_whole_epoch() { + prepare_payload_on_fork_boundary( + Slot::new(E::slots_per_epoch()), + Slot::new(2 * E::slots_per_epoch()), + Epoch::new(2), + ) + .await; +} + +async fn prepare_payload_on_fork_boundary( + parent_block_slot: Slot, + prepare_slot: Slot, + gloas_fork_epoch: Epoch, +) { + // Post-Gloas test. + let mut spec = test_spec::(); + if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { + return; + } + spec.gloas_fork_epoch = Some(gloas_fork_epoch); + let spec = Arc::new(spec); + + // Pre-Gloas blocks are always considered Empty. + let parent_payload_status = PayloadStatus::Empty; + + let num_blocks_produced = parent_block_slot.as_u64(); + let db_path = tempdir().unwrap(); + let store = get_store(&db_path, spec.clone()); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + harness + .extend_chain( + num_blocks_produced as usize, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + // Verify that the withdrawals computed from the block's state differ from the withdrawals + // computed from the block's state with its payload applied by + // `apply_parent_execution_payload`. + let cached_head = harness.chain.canonical_head.cached_head(); + let unadvanced_state = &cached_head.snapshot.beacon_state; + + let mut advanced_state = unadvanced_state.clone(); + complete_state_advance(&mut advanced_state, None, prepare_slot, &spec).unwrap(); + + let withdrawals_unadvanced: Withdrawals = get_expected_withdrawals(unadvanced_state, &spec) + .unwrap() + .into(); + let withdrawals_advanced: Withdrawals = get_expected_withdrawals(&advanced_state, &spec) + .unwrap() + .into(); + + let expect_state_advance_to_change_withdrawals = prepare_slot.epoch(E::slots_per_epoch()) > 0; + if expect_state_advance_to_change_withdrawals { + assert_ne!( + withdrawals_unadvanced, withdrawals_advanced, + "Advancing the state should change the withdrawals" + ); + } + + // Call `prepare_beacon_proposer` for the next slot and ensure that it primes the execution + // layer payload attributes cache with the correct withdrawals (the ones taking into account + // the applied execution_requests). + let current_slot = prepare_slot - 1; + let proposer_index = advanced_state + .get_beacon_proposer_index(prepare_slot, &spec) + .unwrap(); + + // Register the proposer so prepare_beacon_proposer doesn't skip it. + let el = harness.chain.execution_layer.as_ref().unwrap(); + el.update_proposer_preparation( + prepare_slot.epoch(E::slots_per_epoch()), + [( + &ProposerPreparationData { + validator_index: proposer_index as u64, + fee_recipient: Address::repeat_byte(42), + }, + &None, + )], + ) + .await; + + // Advance the slot clock to just before the prepare slot so the lookahead check passes. + harness.advance_to_slot_lookahead(prepare_slot, harness.chain.config.prepare_payload_lookahead); + + harness + .chain + .prepare_beacon_proposer(current_slot) + .await + .unwrap(); + + // Read the payload attributes from the EL cache and verify the withdrawals. + let el = harness.chain.execution_layer.as_ref().unwrap(); + let head_root = harness.head_block_root(); + let attributes = el + .payload_attributes(prepare_slot, head_root, parent_payload_status) + .await + .unwrap(); + + let actual_withdrawals = attributes.withdrawals().unwrap(); + let expected_withdrawals: Vec = withdrawals_advanced.to_vec(); + + assert_eq!( + actual_withdrawals, &expected_withdrawals, + "prepare_beacon_proposer should use withdrawals computed from the \ + advanced state" + ); +} diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index 865599b9bd..9dfb8304bc 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -721,10 +721,9 @@ where if let Some(execution_layer) = beacon_chain.execution_layer.as_ref() { // Only send a head update *after* genesis. if let Ok(current_slot) = beacon_chain.slot() { - let params = beacon_chain - .canonical_head - .cached_head() - .forkchoice_update_parameters(); + let cached_head = beacon_chain.canonical_head.cached_head(); + let head_payload_status = cached_head.head_payload_status(); + let params = cached_head.forkchoice_update_parameters(); if params .head_hash .is_some_and(|hash| hash != ExecutionBlockHash::zero()) @@ -737,6 +736,7 @@ where .update_execution_engine_forkchoice( current_slot, params, + head_payload_status, Default::default(), ) .await; diff --git a/beacon_node/execution_layer/src/engine_api/json_structures.rs b/beacon_node/execution_layer/src/engine_api/json_structures.rs index cfff0b4d9f..9d9391a1e1 100644 --- a/beacon_node/execution_layer/src/engine_api/json_structures.rs +++ b/beacon_node/execution_layer/src/engine_api/json_structures.rs @@ -1,7 +1,7 @@ use super::*; use alloy_rlp::RlpEncodable; use serde::{Deserialize, Serialize}; -use ssz::{Decode, TryFromIter}; +use ssz::{Decode, Encode, TryFromIter}; use ssz_types::{FixedVector, VariableList, typenum::Unsigned}; use strum::EnumString; use superstruct::superstruct; @@ -481,6 +481,34 @@ pub enum RequestsError { #[serde(transparent)] pub struct JsonExecutionRequests(pub Vec); +impl From> for JsonExecutionRequests { + fn from(requests: ExecutionRequests) -> Self { + let mut result = Vec::new(); + if !requests.deposits.is_empty() { + result.push(format!( + "0x{:02x}{}", + RequestType::Deposit.to_u8(), + hex::encode(requests.deposits.as_ssz_bytes()) + )); + } + if !requests.withdrawals.is_empty() { + result.push(format!( + "0x{:02x}{}", + RequestType::Withdrawal.to_u8(), + hex::encode(requests.withdrawals.as_ssz_bytes()) + )); + } + if !requests.consolidations.is_empty() { + result.push(format!( + "0x{:02x}{}", + RequestType::Consolidation.to_u8(), + hex::encode(requests.consolidations.as_ssz_bytes()) + )); + } + JsonExecutionRequests(result) + } +} + impl TryFrom for ExecutionRequests { type Error = RequestsError; diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 4e4fe20e14..4146543fd5 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -403,6 +403,7 @@ impl ProposerPreparationDataEntry { pub struct ProposerKey { slot: Slot, head_block_root: Hash256, + head_payload_status: fork_choice::PayloadStatus, } #[derive(PartialEq, Clone)] @@ -1461,12 +1462,14 @@ impl ExecutionLayer { &self, slot: Slot, head_block_root: Hash256, + head_payload_status: fork_choice::PayloadStatus, validator_index: u64, payload_attributes: PayloadAttributes, ) -> bool { let proposers_key = ProposerKey { slot, head_block_root, + head_payload_status, }; let existing = self.proposers().write().await.insert( @@ -1485,16 +1488,18 @@ impl ExecutionLayer { } /// If there has been a proposer registered via `Self::insert_proposer` with a matching `slot` - /// `head_block_root`, then return the appropriate `PayloadAttributes` for inclusion in - /// `forkchoiceUpdated` calls. + /// `head_block_root`, and `head_payload_status` then return the appropriate `PayloadAttributes` + /// for inclusion in `forkchoiceUpdated` calls. pub async fn payload_attributes( &self, current_slot: Slot, head_block_root: Hash256, + head_payload_status: fork_choice::PayloadStatus, ) -> Option { let proposers_key = ProposerKey { slot: current_slot, head_block_root, + head_payload_status, }; let proposer = self.proposers().read().await.get(&proposers_key).cloned()?; @@ -1518,6 +1523,7 @@ impl ExecutionLayer { finalized_block_hash: ExecutionBlockHash, current_slot: Slot, head_block_root: Hash256, + head_payload_status: fork_choice::PayloadStatus, ) -> Result { let _timer = metrics::start_timer_vec( &metrics::EXECUTION_LAYER_REQUEST_TIMES, @@ -1534,7 +1540,9 @@ impl ExecutionLayer { ); let next_slot = current_slot + 1; - let payload_attributes = self.payload_attributes(next_slot, head_block_root).await; + let payload_attributes = self + .payload_attributes(next_slot, head_block_root, head_payload_status) + .await; // Compute the "lookahead", the time between when the payload will be produced and now. if let Some(ref payload_attributes) = payload_attributes diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index ace6276b75..16d8c03062 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -26,8 +26,8 @@ use tree_hash_derive::TreeHash; use types::{ Blob, ChainSpec, EthSpec, ExecutionBlockHash, ExecutionPayload, ExecutionPayloadBellatrix, ExecutionPayloadCapella, ExecutionPayloadDeneb, ExecutionPayloadElectra, ExecutionPayloadFulu, - ExecutionPayloadGloas, ExecutionPayloadHeader, ForkName, Hash256, KzgProofs, Transaction, - Transactions, Uint256, + ExecutionPayloadGloas, ExecutionPayloadHeader, ExecutionRequests, ForkName, Hash256, KzgProofs, + Transaction, Transactions, Uint256, }; const TEST_BLOB_BUNDLE: &[u8] = include_bytes!("fixtures/mainnet/test_blobs_bundle.ssz"); @@ -161,6 +161,14 @@ pub struct ExecutionBlockGenerator { pub blobs_bundles: HashMap>, pub kzg: Option>, rng: Arc>, + /* + * Execution requests (electra+) + */ + /// Per-payload execution requests returned by `getPayload`. + execution_requests: HashMap>, + /// If set, the next call to `build_new_execution_payload` will associate these + /// execution requests with the generated payload ID. + next_execution_requests: Option>, } fn make_rng() -> Arc> { @@ -199,6 +207,8 @@ impl ExecutionBlockGenerator { blobs_bundles: <_>::default(), kzg, rng: make_rng(), + execution_requests: <_>::default(), + next_execution_requests: None, }; generator.insert_pow_block(0).unwrap(); @@ -458,6 +468,15 @@ impl ExecutionBlockGenerator { self.blobs_bundles.get(id).cloned() } + pub fn get_execution_requests(&self, id: &PayloadId) -> Option> { + self.execution_requests.get(id).cloned() + } + + /// Set execution requests to be returned alongside the next generated payload. + pub fn set_next_execution_requests(&mut self, requests: ExecutionRequests) { + self.next_execution_requests = Some(requests); + } + /// Look up a blob and proof by versioned hash across all stored bundles. pub fn get_blob_and_proof(&self, versioned_hash: &Hash256) -> Option> { self.blobs_bundles @@ -763,6 +782,11 @@ impl ExecutionBlockGenerator { }, }; + // Store execution requests for this payload if configured. + if let Some(requests) = self.next_execution_requests.take() { + self.execution_requests.insert(id, requests); + } + let fork_name = execution_payload.fork_name(); if fork_name.deneb_enabled() { // get random number between 0 and 1 blobs by default diff --git a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs index 3054289996..64eecccc58 100644 --- a/beacon_node/execution_layer/src/test_utils/handle_rpc.rs +++ b/beacon_node/execution_layer/src/test_utils/handle_rpc.rs @@ -295,6 +295,10 @@ pub async fn handle_rpc( })?; let maybe_blobs = ctx.execution_block_generator.write().get_blobs_bundle(&id); + let maybe_execution_requests = ctx + .execution_block_generator + .read() + .get_execution_requests(&id); // validate method called correctly according to shanghai fork time if ctx @@ -432,8 +436,10 @@ pub async fn handle_rpc( ))? .into(), should_override_builder: false, - // TODO(electra): add EL requests in mock el - execution_requests: Default::default(), + execution_requests: maybe_execution_requests + .clone() + .unwrap_or_default() + .into(), }) .unwrap() } @@ -453,7 +459,10 @@ pub async fn handle_rpc( ))? .into(), should_override_builder: false, - execution_requests: Default::default(), + execution_requests: maybe_execution_requests + .clone() + .unwrap_or_default() + .into(), }) .unwrap() } @@ -473,7 +482,9 @@ pub async fn handle_rpc( ))? .into(), should_override_builder: false, - execution_requests: Default::default(), + execution_requests: maybe_execution_requests + .unwrap_or_default() + .into(), }) .unwrap() } diff --git a/beacon_node/execution_layer/src/test_utils/mock_builder.rs b/beacon_node/execution_layer/src/test_utils/mock_builder.rs index 6ab6cca3f6..d6243a7c4d 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_builder.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_builder.rs @@ -800,6 +800,10 @@ impl MockBuilder { let head_block_root = head_block_root.unwrap_or(head.canonical_root()); + // TODO(gloas): Currently the tests are pre-Gloas and we are not considering + // other payload statuses. This codepath may not be relevant for Gloas. + let head_payload_status = fork_choice::PayloadStatus::Pending; + let head_execution_payload = head .message() .body() @@ -934,7 +938,13 @@ impl MockBuilder { ); self.el - .insert_proposer(slot, head_block_root, val_index, payload_attributes.clone()) + .insert_proposer( + slot, + head_block_root, + head_payload_status, + val_index, + payload_attributes.clone(), + ) .await; let forkchoice_update_params = ForkchoiceUpdateParameters { @@ -952,6 +962,7 @@ impl MockBuilder { finalized_execution_hash, slot - 1, head_block_root, + head_payload_status, ) .await .map_err(|e| format!("fcu call failed : {:?}", e))?; diff --git a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs index 288416d51e..5b721bcab2 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs @@ -90,6 +90,8 @@ impl MockExecutionLayer { let timestamp = block_number; let prev_randao = Hash256::from_low_u64_be(block_number); let head_block_root = Hash256::repeat_byte(42); + // TODO(gloas): allow statuses other than Pending? + let head_payload_status = fork_choice::PayloadStatus::Pending; let forkchoice_update_params = ForkchoiceUpdateParameters { head_root: head_block_root, head_hash: Some(parent_hash), @@ -109,7 +111,13 @@ impl MockExecutionLayer { let slot = Slot::new(0); let validator_index = 0; self.el - .insert_proposer(slot, head_block_root, validator_index, payload_attributes) + .insert_proposer( + slot, + head_block_root, + head_payload_status, + validator_index, + payload_attributes, + ) .await; self.el @@ -119,6 +127,7 @@ impl MockExecutionLayer { ExecutionBlockHash::zero(), slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -280,6 +289,7 @@ impl MockExecutionLayer { // Use junk values for slot/head-root to ensure there is no payload supplied. let slot = Slot::new(0); let head_block_root = Hash256::repeat_byte(13); + // TODO(gloas): reconsider the state_payload_status self.el .notify_forkchoice_updated( block_hash, @@ -287,6 +297,7 @@ impl MockExecutionLayer { ExecutionBlockHash::zero(), slot, head_block_root, + fork_choice::PayloadStatus::Pending, ) .await .unwrap(); diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 1c6d3f3201..7abba8a1f6 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -101,7 +101,7 @@ pub enum ExecutionStatus { } /// Represents the status of an execution payload post-Gloas. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Encode, Decode, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Encode, Decode, Serialize, Deserialize)] #[ssz(enum_behaviour = "tag")] #[repr(u8)] pub enum PayloadStatus { diff --git a/testing/execution_engine_integration/src/test_rig.rs b/testing/execution_engine_integration/src/test_rig.rs index 05170d907c..ed6b5787b5 100644 --- a/testing/execution_engine_integration/src/test_rig.rs +++ b/testing/execution_engine_integration/src/test_rig.rs @@ -200,6 +200,9 @@ impl TestRig { pub async fn perform_tests(&self) { self.wait_until_synced().await; + // TODO(gloas): this needs to be for post-Gloas cases + let head_payload_status = fork_choice::PayloadStatus::Pending; + // Create a local signer in case we need to sign transactions locally let private_key_signer: PrivateKeySigner = PRIVATE_KEYS[0].parse().expect("Invalid private key"); @@ -308,6 +311,7 @@ impl TestRig { .insert_proposer( Slot::new(1), // Insert proposer for the next slot head_root, + fork_choice::PayloadStatus::Pending, proposer_index, PayloadAttributes::new( timestamp, @@ -332,6 +336,7 @@ impl TestRig { finalized_block_hash, Slot::new(0), Hash256::zero(), + head_payload_status, ) .await .unwrap(); @@ -411,6 +416,7 @@ impl TestRig { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -452,6 +458,7 @@ impl TestRig { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -587,7 +594,13 @@ impl TestRig { let validator_index = 0; self.ee_a .execution_layer - .insert_proposer(slot, head_block_root, validator_index, payload_attributes) + .insert_proposer( + slot, + head_block_root, + head_payload_status, + validator_index, + payload_attributes, + ) .await; let status = self .ee_a @@ -598,6 +611,7 @@ impl TestRig { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -635,6 +649,7 @@ impl TestRig { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); @@ -688,6 +703,7 @@ impl TestRig { finalized_block_hash, slot, head_block_root, + head_payload_status, ) .await .unwrap(); From 6323cd3827b596080fa43add5b09a7adc91fd58e Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 26 Apr 2026 01:51:02 +0200 Subject: [PATCH 137/189] Fix builder exit signature batch verification logic and small refactor (#9173) We had a bug when performing batch builder exit signature verification. The EF spec tests cover this case, but the EF tests only calls individual signature verification (which is a separate code path). This PR unifies the two code paths. We should probably spend some time reviewing EF test code coverage and make sure we don't have separate code paths that do similar things. Co-Authored-By: Eitan Seri-Levi --- .../process_operations.rs | 27 +++++++++---------- .../per_block_processing/signature_sets.rs | 18 ++++++++++--- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index f1de284fc8..422e0afe06 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -8,6 +8,7 @@ use crate::per_block_processing::builder::{ convert_validator_index_to_builder_index, is_builder_index, }; use crate::per_block_processing::errors::{BlockProcessingError, ExitInvalid, IntoWithIndex}; +use crate::per_block_processing::signature_sets::{exit_signature_set, get_pubkey_from_state}; use crate::per_block_processing::verify_payload_attestation::verify_payload_attestation; use bls::{PublicKeyBytes, SignatureBytes}; use ssz_types::FixedVector; @@ -547,7 +548,8 @@ fn process_builder_voluntary_exit( let builder_index = convert_validator_index_to_builder_index(signed_exit.message.validator_index); - let builder = state + // Verify builder is known + state .builders()? .get(builder_index as usize) .cloned() @@ -570,22 +572,17 @@ fn process_builder_voluntary_exit( )); } - // Verify signature (using EIP-7044 domain: capella_fork_version for Deneb+) if verify_signatures.is_true() { - let pubkey = builder.pubkey; - let domain = spec.compute_domain( - Domain::VoluntaryExit, - spec.capella_fork_version, - state.genesis_validators_root(), + verify!( + exit_signature_set( + state, + |i| get_pubkey_from_state(state, i), + signed_exit, + spec + )? + .verify(), + ExitInvalid::BadSignature ); - let message = signed_exit.message.signing_root(domain); - // TODO(gloas): use builder pubkey cache once available - let bls_pubkey = pubkey - .decompress() - .map_err(|_| BlockOperationError::invalid(ExitInvalid::BadSignature))?; - if !signed_exit.signature.verify(&bls_pubkey, message) { - return Err(BlockOperationError::invalid(ExitInvalid::BadSignature)); - } } // Initiate builder exit diff --git a/consensus/state_processing/src/per_block_processing/signature_sets.rs b/consensus/state_processing/src/per_block_processing/signature_sets.rs index 5c1767f227..0686c4d605 100644 --- a/consensus/state_processing/src/per_block_processing/signature_sets.rs +++ b/consensus/state_processing/src/per_block_processing/signature_sets.rs @@ -2,6 +2,7 @@ //! validated individually, or alongside in others in a potentially cheaper bulk operation. //! //! This module exposes one function to extract each type of `SignatureSet` from a `BeaconBlock`. +use super::builder::{convert_validator_index_to_builder_index, is_builder_index}; use bls::{AggregateSignature, PublicKey, PublicKeyBytes, Signature, SignatureSet}; use ssz::DecodeError; use std::borrow::Cow; @@ -503,7 +504,7 @@ pub fn deposit_pubkey_signature_message( } /// Returns a signature set that is valid if the `SignedVoluntaryExit` was signed by the indicated -/// validator. +/// validator (or builder, in the case of a builder exit). pub fn exit_signature_set<'a, E, F>( state: &'a BeaconState, get_pubkey: F, @@ -515,7 +516,18 @@ where F: Fn(usize) -> Option>, { let exit = &signed_exit.message; - let proposer_index = exit.validator_index as usize; + let validator_index = exit.validator_index; + + let is_builder_exit = + state.fork_name_unchecked().gloas_enabled() && is_builder_index(validator_index); + + let pubkey = if is_builder_exit { + let builder_index = convert_validator_index_to_builder_index(validator_index); + get_builder_pubkey_from_state(state, builder_index) + .ok_or(Error::ValidatorUnknown(validator_index))? + } else { + get_pubkey(validator_index as usize).ok_or(Error::ValidatorUnknown(validator_index))? + }; let domain = if state.fork_name_unchecked().deneb_enabled() { // EIP-7044 @@ -537,7 +549,7 @@ where Ok(SignatureSet::single_pubkey( &signed_exit.signature, - get_pubkey(proposer_index).ok_or(Error::ValidatorUnknown(proposer_index as u64))?, + pubkey, message, )) } From 276c4d5ff353fe93db306668fca7f8639a1e2ab1 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 26 Apr 2026 15:40:22 +0200 Subject: [PATCH 138/189] Gloas set `AttestationData.index` (#9100) For gloas `attestation.data.index` should be set to 1 if we are attesting to a block whose slot is not the attestation duty slot and slot payload_status is `FULL` Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Eitan Seri-Levi Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/beacon_chain/src/beacon_chain.rs | 26 +++ .../beacon_chain/src/early_attester_cache.rs | 13 ++ beacon_node/beacon_chain/src/test_utils.rs | 2 + .../tests/attestation_production.rs | 179 +++++++++++++++--- .../types/src/attestation/attestation.rs | 11 +- .../src/attestation_service.rs | 1 + 6 files changed, 209 insertions(+), 23 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 98dc9cd7fd..b556e6d849 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -1956,6 +1956,7 @@ impl BeaconChain { let beacon_block_root; let beacon_state_root; let target; + let is_same_slot_attestation; let current_epoch_attesting_info: Option<(Checkpoint, usize)>; let head_timer = metrics::start_timer(&metrics::ATTESTATION_PRODUCTION_HEAD_SCRAPE_SECONDS); let head_span = debug_span!("attestation_production_head_scrape").entered(); @@ -1996,11 +1997,20 @@ impl BeaconChain { // When attesting to the head slot or later, always use the head of the chain. beacon_block_root = head.beacon_block_root; beacon_state_root = head.beacon_state_root(); + is_same_slot_attestation = request_slot == head.beacon_block.slot(); } else { // Permit attesting to slots *prior* to the current head. This is desirable when // the VC and BN are out-of-sync due to time issues or overloading. beacon_block_root = *head_state.get_block_root(request_slot)?; beacon_state_root = *head_state.get_state_root(request_slot)?; + + // Fetch the previous block root. If the previous block root equals + // the block root being attested to, the `request_slot` is a skipped slot + // and this is not a same slot attestation. + let prior_slot_root = head_state + .get_block_root(request_slot.saturating_sub(1u64)) + .ok(); + is_same_slot_attestation = prior_slot_root != Some(&beacon_block_root); }; let target_slot = request_epoch.start_slot(T::EthSpec::slots_per_epoch()); @@ -2090,6 +2100,21 @@ impl BeaconChain { ) }; + // For gloas the attestation data index indicates payload presence: + // `payload_present=false` for same-slot attestations or when payload not received. + // `payload_present=true` when attesting to a prior slot whose payload has been received. + let payload_present = if self + .spec + .fork_name_at_slot::(request_slot) + .gloas_enabled() + && !is_same_slot_attestation + { + self.canonical_head + .block_has_canonical_payload(&beacon_block_root, &self.spec)? + } else { + false + }; + Ok(Attestation::::empty_for_signing( request_index, committee_len, @@ -2097,6 +2122,7 @@ impl BeaconChain { beacon_block_root, justified_checkpoint, target, + payload_present, &self.spec, )?) } diff --git a/beacon_node/beacon_chain/src/early_attester_cache.rs b/beacon_node/beacon_chain/src/early_attester_cache.rs index 752e4d1a96..e3a83f9374 100644 --- a/beacon_node/beacon_chain/src/early_attester_cache.rs +++ b/beacon_node/beacon_chain/src/early_attester_cache.rs @@ -165,6 +165,12 @@ impl EarlyAttesterCache { /// - There is a cache `item` present. /// - If `request_slot` is in the same epoch as `item.epoch`. /// - If `request_index` does not exceed `item.committee_count`. + /// + /// Post gloas an additional condition must be met: + /// - `request_slot` is the same slot as `item.block.slot` (i.e. a same slot attestation). + /// + /// Non-same-slot Gloas attestations need `data.index` set from the canonical payload + /// status, which the cache doesn't track. Returning `None` falls through to fork choice. #[instrument(skip_all, fields(%request_slot, %request_index), level = "debug")] pub fn try_attest( &self, @@ -197,6 +203,12 @@ impl EarlyAttesterCache { item.committee_lengths .get_committee_length::(request_slot, request_index, spec)?; + let is_same_slot_attestation = request_slot == item.block.slot(); + if spec.fork_name_at_slot::(request_slot).gloas_enabled() && !is_same_slot_attestation { + return Ok(None); + } + let payload_present = false; + let attestation = Attestation::empty_for_signing( request_index, committee_len, @@ -204,6 +216,7 @@ impl EarlyAttesterCache { item.beacon_block_root, item.source, item.target, + payload_present, spec, ) .map_err(Error::AttestationError)?; diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index b657f81b1f..274f41d1cb 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1451,6 +1451,7 @@ where epoch, root: target_root, }, + false, &self.spec, )?; @@ -1560,6 +1561,7 @@ where epoch, root: target_root, }, + false, &self.spec, )?) } diff --git a/beacon_node/beacon_chain/tests/attestation_production.rs b/beacon_node/beacon_chain/tests/attestation_production.rs index a3ab959d12..1b87fc041a 100644 --- a/beacon_node/beacon_chain/tests/attestation_production.rs +++ b/beacon_node/beacon_chain/tests/attestation_production.rs @@ -2,7 +2,9 @@ use beacon_chain::attestation_simulator::produce_unaggregated_attestation; use beacon_chain::custody_context::NodeCustodyType; -use beacon_chain::test_utils::{AttestationStrategy, BeaconChainHarness, BlockStrategy}; +use beacon_chain::test_utils::{ + AttestationStrategy, BeaconChainHarness, BlockStrategy, fork_name_from_env, +}; use beacon_chain::validator_monitor::UNAGGREGATED_ATTESTATION_LAG_SLOTS; use beacon_chain::{StateSkipConfig, WhenSlotSkipped, metrics}; use bls::{AggregateSignature, Keypair}; @@ -206,7 +208,15 @@ async fn produces_attestations() { &AggregateSignature::infinity(), "bad signature" ); - assert_eq!(data.index, index, "bad index"); + if harness + .spec + .fork_name_at_slot::(data.slot) + .gloas_enabled() + { + assert!(data.index <= 1, "invalid index"); + } else { + assert_eq!(data.index, index, "bad index"); + } assert_eq!(data.slot, slot, "bad slot"); assert_eq!(data.beacon_block_root, block_root, "bad block root"); assert_eq!( @@ -226,27 +236,35 @@ async fn produces_attestations() { .build_range_sync_block_from_store_blobs(Some(block_root), Arc::new(block.clone())); let available_block = range_sync_block.into_available_block(); - let early_attestation = { - let proto_block = chain - .canonical_head - .fork_choice_read_lock() - .get_block(&block_root) - .unwrap(); - chain - .early_attester_cache - .add_head_block(block_root, &available_block, proto_block, &state) - .unwrap(); - chain - .early_attester_cache - .try_attest(slot, index, &chain.spec) - .unwrap() - .unwrap() - }; + // For Gloas non-same-slot attestations, the early attester cache returns None. + let is_same_slot_attestation = slot == block_slot; + let is_gloas = harness + .spec + .fork_name_at_slot::(slot) + .gloas_enabled(); + if !is_gloas || is_same_slot_attestation { + let early_attestation = { + let proto_block = chain + .canonical_head + .fork_choice_read_lock() + .get_block(&block_root) + .unwrap(); + chain + .early_attester_cache + .add_head_block(block_root, &available_block, proto_block, &state) + .unwrap(); + chain + .early_attester_cache + .try_attest(slot, index, &chain.spec) + .unwrap() + .unwrap() + }; - assert_eq!( - attestation, early_attestation, - "early attester cache inconsistent" - ); + assert_eq!( + attestation, early_attestation, + "early attester cache inconsistent" + ); + } } } } @@ -313,3 +331,120 @@ async fn early_attester_cache_old_request() { .unwrap(); assert_eq!(attested_block.slot(), attest_slot); } + +/// Verify that `produce_unaggregated_attestation` sets `data.index = 1` (payload_present) +/// when a gloas validator attests to a prior slot whose block+envelope have been received. +/// +/// Setup: build a chain at gloas genesis, produce a block with envelope at slot N, +/// then advance the clock to slot N+1 without producing a block (skipped slot). +/// Attesting at slot N+1 should target the block at slot N with payload_present = true. +#[tokio::test] +async fn gloas_attestation_index_payload_present() { + if fork_name_from_env().is_some_and(|f| !f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .default_spec() + .keypairs(KEYPAIRS[..].to_vec()) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + let chain = &harness.chain; + + // Build a few blocks so the chain is established (slots 1..=3). + harness.advance_slot(); + harness + .extend_chain( + 3, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + let head = chain.head_snapshot(); + assert_eq!(head.beacon_block.slot(), Slot::new(3)); + + // Advance clock to slot 4 without producing a block (skipped slot). + harness.advance_slot(); + let attest_slot = chain.slot().unwrap(); + assert_eq!(attest_slot, Slot::new(4)); + + // Attest at slot 4 — this should target the block at slot 3 whose payload was received. + let attestation = chain + .produce_unaggregated_attestation(attest_slot, 0) + .expect("should produce attestation"); + + assert_eq!(attestation.data().slot, attest_slot); + assert_eq!( + attestation.data().index, + 1, + "gloas attestation to prior slot with payload should have index=1 (payload_present)" + ); +} + +/// Verify that `produce_unaggregated_attestation` sets `data.index = 0` (payload NOT present) +/// when a gloas validator attests to a prior slot whose block was imported but whose +/// payload envelope was never received. +/// +/// Setup: build a chain at gloas genesis through slot 2, then at slot 3 import only the +/// beacon block (no envelope), advance to slot 4 (skipped), and attest. +#[tokio::test] +async fn gloas_attestation_index_payload_absent() { + if fork_name_from_env().is_some_and(|f| !f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(MainnetEthSpec) + .default_spec() + .keypairs(KEYPAIRS[..].to_vec()) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + let chain = &harness.chain; + + // Build slots 1..=2 normally (with envelopes). + harness.advance_slot(); + harness + .extend_chain( + 2, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + assert_eq!(chain.head_snapshot().beacon_block.slot(), Slot::new(2)); + + // Slot 3: produce and import the beacon block but do NOT process the envelope. + harness.advance_slot(); + let state = harness.get_current_state(); + let (block_contents, _envelope, _new_state) = + harness.make_block_with_envelope(state, Slot::new(3)).await; + + let block_root = block_contents.0.canonical_root(); + harness + .process_block(Slot::new(3), block_root, block_contents) + .await + .expect("block should import without envelope"); + + assert_eq!(chain.head_snapshot().beacon_block.slot(), Slot::new(3)); + + // Advance clock to slot 4 without producing a block (skipped slot). + harness.advance_slot(); + let attest_slot = chain.slot().unwrap(); + assert_eq!(attest_slot, Slot::new(4)); + + // Attest at slot 4 — targets slot 3 whose payload was NOT received. + let attestation = chain + .produce_unaggregated_attestation(attest_slot, 0) + .expect("should produce attestation"); + + assert_eq!(attestation.data().slot, attest_slot); + assert_eq!( + attestation.data().index, + 0, + "gloas attestation to prior slot without payload should have index=0 (payload_absent)" + ); +} diff --git a/consensus/types/src/attestation/attestation.rs b/consensus/types/src/attestation/attestation.rs index 693b5889f5..28059efee6 100644 --- a/consensus/types/src/attestation/attestation.rs +++ b/consensus/types/src/attestation/attestation.rs @@ -102,6 +102,7 @@ impl Hash for Attestation { impl Attestation { /// Produces an attestation with empty signature. + #[allow(clippy::too_many_arguments)] pub fn empty_for_signing( committee_index: u64, committee_length: usize, @@ -109,6 +110,7 @@ impl Attestation { beacon_block_root: Hash256, source: Checkpoint, target: Checkpoint, + payload_present: bool, spec: &ChainSpec, ) -> Result { if spec.fork_name_at_slot::(slot).electra_enabled() { @@ -116,12 +118,19 @@ impl Attestation { committee_bits .set(committee_index as usize, true) .map_err(|_| Error::InvalidCommitteeIndex)?; + // Gloas attestation data index now indicates payload presence. + // Pre-gloas index is always 0. + let index = if spec.fork_name_at_slot::(slot).gloas_enabled() && payload_present { + 1u64 + } else { + 0u64 + }; Ok(Attestation::Electra(AttestationElectra { aggregation_bits: BitList::with_capacity(committee_length) .map_err(|_| Error::InvalidCommitteeLength)?, data: AttestationData { slot, - index: 0u64, + index, beacon_block_root, source, target, diff --git a/validator_client/validator_services/src/attestation_service.rs b/validator_client/validator_services/src/attestation_service.rs index dc5fc27a4f..3ffe602892 100644 --- a/validator_client/validator_services/src/attestation_service.rs +++ b/validator_client/validator_services/src/attestation_service.rs @@ -546,6 +546,7 @@ impl AttestationService attestation, From fae7941b2d13dc9cd1ba8282aefe2798a70c7c74 Mon Sep 17 00:00:00 2001 From: Shane K Moore <41407272+shane-moore@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:25:00 -0700 Subject: [PATCH 139/189] Gloas ptc duties beacon node response (#8415) Co-Authored-By: shane-moore Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Eitan Seri-Levi Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/beacon_chain/src/beacon_chain.rs | 44 ++++- beacon_node/http_api/src/lib.rs | 10 + beacon_node/http_api/src/ptc_duties.rs | 182 +++++++++++++++++++ beacon_node/http_api/src/validator/mod.rs | 38 +++- beacon_node/http_api/tests/tests.rs | 120 +++++++++++- consensus/types/src/state/beacon_state.rs | 21 +++ 6 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 beacon_node/http_api/src/ptc_duties.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index b556e6d849..bfe1b404e0 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -84,8 +84,8 @@ use crate::{ use bls::{PublicKey, PublicKeyBytes, Signature}; use eth2::beacon_response::ForkVersionedResponse; use eth2::types::{ - EventKind, SseBlobSidecar, SseBlock, SseDataColumnSidecar, SseExtendedPayloadAttributes, - SseHead, + EventKind, PtcDuty, SseBlobSidecar, SseBlock, SseDataColumnSidecar, + SseExtendedPayloadAttributes, SseHead, }; use execution_layer::{ BlockProposalContents, BlockProposalContentsType, BuilderParams, ChainHealth, ExecutionLayer, @@ -1719,6 +1719,46 @@ impl BeaconChain { Ok((duties, dependent_root, execution_status)) } + /// Get PTC duties for validators at a given epoch. + /// + /// TODO(gloas): per-validator `get_ptc_assignment` makes this O(N * slots_per_epoch * PTCSize). + /// A future ptc cache (or a single-pass `ptc_window` walk) can drop this to + /// O(slots_per_epoch * PTCSize + N). + pub fn compute_ptc_duties( + &self, + state: &BeaconState, + epoch: Epoch, + validator_indices: &[u64], + dependent_block_root: Hash256, + ) -> Result<(Vec>, Hash256), Error> { + // The ptc_window only covers previous, current, and next epochs. + let relative_epoch = RelativeEpoch::from_epoch(state.current_epoch(), epoch) + .map_err(Error::IncorrectStateForAttestation)?; + + let dependent_root = + state.attester_shuffling_decision_root(dependent_block_root, relative_epoch)?; + + let pubkey_cache = self.validator_pubkey_cache.read(); + + let duties = validator_indices + .iter() + .map(|&validator_index| -> Result, Error> { + let Some(&pubkey) = pubkey_cache.get_pubkey_bytes(validator_index as usize) else { + return Ok(None); + }; + let slot_opt = + state.get_ptc_assignment(validator_index as usize, epoch, &self.spec)?; + Ok(slot_opt.map(|slot| PtcDuty { + validator_index, + slot, + pubkey, + })) + }) + .collect::, _>>()?; + + Ok((duties, dependent_root)) + } + pub fn get_aggregated_attestation( &self, attestation: AttestationRef, diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index 0be631c057..bd80dd1e82 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -19,6 +19,7 @@ mod metrics; mod peer; mod produce_block; mod proposer_duties; +mod ptc_duties; mod publish_attestations; mod publish_blocks; mod standard_block_rewards; @@ -2560,6 +2561,14 @@ pub fn serve( task_spawner_filter.clone(), ); + // POST validator/duties/ptc/{epoch} + let post_validator_duties_ptc = post_validator_duties_ptc( + eth_v1.clone(), + chain_filter.clone(), + not_while_syncing_filter.clone(), + task_spawner_filter.clone(), + ); + // POST validator/duties/sync/{epoch} let post_validator_duties_sync = post_validator_duties_sync( eth_v1.clone(), @@ -3410,6 +3419,7 @@ pub fn serve( .uor(post_beacon_rewards_attestations) .uor(post_beacon_rewards_sync_committee) .uor(post_validator_duties_attester) + .uor(post_validator_duties_ptc) .uor(post_validator_duties_sync) .uor(post_validator_aggregate_and_proofs) .uor(post_validator_contribution_and_proofs) diff --git a/beacon_node/http_api/src/ptc_duties.rs b/beacon_node/http_api/src/ptc_duties.rs new file mode 100644 index 0000000000..f727b84004 --- /dev/null +++ b/beacon_node/http_api/src/ptc_duties.rs @@ -0,0 +1,182 @@ +//! Contains the handler for the `POST validator/duties/ptc/{epoch}` endpoint. + +use crate::state_id::StateId; +use beacon_chain::{BeaconChain, BeaconChainError, BeaconChainTypes}; +use eth2::types::{self as api_types, PtcDuty}; +use slot_clock::SlotClock; +use state_processing::state_advance::partial_state_advance; +use types::{BeaconState, ChainSpec, Epoch, EthSpec, Hash256}; + +type ApiDuties = api_types::DutiesResponse>; + +pub fn ptc_duties( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain, +) -> Result { + let current_epoch = chain + .slot_clock + .now_or_genesis() + .map(|slot| slot.epoch(T::EthSpec::slots_per_epoch())) + .ok_or(BeaconChainError::UnableToReadSlot) + .map_err(warp_utils::reject::unhandled_error)?; + + let tolerant_current_epoch = if chain.slot_clock.is_prior_to_genesis().unwrap_or(true) { + current_epoch + } else { + chain + .slot_clock + .now_with_future_tolerance(chain.spec.maximum_gossip_clock_disparity()) + .ok_or_else(|| { + warp_utils::reject::custom_server_error("unable to read slot clock".into()) + })? + .epoch(T::EthSpec::slots_per_epoch()) + }; + + let is_within_clock_tolerance = request_epoch == current_epoch + || request_epoch == current_epoch + 1 + || request_epoch == tolerant_current_epoch + 1; + + if is_within_clock_tolerance { + let head_epoch = chain + .canonical_head + .cached_head() + .snapshot + .beacon_state + .current_epoch(); + + let head_can_serve_request = request_epoch == head_epoch || request_epoch == head_epoch + 1; + + if head_can_serve_request { + compute_ptc_duties_from_cached_head(request_epoch, request_indices, chain) + } else { + compute_ptc_duties_from_state(request_epoch, request_indices, chain) + } + } else if request_epoch > current_epoch + 1 { + Err(warp_utils::reject::custom_bad_request(format!( + "request epoch {} is more than one epoch past the current epoch {}", + request_epoch, current_epoch + ))) + } else { + compute_ptc_duties_from_state(request_epoch, request_indices, chain) + } +} + +fn compute_ptc_duties_from_cached_head( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain, +) -> Result { + let (cached_head, execution_status) = chain + .canonical_head + .head_and_execution_status() + .map_err(warp_utils::reject::unhandled_error)?; + let state = &cached_head.snapshot.beacon_state; + let head_block_root = cached_head.head_block_root(); + + let (duties, dependent_root) = chain + .compute_ptc_duties(state, request_epoch, request_indices, head_block_root) + .map_err(warp_utils::reject::unhandled_error)?; + + convert_to_api_response( + duties, + dependent_root, + execution_status.is_optimistic_or_invalid(), + ) +} + +fn compute_ptc_duties_from_state( + request_epoch: Epoch, + request_indices: &[u64], + chain: &BeaconChain, +) -> Result { + let state_opt = { + let (cached_head, execution_status) = chain + .canonical_head + .head_and_execution_status() + .map_err(warp_utils::reject::unhandled_error)?; + let head = &cached_head.snapshot; + + if head.beacon_state.current_epoch() <= request_epoch { + Some(( + head.beacon_state_root(), + head.beacon_state.clone(), + execution_status.is_optimistic_or_invalid(), + )) + } else { + None + } + }; + + let (state, execution_optimistic) = + if let Some((state_root, mut state, execution_optimistic)) = state_opt { + ensure_state_knows_ptc_duties_for_epoch( + &mut state, + state_root, + request_epoch, + &chain.spec, + )?; + (state, execution_optimistic) + } else { + let (state, execution_optimistic, _finalized) = + StateId::from_slot(request_epoch.start_slot(T::EthSpec::slots_per_epoch())) + .state(chain)?; + (state, execution_optimistic) + }; + + if !(state.current_epoch() == request_epoch || state.current_epoch() + 1 == request_epoch) { + return Err(warp_utils::reject::custom_server_error(format!( + "state epoch {} not suitable for request epoch {}", + state.current_epoch(), + request_epoch + ))); + } + + let (duties, dependent_root) = chain + .compute_ptc_duties( + &state, + request_epoch, + request_indices, + chain.genesis_block_root, + ) + .map_err(warp_utils::reject::unhandled_error)?; + + convert_to_api_response(duties, dependent_root, execution_optimistic) +} + +fn ensure_state_knows_ptc_duties_for_epoch( + state: &mut BeaconState, + state_root: Hash256, + target_epoch: Epoch, + spec: &ChainSpec, +) -> Result<(), warp::reject::Rejection> { + if state.current_epoch() > target_epoch { + return Err(warp_utils::reject::custom_server_error(format!( + "state epoch {} is later than target epoch {}", + state.current_epoch(), + target_epoch + ))); + } else if state.current_epoch() + 1 < target_epoch { + let target_slot = target_epoch + .saturating_sub(1_u64) + .start_slot(E::slots_per_epoch()); + + partial_state_advance(state, Some(state_root), target_slot, spec) + .map_err(BeaconChainError::from) + .map_err(warp_utils::reject::unhandled_error)?; + } + + Ok(()) +} + +fn convert_to_api_response( + duties: Vec>, + dependent_root: Hash256, + execution_optimistic: bool, +) -> Result { + Ok(api_types::DutiesResponse { + dependent_root, + execution_optimistic: Some(execution_optimistic), + data: duties.into_iter().flatten().collect(), + }) +} diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 7349aa4db0..27fe5de6e7 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -7,7 +7,7 @@ use crate::utils::{ ResponseFilter, TaskSpawnerFilter, ValidatorSubscriptionTxFilter, publish_network_message, }; use crate::version::{V1, V2, V3, unsupported_version_rejection}; -use crate::{StateId, attester_duties, proposer_duties, sync_committees}; +use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; use bls::PublicKeyBytes; @@ -168,6 +168,42 @@ pub fn post_validator_duties_attester( .boxed() } +// POST validator/duties/ptc/{epoch} +pub fn post_validator_duties_ptc( + eth_v1: EthV1Filter, + chain_filter: ChainFilter, + not_while_syncing_filter: NotWhileSyncingFilter, + task_spawner_filter: TaskSpawnerFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("duties")) + .and(warp::path("ptc")) + .and(warp::path::param::().or_else(|_| async { + Err(warp_utils::reject::custom_bad_request( + "Invalid epoch".to_string(), + )) + })) + .and(warp::path::end()) + .and(not_while_syncing_filter.clone()) + .and(warp_utils::json::json()) + .and(task_spawner_filter.clone()) + .and(chain_filter.clone()) + .then( + |epoch: Epoch, + not_synced_filter: Result<(), Rejection>, + indices: ValidatorIndexData, + task_spawner: TaskSpawner, + chain: Arc>| { + task_spawner.blocking_json_task(Priority::P0, move || { + not_synced_filter?; + ptc_duties::ptc_duties(epoch, &indices.0, &chain) + }) + }, + ) + .boxed() +} + // GET validator/aggregate_attestation?attestation_data_root,slot pub fn get_validator_aggregate_attestation( any_version: AnyVersionFilter, diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 2dd4c28040..aac3384fbd 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -3474,7 +3474,6 @@ impl ApiTester { self } - // TODO(EIP-7732): Add test_get_validator_duties_ptc function to test PTC duties endpoint pub async fn test_get_validator_duties_proposer_v2(self) -> Self { let current_epoch = self.chain.epoch().unwrap(); @@ -3598,6 +3597,17 @@ impl ApiTester { "should not get attester duties outside of tolerance" ); + assert_eq!( + self.client + .post_validator_duties_ptc(next_epoch, &[0]) + .await + .unwrap_err() + .status() + .map(Into::into), + Some(400), + "should not get ptc duties outside of tolerance" + ); + self.chain.slot_clock.set_current_time( current_epoch_start - self.chain.spec.maximum_gossip_clock_disparity(), ); @@ -3621,6 +3631,88 @@ impl ApiTester { .await .expect("should get attester duties within tolerance"); + self.client + .post_validator_duties_ptc(next_epoch, &[0]) + .await + .expect("should get ptc duties within tolerance"); + + self + } + + pub async fn test_get_validator_duties_ptc(self) -> Self { + let current_epoch = self.chain.epoch().unwrap().as_u64(); + + let half = current_epoch / 2; + let first = current_epoch - half; + let last = current_epoch + half; + + for epoch in first..=last { + for indices in self.interesting_validator_indices() { + let epoch = Epoch::from(epoch); + + // The endpoint does not allow getting duties past the next epoch. + if epoch > current_epoch + 1 { + assert_eq!( + self.client + .post_validator_duties_ptc(epoch, indices.as_slice()) + .await + .unwrap_err() + .status() + .map(Into::into), + Some(400) + ); + continue; + } + + let results = self + .client + .post_validator_duties_ptc(epoch, indices.as_slice()) + .await + .unwrap(); + + let dependent_root = self + .chain + .block_root_at_slot( + (epoch - 1).start_slot(E::slots_per_epoch()) - 1, + WhenSlotSkipped::Prev, + ) + .unwrap() + .unwrap_or(self.chain.head_beacon_block_root()); + + assert_eq!(results.dependent_root, dependent_root); + + let result_duties = results.data; + + let state = self + .chain + .state_at_slot( + epoch.start_slot(E::slots_per_epoch()), + StateSkipConfig::WithStateRoots, + ) + .unwrap(); + + let expected_duties: Vec = indices + .iter() + .filter_map(|&validator_index| { + let validator = state.validators().get(validator_index as usize)?; + let slot = state + .get_ptc_assignment(validator_index as usize, epoch, &self.chain.spec) + .unwrap()?; + Some(PtcDuty { + pubkey: validator.pubkey, + validator_index, + slot, + }) + }) + .collect(); + + assert_eq!( + result_duties, expected_duties, + "ptc duties should exactly match state assignments" + ); + } + } + self } @@ -7871,6 +7963,9 @@ async fn get_light_client_finality_update() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_validator_duties_early() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } ApiTester::new() .await .test_get_validator_duties_early() @@ -7936,6 +8031,29 @@ async fn get_validator_duties_proposer_v2_with_skip_slots() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_ptc() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .test_get_validator_duties_ptc() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_duties_ptc_with_skip_slots() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .skip_slots(E::slots_per_epoch() * 2) + .test_get_validator_duties_ptc() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn block_production() { ApiTester::new().await.test_block_production().await; diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 7e2b3096a8..7ed3121d6e 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -3198,6 +3198,27 @@ impl BeaconState { Ok(hash(&preimage)) } + /// Find the first slot in the given epoch where the validator is assigned to the PTC. + /// + /// Returns `Ok(Some(slot))` if the validator is in the PTC for any slot in the epoch, + /// `Ok(None)` if the validator is not in the PTC for this epoch. + /// + /// This iterates through all slots in the epoch, so it's O(slots_per_epoch) per validator. + pub fn get_ptc_assignment( + &self, + validator_index: usize, + epoch: Epoch, + spec: &ChainSpec, + ) -> Result, BeaconStateError> { + for slot in epoch.slot_iter(E::slots_per_epoch()) { + let ptc = self.get_ptc(slot, spec)?; + if ptc.0.contains(&validator_index) { + return Ok(Some(slot)); + } + } + Ok(None) + } + /// Return size indices sampled by effective balance, using indices as candidates. /// /// If shuffle_indices is True, candidate indices are themselves sampled from indices From 6ab48a76f0aab997dd7a818d8b02541d197e1746 Mon Sep 17 00:00:00 2001 From: hopinheimer <48147533+hopinheimer@users.noreply.github.com> Date: Mon, 27 Apr 2026 05:51:20 -0400 Subject: [PATCH 140/189] Gloas `PayloadAttestation` gossip verification (#9145) Co-Authored-By: hopinheimer Co-Authored-By: hopinheimer <48147533+hopinheimer@users.noreply.github.com> Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Jimmy Chen --- beacon_node/beacon_chain/src/beacon_chain.rs | 22 +- beacon_node/beacon_chain/src/builder.rs | 1 + beacon_node/beacon_chain/src/lib.rs | 1 + beacon_node/beacon_chain/src/metrics.rs | 21 + .../beacon_chain/src/observed_attesters.rs | 42 ++ .../gossip_verified_payload_attestation.rs | 255 +++++++++++ .../payload_attestation_verification/mod.rs | 110 +++++ .../payload_attestation_verification/tests.rs | 422 ++++++++++++++++++ .../gossip_methods.rs | 152 ++++++- .../src/network_beacon_processor/mod.rs | 2 +- consensus/fork_choice/src/fork_choice.rs | 2 +- 11 files changed, 1014 insertions(+), 16 deletions(-) create mode 100644 beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs create mode 100644 beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs create mode 100644 beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index bfe1b404e0..cf5afb089a 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -53,7 +53,8 @@ use crate::observed_aggregates::{ Error as AttestationObservationError, ObservedAggregateAttestations, ObservedSyncContributions, }; use crate::observed_attesters::{ - ObservedAggregators, ObservedAttesters, ObservedSyncAggregators, ObservedSyncContributors, + ObservedAggregators, ObservedAttesters, ObservedPayloadAttesters, ObservedSyncAggregators, + ObservedSyncContributors, }; use crate::observed_block_producers::ObservedBlockProducers; use crate::observed_data_sidecars::ObservedDataSidecars; @@ -418,6 +419,9 @@ pub struct BeaconChain { /// Maintains a record of which validators have been seen to create `SignedContributionAndProofs` /// in recent epochs. pub(crate) observed_sync_aggregators: RwLock>, + /// Maintains a record of which validators have sent payload attestation messages + /// in recent slots. + pub(crate) observed_payload_attesters: RwLock>, /// Maintains a record of which validators have proposed blocks for each slot. pub observed_block_producers: RwLock>, /// Maintains a record of blob sidecars seen over the gossip network. @@ -2308,6 +2312,22 @@ impl BeaconChain { }) } + pub fn apply_payload_attestation_to_fork_choice( + &self, + indexed_payload_attestation: &IndexedPayloadAttestation, + ptc: &PTC, + ) -> Result<(), Error> { + self.canonical_head + .fork_choice_write_lock() + .on_payload_attestation( + self.slot()?, + indexed_payload_attestation, + AttestationFromBlock::False, + &ptc.0, + ) + .map_err(Into::into) + } + /// Accepts some `SyncCommitteeMessage` from the network and attempts to verify it, returning `Ok(_)` if /// it is valid to be (re)broadcast on the gossip network. pub fn verify_sync_committee_message_for_gossip( diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 19eb1aa877..d70561db9b 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -1015,6 +1015,7 @@ where observed_aggregators: <_>::default(), // TODO: allow for persisting and loading the pool from disk. observed_sync_aggregators: <_>::default(), + observed_payload_attesters: <_>::default(), // TODO: allow for persisting and loading the pool from disk. observed_block_producers: <_>::default(), observed_column_sidecars: RwLock::new(ObservedDataSidecars::new(self.spec.clone())), diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index 7631e6b904..d70fc1b3ec 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -44,6 +44,7 @@ pub mod observed_data_sidecars; pub mod observed_operations; mod observed_slashable; pub mod partial_data_column_assembler; +pub mod payload_attestation_verification; pub mod payload_bid_verification; pub mod payload_envelope_streamer; pub mod payload_envelope_verification; diff --git a/beacon_node/beacon_chain/src/metrics.rs b/beacon_node/beacon_chain/src/metrics.rs index ce136ef3fc..43c3337bc9 100644 --- a/beacon_node/beacon_chain/src/metrics.rs +++ b/beacon_node/beacon_chain/src/metrics.rs @@ -1468,6 +1468,27 @@ pub static SYNC_MESSAGE_GOSSIP_VERIFICATION_TIMES: LazyLock> = "Full runtime of sync contribution gossip verification", ) }); +pub static PAYLOAD_ATTESTATION_PROCESSING_REQUESTS: LazyLock> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_payload_attestation_processing_requests_total", + "Count of all payload attestation messages submitted for processing", + ) + }); +pub static PAYLOAD_ATTESTATION_PROCESSING_SUCCESSES: LazyLock> = + LazyLock::new(|| { + try_create_int_counter( + "beacon_payload_attestation_processing_successes_total", + "Number of payload attestation messages verified for gossip", + ) + }); +pub static PAYLOAD_ATTESTATION_GOSSIP_VERIFICATION_TIMES: LazyLock> = + LazyLock::new(|| { + try_create_histogram( + "beacon_payload_attestation_gossip_verification_seconds", + "Full runtime of payload attestation gossip verification", + ) + }); pub static SYNC_MESSAGE_EQUIVOCATIONS: LazyLock> = LazyLock::new(|| { try_create_int_counter( "sync_message_equivocations_total", diff --git a/beacon_node/beacon_chain/src/observed_attesters.rs b/beacon_node/beacon_chain/src/observed_attesters.rs index 277bf38ffc..4bb536880c 100644 --- a/beacon_node/beacon_chain/src/observed_attesters.rs +++ b/beacon_node/beacon_chain/src/observed_attesters.rs @@ -42,6 +42,8 @@ pub type ObservedSyncContributors = pub type ObservedAggregators = AutoPruningEpochContainer; pub type ObservedSyncAggregators = AutoPruningSlotContainer; +pub type ObservedPayloadAttesters = + AutoPruningSlotContainer, E>; #[derive(Debug, PartialEq)] pub enum Error { @@ -255,6 +257,46 @@ impl Item<()> for SyncAggregatorSlotHashSet { } } +/// Stores a `HashSet` of validator indices that have sent a payload attestation gossip +/// message during a slot. +pub struct PayloadAttesterSlotHashSet { + set: HashSet, + phantom: PhantomData, +} + +impl Item<()> for PayloadAttesterSlotHashSet { + fn with_capacity(capacity: usize) -> Self { + Self { + set: HashSet::with_capacity(capacity), + phantom: PhantomData, + } + } + + /// Defaults to `PTC_SIZE`, the maximum number of payload attesters per slot. + fn default_capacity() -> usize { + E::ptc_size() + } + + fn len(&self) -> usize { + self.set.len() + } + + fn validator_count(&self) -> usize { + self.set.len() + } + + /// Inserts the `validator_index` in the set. Returns `true` if the `validator_index` was + /// already in the set. + fn insert(&mut self, validator_index: usize, _value: ()) -> bool { + !self.set.insert(validator_index) + } + + /// Returns `true` if the `validator_index` is in the set. + fn get(&self, validator_index: usize) -> Option<()> { + self.set.contains(&validator_index).then_some(()) + } +} + /// A container that stores some number of `T` items. /// /// This container is "auto-pruning" since it gets an idea of the current slot by which diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs new file mode 100644 index 0000000000..2d9fce812e --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs @@ -0,0 +1,255 @@ +use super::Error; +use crate::beacon_chain::BeaconStore; +use crate::canonical_head::CanonicalHead; +use crate::observed_attesters::ObservedPayloadAttesters; +use crate::validator_pubkey_cache::ValidatorPubkeyCache; +use crate::{BeaconChain, BeaconChainError, BeaconChainTypes, metrics}; +use bls::AggregateSignature; +use educe::Educe; +use parking_lot::RwLock; +use safe_arith::SafeArith; +use slot_clock::SlotClock; +use state_processing::per_block_processing::signature_sets::indexed_payload_attestation_signature_set; +use state_processing::state_advance::partial_state_advance; +use std::borrow::Cow; +use types::{ChainSpec, EthSpec, IndexedPayloadAttestation, PTC, PayloadAttestationMessage, Slot}; + +pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { + pub slot_clock: &'a T::SlotClock, + pub spec: &'a ChainSpec, + pub observed_payload_attesters: &'a RwLock>, + pub canonical_head: &'a CanonicalHead, + pub validator_pubkey_cache: &'a RwLock>, + pub store: &'a BeaconStore, +} + +/// A `PayloadAttestationMessage` that has been verified for propagation on the gossip network. +#[derive(Educe)] +#[educe(Clone, Debug)] +pub struct VerifiedPayloadAttestationMessage { + payload_attestation_message: PayloadAttestationMessage, + indexed_payload_attestation: IndexedPayloadAttestation, + ptc: PTC, +} + +impl VerifiedPayloadAttestationMessage { + pub fn new( + payload_attestation_message: PayloadAttestationMessage, + ctx: &GossipVerificationContext<'_, T>, + ) -> Result { + let slot = payload_attestation_message.data.slot; + let validator_index = payload_attestation_message.validator_index; + + // [IGNORE] `data.slot` is within the `MAXIMUM_GOSSIP_CLOCK_DISPARITY` allowance. + verify_propagation_slot_range(ctx.slot_clock, slot, ctx.spec)?; + + // [IGNORE] There has been no other valid payload attestation message for this + // validator index. + if ctx + .observed_payload_attesters + .read() + .validator_has_been_observed(slot, validator_index as usize) + .map_err(BeaconChainError::from)? + { + return Err(Error::PriorPayloadAttestationMessageKnown { + validator_index, + slot, + }); + } + + // [IGNORE] `data.beacon_block_root` has been seen + // [REJECT] `data.beacon_block_root` passes validation. + // + // TODO(gloas): These two conditions are conflated. We need a status table to + // differentiate between: + // 1. Blocks we haven't seen (IGNORE), and + // 2. Blocks we've seen that are invalid (REJECT). + // Presently both cases return IGNORE. + let beacon_block_root = payload_attestation_message.data.beacon_block_root; + if ctx + .canonical_head + .fork_choice_read_lock() + .get_block(&beacon_block_root) + .is_none() + { + return Err(Error::UnknownHeadBlock { beacon_block_root }); + } + + // Get head state for PTC computation. If the cached head state is too stale + // (e.g. during liveness failures with many skipped slots), fall back to loading + // a more recent state from the store and advancing it if necessary. + let head = ctx.canonical_head.cached_head(); + let head_state = &head.snapshot.beacon_state; + + let message_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); + let state_epoch = head_state.current_epoch(); + + // get_ptc can serve epochs in [state_epoch - 1, state_epoch + min_seed_lookahead]. + // If the message epoch is beyond that range, the head state is stale. + let advanced_state = if message_epoch + > state_epoch + .safe_add(ctx.spec.min_seed_lookahead) + .map_err(BeaconChainError::from)? + { + let head_block_root = head.head_block_root(); + let target_slot = message_epoch.start_slot(T::EthSpec::slots_per_epoch()); + + let (state_root, mut state) = ctx + .store + .get_advanced_hot_state( + head_block_root, + target_slot, + head.snapshot.beacon_state_root(), + ) + .map_err(BeaconChainError::from)? + .ok_or(BeaconChainError::MissingBeaconState( + head.snapshot.beacon_state_root(), + ))?; + + if state + .current_epoch() + .safe_add(ctx.spec.min_seed_lookahead) + .map_err(BeaconChainError::from)? + < message_epoch + { + partial_state_advance(&mut state, Some(state_root), target_slot, ctx.spec) + .map_err(BeaconChainError::from)?; + } + + Some(state) + } else { + None + }; + + let state = advanced_state.as_ref().unwrap_or(head_state); + + // [REJECT] `validator_index` is within `get_ptc(state, data.slot)`. + let ptc = state.get_ptc(slot, ctx.spec)?; + if !ptc.0.contains(&(validator_index as usize)) { + return Err(Error::NotInPTC { + validator_index, + slot, + }); + } + + // Build the indexed form for signature verification and downstream fork choice. + let indexed_payload_attestation = IndexedPayloadAttestation { + attesting_indices: vec![validator_index] + .try_into() + .map_err(|_| Error::UnknownValidatorIndex(validator_index))?, + data: payload_attestation_message.data.clone(), + signature: AggregateSignature::from(&payload_attestation_message.signature), + }; + + { + // [REJECT] The signature is valid with respect to the `validator_index`. + let pubkey_cache = ctx.validator_pubkey_cache.read(); + let signature_set = indexed_payload_attestation_signature_set( + state, + |validator_index| pubkey_cache.get(validator_index).map(Cow::Borrowed), + &indexed_payload_attestation.signature, + &indexed_payload_attestation, + ctx.spec, + ) + .map_err(|_| Error::UnknownValidatorIndex(validator_index))?; + + if !signature_set.verify() { + return Err(Error::InvalidSignature); + } + } + + // Record that we have received a valid payload attestation message from this + // validator. Double check with the write lock to handle race conditions. + if ctx + .observed_payload_attesters + .write() + .observe_validator(slot, validator_index as usize, ()) + .map_err(BeaconChainError::from)? + { + return Err(Error::PriorPayloadAttestationMessageKnown { + validator_index, + slot, + }); + } + + Ok(Self { + payload_attestation_message, + indexed_payload_attestation, + ptc, + }) + } + + pub fn payload_attestation_message(&self) -> &PayloadAttestationMessage { + &self.payload_attestation_message + } + + pub fn indexed_payload_attestation(&self) -> &IndexedPayloadAttestation { + &self.indexed_payload_attestation + } + + pub fn ptc(&self) -> &PTC { + &self.ptc + } + + pub fn into_payload_attestation_message(self) -> PayloadAttestationMessage { + self.payload_attestation_message + } +} + +impl BeaconChain { + pub fn payload_attestation_gossip_context(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + slot_clock: &self.slot_clock, + spec: &self.spec, + observed_payload_attesters: &self.observed_payload_attesters, + canonical_head: &self.canonical_head, + validator_pubkey_cache: &self.validator_pubkey_cache, + store: &self.store, + } + } + + pub fn verify_payload_attestation_message_for_gossip( + &self, + payload_attestation_message: PayloadAttestationMessage, + ) -> Result, Error> { + metrics::inc_counter(&metrics::PAYLOAD_ATTESTATION_PROCESSING_REQUESTS); + let _timer = metrics::start_timer(&metrics::PAYLOAD_ATTESTATION_GOSSIP_VERIFICATION_TIMES); + + let ctx = self.payload_attestation_gossip_context(); + VerifiedPayloadAttestationMessage::new(payload_attestation_message, &ctx).inspect(|_| { + metrics::inc_counter(&metrics::PAYLOAD_ATTESTATION_PROCESSING_SUCCESSES); + }) + } +} + +/// Verify that the `slot` is within the acceptable gossip propagation range, with reference +/// to the current slot of the clock. +/// +/// Accounts for `MAXIMUM_GOSSIP_CLOCK_DISPARITY`. +fn verify_propagation_slot_range( + slot_clock: &S, + message_slot: Slot, + spec: &ChainSpec, +) -> Result<(), Error> { + let latest_permissible_slot = slot_clock + .now_with_future_tolerance(spec.maximum_gossip_clock_disparity()) + .ok_or(BeaconChainError::UnableToReadSlot)?; + if message_slot > latest_permissible_slot { + return Err(Error::FutureSlot { + message_slot, + latest_permissible_slot, + }); + } + + let earliest_permissible_slot = slot_clock + .now_with_past_tolerance(spec.maximum_gossip_clock_disparity()) + .ok_or(BeaconChainError::UnableToReadSlot)?; + if message_slot < earliest_permissible_slot { + return Err(Error::PastSlot { + message_slot, + earliest_permissible_slot, + }); + } + + Ok(()) +} diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs new file mode 100644 index 0000000000..477527c0aa --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs @@ -0,0 +1,110 @@ +//! Provides verification for `PayloadAttestationMessage` received from the gossip network. +//! +//! ```ignore +//! types::PayloadAttestationMessage +//! | +//! ▼ +//! VerifiedPayloadAttestationMessage +//! ``` + +use crate::BeaconChainError; +use strum::AsRefStr; +use types::{BeaconStateError, Hash256, Slot}; + +pub mod gossip_verified_payload_attestation; + +pub use gossip_verified_payload_attestation::{ + GossipVerificationContext, VerifiedPayloadAttestationMessage, +}; + +/// Returned when a payload attestation message was not successfully verified. It might not have +/// been verified for two reasons: +/// +/// - The message is malformed or inappropriate for the context (indicated by all variants +/// other than `BeaconChainError`). +/// - The application encountered an internal error whilst attempting to determine validity +/// (the `BeaconChainError` variant) +#[derive(Debug, AsRefStr)] +pub enum Error { + /// The payload attestation message is from a slot that is later than the current slot + /// (with respect to the gossip clock disparity). + /// + /// ## Peer scoring + /// + /// Assuming the local clock is correct, the peer has sent an invalid message. + FutureSlot { + message_slot: Slot, + latest_permissible_slot: Slot, + }, + /// The payload attestation message is from a slot that is prior to the earliest + /// permissible slot (with respect to the gossip clock disparity). + /// + /// ## Peer scoring + /// + /// Assuming the local clock is correct, the peer has sent an invalid message. + PastSlot { + message_slot: Slot, + earliest_permissible_slot: Slot, + }, + /// We have already observed a valid payload attestation message from this validator + /// for this slot. + /// + /// ## Peer scoring + /// + /// The peer is not necessarily faulty. + PriorPayloadAttestationMessageKnown { validator_index: u64, slot: Slot }, + /// The beacon block referenced by the payload attestation message is not known. + /// + /// ## Peer scoring + /// + /// The attestation points to a block we have not yet imported. It's unclear if the + /// attestation is valid or not. + UnknownHeadBlock { beacon_block_root: Hash256 }, + /// The validator index is not a member of the PTC for this slot. + /// + /// ## Peer scoring + /// + /// The peer has sent an invalid message. + NotInPTC { validator_index: u64, slot: Slot }, + /// The validator index is unknown. + /// + /// ## Peer scoring + /// + /// The peer has sent an invalid message. + UnknownValidatorIndex(u64), + /// The signature on the payload attestation message is invalid. + /// + /// ## Peer scoring + /// + /// The peer has sent an invalid message. + InvalidSignature, + /// There was an error whilst processing the payload attestation message. It is not known + /// if it is valid or invalid. + /// + /// ## Peer scoring + /// + /// We were unable to process this message due to an internal error. It's unclear if the + /// message is valid. + BeaconChainError(Box), + /// An error reading beacon state. + /// + /// ## Peer scoring + /// + /// We were unable to process this message due to an internal error. + BeaconStateError(BeaconStateError), +} + +impl From for Error { + fn from(e: BeaconChainError) -> Self { + Error::BeaconChainError(Box::new(e)) + } +} + +impl From for Error { + fn from(e: BeaconStateError) -> Self { + Error::BeaconStateError(e) + } +} + +#[cfg(test)] +mod tests; diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs new file mode 100644 index 0000000000..7faad98e55 --- /dev/null +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs @@ -0,0 +1,422 @@ +use std::sync::Arc; +use std::time::Duration; + +use bls::{Keypair, Signature}; +use fork_choice::ForkChoice; +use genesis::{generate_deterministic_keypairs, interop_genesis_state}; +use parking_lot::RwLock; +use proto_array::PayloadStatus; +use slot_clock::{SlotClock, TestingSlotClock}; +use state_processing::AllCaches; +use state_processing::genesis::genesis_block; +use store::{HotColdDB, StoreConfig}; +use types::{ + ChainSpec, Checkpoint, Domain, Epoch, EthSpec, Hash256, MinimalEthSpec, PayloadAttestationData, + PayloadAttestationMessage, SignedBeaconBlock, SignedRoot, Slot, +}; + +use crate::{ + beacon_fork_choice_store::BeaconForkChoiceStore, + beacon_snapshot::BeaconSnapshot, + canonical_head::CanonicalHead, + observed_attesters::ObservedPayloadAttesters, + payload_attestation_verification::{ + Error as PayloadAttestationError, + gossip_verified_payload_attestation::{ + GossipVerificationContext, VerifiedPayloadAttestationMessage, + }, + }, + test_utils::{BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, test_spec}, + validator_pubkey_cache::ValidatorPubkeyCache, +}; + +type E = MinimalEthSpec; +type T = EphemeralHarnessType; + +const NUM_VALIDATORS: usize = 64; + +struct TestContext { + canonical_head: CanonicalHead, + observed_payload_attesters: RwLock>, + validator_pubkey_cache: RwLock>, + slot_clock: TestingSlotClock, + keypairs: Vec, + spec: ChainSpec, + genesis_block_root: Hash256, + store: Arc, store::MemoryStore>>, +} + +impl TestContext { + fn new() -> Self { + let spec = test_spec::(); + let store = Arc::new( + HotColdDB::open_ephemeral(StoreConfig::default(), Arc::new(spec.clone())) + .expect("should open ephemeral store"), + ); + + let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS); + + let mut state = + interop_genesis_state::(&keypairs, 0, Hash256::repeat_byte(0x42), None, &spec) + .expect("should build genesis state"); + + *state.finalized_checkpoint_mut() = Checkpoint { + epoch: Epoch::new(1), + root: Hash256::ZERO, + }; + + let mut block = genesis_block(&state, &spec).expect("should build genesis block"); + *block.state_root_mut() = state + .update_tree_hash_cache() + .expect("should hash genesis state"); + let signed_block = SignedBeaconBlock::from_block(block, Signature::empty()); + let block_root = signed_block.canonical_root(); + + let snapshot = BeaconSnapshot::new( + Arc::new(signed_block.clone()), + None, + block_root, + state.clone(), + ); + + let fc_store = BeaconForkChoiceStore::get_forkchoice_store(store.clone(), snapshot.clone()) + .expect("should create fork choice store"); + let fork_choice = + ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec) + .expect("should create fork choice"); + + let canonical_head = + CanonicalHead::new(fork_choice, Arc::new(snapshot), PayloadStatus::Pending); + + let slot_clock = TestingSlotClock::new( + Slot::new(0), + Duration::from_secs(0), + spec.get_slot_duration(), + ); + // Advance past genesis so `now_with_past_tolerance` doesn't underflow. + slot_clock.set_current_time(spec.get_slot_duration()); + + let validator_pubkey_cache = + ValidatorPubkeyCache::new(&state, store.clone()).expect("should create pubkey cache"); + + Self { + canonical_head, + observed_payload_attesters: RwLock::new(ObservedPayloadAttesters::default()), + validator_pubkey_cache: RwLock::new(validator_pubkey_cache), + slot_clock, + keypairs, + spec, + genesis_block_root: block_root, + store, + } + } + + fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { + GossipVerificationContext { + slot_clock: &self.slot_clock, + spec: &self.spec, + observed_payload_attesters: &self.observed_payload_attesters, + canonical_head: &self.canonical_head, + validator_pubkey_cache: &self.validator_pubkey_cache, + store: &self.store, + } + } + + fn ptc_members(&self, slot: Slot) -> Vec { + let head = self.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let ptc = state.get_ptc(slot, &self.spec).expect("should get PTC"); + ptc.0.to_vec() + } + + fn sign_payload_attestation( + &self, + data: PayloadAttestationData, + validator_index: u64, + ) -> PayloadAttestationMessage { + let head = self.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let domain = self.spec.get_domain( + data.slot.epoch(E::slots_per_epoch()), + Domain::PTCAttester, + &state.fork(), + state.genesis_validators_root(), + ); + let message = data.signing_root(domain); + let signature = self.keypairs[validator_index as usize].sk.sign(message); + PayloadAttestationMessage { + validator_index, + data, + signature, + } + } +} + +fn make_payload_attestation( + slot: Slot, + validator_index: u64, + beacon_block_root: Hash256, +) -> PayloadAttestationMessage { + PayloadAttestationMessage { + validator_index, + data: PayloadAttestationData { + beacon_block_root, + slot, + payload_present: true, + blob_data_available: true, + }, + signature: Signature::empty(), + } +} + +#[test] +fn future_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let future_slot = Slot::new(5); + let msg = make_payload_attestation(future_slot, 0, ctx.genesis_block_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!(matches!( + result, + Err(PayloadAttestationError::FutureSlot { .. }) + )); +} + +#[test] +fn past_slot() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + ctx.slot_clock.set_slot(5); + let gossip = ctx.gossip_ctx(); + + let msg = make_payload_attestation(Slot::new(0), 0, ctx.genesis_block_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!(matches!( + result, + Err(PayloadAttestationError::PastSlot { .. }) + )); +} + +#[test] +fn unknown_head_block() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let unknown_root = Hash256::repeat_byte(0xff); + let msg = make_payload_attestation(Slot::new(1), 0, unknown_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!( + matches!( + result, + Err(PayloadAttestationError::UnknownHeadBlock { .. }) + ), + "expected UnknownHeadBlock, got: {:?}", + result + ); +} + +#[test] +fn not_in_ptc() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let ptc_members = ctx.ptc_members(slot); + let non_ptc_validator = (0..NUM_VALIDATORS as u64) + .find(|&i| !ptc_members.contains(&(i as usize))) + .expect("should find non-PTC validator"); + + let msg = make_payload_attestation(slot, non_ptc_validator, ctx.genesis_block_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!(matches!( + result, + Err(PayloadAttestationError::NotInPTC { .. }) + )); +} + +#[test] +fn invalid_signature() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let ptc_members = ctx.ptc_members(slot); + let validator_index = ptc_members[0] as u64; + + let msg = make_payload_attestation(slot, validator_index, ctx.genesis_block_root); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!(matches!( + result, + Err(PayloadAttestationError::InvalidSignature) + )); +} + +#[test] +fn valid_payload_attestation() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let ptc_members = ctx.ptc_members(slot); + let validator_index = ptc_members[0] as u64; + + let data = PayloadAttestationData { + beacon_block_root: ctx.genesis_block_root, + slot, + payload_present: true, + blob_data_available: true, + }; + let msg = ctx.sign_payload_attestation(data, validator_index); + let result = VerifiedPayloadAttestationMessage::new(msg, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn duplicate_after_valid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + + let ptc_members = ctx.ptc_members(slot); + let validator_index = ptc_members[0] as u64; + + let data = PayloadAttestationData { + beacon_block_root: ctx.genesis_block_root, + slot, + payload_present: true, + blob_data_available: true, + }; + + let msg1 = ctx.sign_payload_attestation(data.clone(), validator_index); + let result1 = VerifiedPayloadAttestationMessage::new(msg1, &gossip); + assert!( + result1.is_ok(), + "first message should pass: {:?}", + result1.unwrap_err() + ); + + let msg2 = ctx.sign_payload_attestation(data, validator_index); + let result2 = VerifiedPayloadAttestationMessage::new(msg2, &gossip); + assert!(matches!( + result2, + Err(PayloadAttestationError::PriorPayloadAttestationMessageKnown { .. }) + )); +} + +/// Exercises the `partial_state_advance` fallback in gossip verification when +/// the head state is too stale to compute PTC membership (e.g., during a +/// network liveness failure with many missed slots). +#[tokio::test] +async fn stale_head_with_partial_advance() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let slots_per_epoch = E::slots_per_epoch(); + // Head at epoch 1, message at epoch 5 — 4 epochs of missed slots. + // This exceeds min_seed_lookahead (1), triggering the fallback path: + // get_advanced_hot_state loads the stored state, then partial_state_advance + // advances it through epoch boundaries to populate ptc_window. + let head_slot = Slot::new(slots_per_epoch); + let missed_epochs = 4; + let target_slot = Slot::new(slots_per_epoch * (1 + missed_epochs)); + let target_epoch = target_slot.epoch(slots_per_epoch); + + // GIVEN a chain with blocks through epoch 1 (so the store has states). + let harness = BeaconChainHarness::builder(E::default()) + .default_spec() + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + harness.extend_to_slot(head_slot).await; + + let head = harness.chain.canonical_head.cached_head(); + let head_epoch = head.snapshot.beacon_state.current_epoch(); + assert!( + target_epoch > head_epoch + harness.spec.min_seed_lookahead, + "precondition: message epoch must exceed head + min_seed_lookahead to trigger fallback" + ); + + // GIVEN a slot clock advanced to epoch 5 without producing blocks + // (simulating missed slots during a liveness failure). + harness.chain.slot_clock.set_slot(target_slot.as_u64()); + + // Advance a reference state to compute the PTC at the target slot. + let mut reference_state = head.snapshot.beacon_state.clone(); + state_processing::state_advance::partial_state_advance( + &mut reference_state, + Some(head.snapshot.beacon_state_root()), + target_slot, + &harness.spec, + ) + .expect("should advance reference state"); + reference_state + .build_all_caches(&harness.spec) + .expect("should build caches"); + + let ptc = reference_state + .get_ptc(target_slot, &harness.spec) + .expect("should get PTC from reference state"); + let validator_index = *ptc.0.first().expect("PTC should have at least one member") as u64; + + // WHEN a properly-signed payload attestation from a PTC member is verified. + let domain = harness.spec.get_domain( + target_epoch, + Domain::PTCAttester, + &reference_state.fork(), + reference_state.genesis_validators_root(), + ); + let data = PayloadAttestationData { + beacon_block_root: head.head_block_root(), + slot: target_slot, + payload_present: true, + blob_data_available: true, + }; + let message = data.signing_root(domain); + let signature = harness.validator_keypairs[validator_index as usize] + .sk + .sign(message); + let msg = PayloadAttestationMessage { + validator_index, + data, + signature, + }; + + // THEN verification succeeds despite the head being 4 epochs stale. + let result = harness + .chain + .verify_payload_attestation_message_for_gossip(msg); + assert!( + result.is_ok(), + "expected Ok (head epoch {}, message epoch {}), got: {:?}", + head_epoch, + target_epoch, + result.unwrap_err() + ); +} diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index ea1a2286a0..4083b1a3af 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -21,6 +21,9 @@ use beacon_chain::{ light_client_finality_update_verification::Error as LightClientFinalityUpdateError, light_client_optimistic_update_verification::Error as LightClientOptimisticUpdateError, observed_operations::ObservationOutcome, + payload_attestation_verification::{ + Error as PayloadAttestationError, VerifiedPayloadAttestationMessage, + }, sync_committee_verification::{self, Error as SyncCommitteeError}, validator_monitor::{get_block_delay_ms, get_slot_delay_ms}, }; @@ -137,6 +140,11 @@ struct RejectedAggregate { error: AttnError, } +struct RejectedPayloadAttestation { + payload_attestation_message: Box, + error: PayloadAttestationError, +} + /// Data for an aggregated or unaggregated attestation that failed verification. enum FailedAtt { Unaggregate { @@ -4088,25 +4096,143 @@ impl NetworkBeaconProcessor { } } - // TODO(gloas) dont forget to add tracing instrumentation + #[instrument( + level = "trace", + skip_all, + fields( + peer_id = %peer_id, + slot = %payload_attestation_message.data.slot, + validator_index = payload_attestation_message.validator_index, + ) + )] pub fn process_gossip_payload_attestation( self: &Arc, message_id: MessageId, peer_id: PeerId, - payload_attestation_message: PayloadAttestationMessage, + payload_attestation_message: Box, ) { - // TODO(EIP-7732): Implement proper payload attestation message gossip processing. - // This should integrate with a payload_attestation_verification.rs module once it's implemented. + let result = match self + .chain + .verify_payload_attestation_message_for_gossip(*payload_attestation_message.clone()) + { + Ok(verified) => Ok(verified), + Err(error) => Err(RejectedPayloadAttestation { + payload_attestation_message: payload_attestation_message.clone(), + error, + }), + }; - trace!( - %peer_id, - validator_index = payload_attestation_message.validator_index, - slot = %payload_attestation_message.data.slot, - beacon_block_root = %payload_attestation_message.data.beacon_block_root, - "Processing payload attestation message" - ); + self.process_gossip_payload_attestation_result(result, message_id, peer_id); + } - // For now, ignore all payload attestation messages since verification is not implemented - self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + fn process_gossip_payload_attestation_result( + self: &Arc, + result: Result, RejectedPayloadAttestation>, + message_id: MessageId, + peer_id: PeerId, + ) { + match result { + Ok(verified) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Accept); + + if let Err(e) = self.chain.apply_payload_attestation_to_fork_choice( + verified.indexed_payload_attestation(), + verified.ptc(), + ) { + match e { + BeaconChainError::ForkChoiceError( + ForkChoiceError::InvalidPayloadAttestation(e), + ) => { + debug!( + reason = ?e, + %peer_id, + "Payload attestation invalid for fork choice" + ) + } + e => error!( + reason = ?e, + %peer_id, + "Error applying payload attestation to fork choice" + ), + } + } + } + Err(RejectedPayloadAttestation { + payload_attestation_message, + error, + }) => { + self.handle_payload_attestation_verification_failure( + peer_id, + message_id, + error, + payload_attestation_message.data.slot, + ); + } + } + } + + fn handle_payload_attestation_verification_failure( + &self, + peer_id: PeerId, + message_id: MessageId, + error: PayloadAttestationError, + message_slot: Slot, + ) { + match &error { + PayloadAttestationError::FutureSlot { .. } => { + self.gossip_penalize_peer( + peer_id, + PeerAction::HighToleranceError, + "payload_attn_future_slot", + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + PayloadAttestationError::PastSlot { .. } + | PayloadAttestationError::PriorPayloadAttestationMessageKnown { .. } => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + PayloadAttestationError::UnknownHeadBlock { .. } => { + debug!( + %peer_id, + %message_slot, + "Payload attestation references unknown block" + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + PayloadAttestationError::NotInPTC { .. } => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "payload_attn_not_in_ptc", + ); + } + PayloadAttestationError::UnknownValidatorIndex(_) => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "payload_attn_unknown_validator", + ); + } + PayloadAttestationError::InvalidSignature => { + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Reject); + self.gossip_penalize_peer( + peer_id, + PeerAction::LowToleranceError, + "payload_attn_invalid_sig", + ); + } + PayloadAttestationError::BeaconChainError(_) + | PayloadAttestationError::BeaconStateError(_) => { + debug!( + %peer_id, + %message_slot, + ?error, + "Internal error verifying payload attestation" + ); + self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); + } + } } } diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index 015b6a616e..bfcff2088b 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -511,7 +511,7 @@ impl NetworkBeaconProcessor { processor.process_gossip_payload_attestation( message_id, peer_id, - *payload_attestation_message, + payload_attestation_message, ) }; diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index f9d779fd24..a9e62dbe94 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -1351,7 +1351,7 @@ where let ptc_indices: Vec = attestation .attesting_indices .iter() - .filter_map(|vi| ptc.iter().position(|&p| p == *vi as usize)) + .filter_map(|validator_index| ptc.iter().position(|&p| p == *validator_index as usize)) .collect(); // Check that all the attesters are in the PTC From 028b5a42a9715c31f416d45db70add39d9934b12 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Mon, 27 Apr 2026 17:13:35 +0200 Subject: [PATCH 141/189] Add payload attestation validator duty (#9178) Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Jimmy Chen --- beacon_node/http_api/src/beacon/pool.rs | 149 ++++++++++- beacon_node/http_api/src/lib.rs | 21 +- beacon_node/http_api/tests/tests.rs | 96 +++++++ common/eth2/src/lib.rs | 44 +++- .../lighthouse_validator_store/src/lib.rs | 42 +++- validator_client/signing_method/src/lib.rs | 5 + .../signing_method/src/web3signer.rs | 3 + validator_client/src/lib.rs | 19 ++ .../validator_services/src/lib.rs | 1 + .../src/payload_attestation_service.rs | 238 ++++++++++++++++++ validator_client/validator_store/src/lib.rs | 16 +- 11 files changed, 618 insertions(+), 16 deletions(-) create mode 100644 validator_client/validator_services/src/payload_attestation_service.rs diff --git a/beacon_node/http_api/src/beacon/pool.rs b/beacon_node/http_api/src/beacon/pool.rs index 059573c317..c6b8a69643 100644 --- a/beacon_node/http_api/src/beacon/pool.rs +++ b/beacon_node/http_api/src/beacon/pool.rs @@ -1,24 +1,31 @@ use crate::task_spawner::{Priority, TaskSpawner}; -use crate::utils::{NetworkTxFilter, OptionalConsensusVersionHeaderFilter, ResponseFilter}; +use crate::utils::{ + ChainFilter, EthV1Filter, NetworkTxFilter, OptionalConsensusVersionHeaderFilter, + ResponseFilter, TaskSpawnerFilter, +}; use crate::version::{ ResponseIncludesVersion, V1, V2, add_consensus_version_header, beacon_response, unsupported_version_rejection, }; use crate::{sync_committees, utils}; use beacon_chain::observed_operations::ObservationOutcome; +use beacon_chain::payload_attestation_verification::Error as PayloadAttestationError; use beacon_chain::{BeaconChain, BeaconChainTypes}; +use bytes::Bytes; use eth2::types::{AttestationPoolQuery, EndpointVersion, Failure, GenericResponse}; use lighthouse_network::PubsubMessage; use network::NetworkMessage; use operation_pool::ReceivedPreCapella; use slot_clock::SlotClock; +use ssz::{Decode, Encode}; use std::collections::HashSet; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use types::{ - Attestation, AttestationData, AttesterSlashing, ForkName, ProposerSlashing, - SignedBlsToExecutionChange, SignedVoluntaryExit, SingleAttestation, SyncCommitteeMessage, + Attestation, AttestationData, AttesterSlashing, ForkName, PayloadAttestationMessage, + ProposerSlashing, SignedBlsToExecutionChange, SignedVoluntaryExit, SingleAttestation, + SyncCommitteeMessage, }; use warp::filters::BoxedFilter; use warp::{Filter, Reply}; @@ -520,3 +527,137 @@ pub fn post_beacon_pool_attestations_v2( ) .boxed() } + +/// POST beacon/pool/payload_attestations (JSON) +pub fn post_beacon_pool_payload_attestations( + network_tx_filter: &NetworkTxFilter, + optional_consensus_version_header_filter: OptionalConsensusVersionHeaderFilter, + beacon_pool_path: &BeaconPoolPathFilter, +) -> ResponseFilter { + beacon_pool_path + .clone() + .and(warp::path("payload_attestations")) + .and(warp::path::end()) + .and(warp_utils::json::json()) + .and(optional_consensus_version_header_filter) + .and(network_tx_filter.clone()) + .then( + |task_spawner: TaskSpawner, + chain: Arc>, + messages: Vec, + _fork_name: Option, + network_tx: UnboundedSender>| { + task_spawner.blocking_json_task(Priority::P0, move || { + publish_payload_attestation_messages(&chain, &network_tx, messages) + }) + }, + ) + .boxed() +} + +/// POST beacon/pool/payload_attestations (SSZ) +pub fn post_beacon_pool_payload_attestations_ssz( + eth_v1: EthV1Filter, + task_spawner_filter: TaskSpawnerFilter, + chain_filter: ChainFilter, + network_tx_filter: NetworkTxFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("beacon")) + .and(warp::path("pool")) + .and(warp::path("payload_attestations")) + .and(warp::path::end()) + .and(warp::body::bytes()) + .and(task_spawner_filter) + .and(chain_filter) + .and(network_tx_filter) + .then( + |body_bytes: Bytes, + task_spawner: TaskSpawner, + chain: Arc>, + network_tx: UnboundedSender>| { + task_spawner.blocking_json_task(Priority::P0, move || { + let item_len = ::ssz_fixed_len(); + if !body_bytes.len().is_multiple_of(item_len) { + return Err(warp_utils::reject::custom_bad_request(format!( + "SSZ body length {} is not a multiple of PayloadAttestationMessage size {}", + body_bytes.len(), + item_len, + ))); + } + let messages: Vec = body_bytes + .chunks(item_len) + .map(|chunk| { + PayloadAttestationMessage::from_ssz_bytes(chunk).map_err(|e| { + warp_utils::reject::custom_bad_request(format!( + "invalid SSZ: {e:?}" + )) + }) + }) + .collect::>()?; + + publish_payload_attestation_messages(&chain, &network_tx, messages) + }) + }, + ) + .boxed() +} + +fn publish_payload_attestation_messages( + chain: &BeaconChain, + network_tx: &UnboundedSender>, + messages: Vec, +) -> Result<(), warp::Rejection> { + let mut failures = vec![]; + let mut num_already_known = 0; + + for (index, message) in messages.into_iter().enumerate() { + match chain.verify_payload_attestation_message_for_gossip(message.clone()) { + Ok(verified) => { + utils::publish_pubsub_message( + network_tx, + PubsubMessage::PayloadAttestation(Box::new(message)), + )?; + + if let Err(e) = chain.apply_payload_attestation_to_fork_choice( + verified.indexed_payload_attestation(), + verified.ptc(), + ) { + warn!( + error = ?e, + request_index = index, + "Payload attestation invalid for fork choice" + ); + } + } + Err(PayloadAttestationError::PriorPayloadAttestationMessageKnown { .. }) => { + num_already_known += 1; + } + // TODO(gloas): requeue for reprocessing like attestations do. + Err(e) => { + error!( + error = ?e, + request_index = index, + "Failure verifying payload attestation for gossip" + ); + failures.push(Failure::new(index, format!("{e:?}"))); + } + } + } + + if num_already_known > 0 { + debug!( + count = num_already_known, + "Some payload attestations already known" + ); + } + + if failures.is_empty() { + Ok(()) + } else { + Err(warp_utils::reject::indexed_bad_request( + "error processing payload attestations".to_string(), + failures, + )) + } +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index bd80dd1e82..b2d069f384 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -1454,7 +1454,7 @@ pub fn serve( let post_beacon_pool_attestations_v2 = post_beacon_pool_attestations_v2( &network_tx_filter, - optional_consensus_version_header_filter, + optional_consensus_version_header_filter.clone(), &beacon_pool_path_v2, ); @@ -1487,6 +1487,21 @@ pub fn serve( let post_beacon_pool_sync_committees = post_beacon_pool_sync_committees(&network_tx_filter, &beacon_pool_path); + // POST beacon/pool/payload_attestations + let post_beacon_pool_payload_attestations = post_beacon_pool_payload_attestations( + &network_tx_filter, + optional_consensus_version_header_filter, + &beacon_pool_path, + ); + + // POST beacon/pool/payload_attestations (SSZ) + let post_beacon_pool_payload_attestations_ssz = post_beacon_pool_payload_attestations_ssz( + eth_v1.clone(), + task_spawner_filter.clone(), + chain_filter.clone(), + network_tx_filter.clone(), + ); + // GET beacon/pool/bls_to_execution_changes let get_beacon_pool_bls_to_execution_changes = get_beacon_pool_bls_to_execution_changes(&beacon_pool_path); @@ -3400,7 +3415,8 @@ pub fn serve( .uor(post_beacon_blocks_v2_ssz) .uor(post_beacon_blinded_blocks_ssz) .uor(post_beacon_blinded_blocks_v2_ssz) - .uor(post_beacon_execution_payload_envelope_ssz), + .uor(post_beacon_execution_payload_envelope_ssz) + .uor(post_beacon_pool_payload_attestations_ssz), ) .uor(post_beacon_blocks) .uor(post_beacon_blinded_blocks) @@ -3411,6 +3427,7 @@ pub fn serve( .uor(post_beacon_pool_proposer_slashings) .uor(post_beacon_pool_voluntary_exits) .uor(post_beacon_pool_sync_committees) + .uor(post_beacon_pool_payload_attestations) .uor(post_beacon_pool_bls_to_execution_changes) .uor(post_beacon_execution_payload_envelope) .uor(post_beacon_state_validators) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index aac3384fbd..b8326f4495 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -2793,6 +2793,89 @@ impl ApiTester { self } + fn make_valid_payload_attestation_message( + &self, + ptc_offset: usize, + ) -> PayloadAttestationMessage { + let head = self.chain.head_snapshot(); + let head_slot = head.beacon_block.slot(); + let head_root = head.beacon_block_root; + let fork = head.beacon_state.fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + + let ptc = head + .beacon_state + .get_ptc(head_slot, &self.chain.spec) + .expect("should get PTC"); + + // Find distinct validator indices in the PTC (may contain duplicates due to + // weighted sampling with a small validator set). + let mut seen = std::collections::HashSet::new(); + let distinct_indices: Vec = ptc + .0 + .iter() + .copied() + .filter(|idx| seen.insert(*idx)) + .collect(); + let validator_index = distinct_indices[ptc_offset % distinct_indices.len()]; + + let data = PayloadAttestationData { + beacon_block_root: head_root, + slot: head_slot, + payload_present: true, + blob_data_available: true, + }; + + let epoch = head_slot.epoch(E::slots_per_epoch()); + let domain = + self.chain + .spec + .get_domain(epoch, Domain::PTCAttester, &fork, genesis_validators_root); + let signing_root = data.signing_root(domain); + let sk = &self.validator_keypairs()[validator_index].sk; + let signature = sk.sign(signing_root); + + PayloadAttestationMessage { + validator_index: validator_index as u64, + data, + signature, + } + } + + pub async fn test_post_beacon_pool_payload_attestations_valid(mut self) -> Self { + let message = self.make_valid_payload_attestation_message(0); + let fork_name = self.chain.spec.fork_name_at_slot::(message.data.slot); + + self.client + .post_beacon_pool_payload_attestations(&[message], fork_name) + .await + .unwrap(); + + assert!( + self.network_rx.network_recv.recv().await.is_some(), + "valid payload attestation should be sent to network" + ); + + self + } + + pub async fn test_post_beacon_pool_payload_attestations_valid_ssz(mut self) -> Self { + let message = self.make_valid_payload_attestation_message(1); + let fork_name = self.chain.spec.fork_name_at_slot::(message.data.slot); + + self.client + .post_beacon_pool_payload_attestations_ssz(&[message], fork_name) + .await + .unwrap(); + + assert!( + self.network_rx.network_recv.recv().await.is_some(), + "valid payload attestation (SSZ) should be sent to network" + ); + + self + } + pub async fn test_get_config_fork_schedule(self) -> Self { let result = self.client.get_config_fork_schedule().await.unwrap().data; @@ -8246,6 +8329,19 @@ async fn get_validator_payload_attestation_data_pre_gloas() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_beacon_pool_payload_attestations_valid() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new() + .await + .test_post_beacon_pool_payload_attestations_valid() + .await + .test_post_beacon_pool_payload_attestations_valid_ssz() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_validator_aggregate_attestation_v1() { ApiTester::new() diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index 4ec75468a2..e866547b9f 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -46,7 +46,7 @@ use ssz::{Decode, Encode}; use std::fmt; use std::future::Future; use std::time::Duration; -use types::PayloadAttestationData; +use types::{PayloadAttestationData, PayloadAttestationMessage}; pub const V1: EndpointVersion = EndpointVersion(1); pub const V2: EndpointVersion = EndpointVersion(2); @@ -1789,6 +1789,48 @@ impl BeaconNodeHttpClient { Ok(()) } + /// `POST beacon/pool/payload_attestations` (JSON) + pub async fn post_beacon_pool_payload_attestations( + &self, + messages: &[PayloadAttestationMessage], + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("pool") + .push("payload_attestations"); + + self.post_generic_with_consensus_version(path, &messages, None, fork_name) + .await?; + + Ok(()) + } + + /// `POST beacon/pool/payload_attestations` (SSZ) + pub async fn post_beacon_pool_payload_attestations_ssz( + &self, + messages: &[PayloadAttestationMessage], + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("beacon") + .push("pool") + .push("payload_attestations"); + + let ssz_body: Vec = messages.iter().flat_map(|m| m.as_ssz_bytes()).collect(); + + self.post_generic_with_consensus_version_and_ssz_body(path, ssz_body, None, fork_name) + .await?; + + Ok(()) + } + /// `POST beacon/pool/bls_to_execution_changes` pub async fn post_beacon_pool_bls_to_execution_changes( &self, diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index c5bcd88eb1..1b32777678 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -21,11 +21,12 @@ use tracing::{Instrument, debug, error, info, info_span, instrument, warn}; use types::{ AbstractExecPayload, Address, AggregateAndProof, Attestation, BeaconBlock, BlindedPayload, ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, ExecutionPayloadEnvelope, Fork, - FullPayload, Graffiti, Hash256, SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, - SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedRoot, - SignedValidatorRegistrationData, SignedVoluntaryExit, Slot, SyncAggregatorSelectionData, - SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, - ValidatorRegistrationData, VoluntaryExit, graffiti::GraffitiString, + FullPayload, Graffiti, Hash256, PayloadAttestationData, PayloadAttestationMessage, + SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, SignedContributionAndProof, + SignedExecutionPayloadEnvelope, SignedRoot, SignedValidatorRegistrationData, + SignedVoluntaryExit, Slot, SyncAggregatorSelectionData, SyncCommitteeContribution, + SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, + VoluntaryExit, graffiti::GraffitiString, }; use validator_store::{ AggregateToSign, AttestationToSign, ContributionToSign, DoppelgangerStatus, @@ -1423,6 +1424,37 @@ impl ValidatorStore for LighthouseValidatorS }) } + async fn sign_payload_attestation( + &self, + validator_pubkey: PublicKeyBytes, + data: PayloadAttestationData, + ) -> Result { + let signing_context = + self.signing_context(Domain::PTCAttester, data.slot.epoch(E::slots_per_epoch())); + + let validator_index = self + .validator_index(&validator_pubkey) + .ok_or(ValidatorStoreError::UnknownPubkey(validator_pubkey))?; + + let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?; + + let signature = signing_method + .get_signature::>( + SignableMessage::PayloadAttestationData(&data), + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + Ok(PayloadAttestationMessage { + validator_index, + data, + signature, + }) + } + /// Sign an `ExecutionPayloadEnvelope` for Gloas (local building). /// The proposer acts as the builder and signs with the BeaconBuilder domain. async fn sign_execution_payload_envelope( diff --git a/validator_client/signing_method/src/lib.rs b/validator_client/signing_method/src/lib.rs index c132d86c17..2f80fa5761 100644 --- a/validator_client/signing_method/src/lib.rs +++ b/validator_client/signing_method/src/lib.rs @@ -50,6 +50,7 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload = FullP ValidatorRegistration(&'a ValidatorRegistrationData), VoluntaryExit(&'a VoluntaryExit), ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), + PayloadAttestationData(&'a PayloadAttestationData), } impl> SignableMessage<'_, E, Payload> { @@ -72,6 +73,7 @@ impl> SignableMessage<'_, E, Payload SignableMessage::ValidatorRegistration(v) => v.signing_root(domain), SignableMessage::VoluntaryExit(exit) => exit.signing_root(domain), SignableMessage::ExecutionPayloadEnvelope(e) => e.signing_root(domain), + SignableMessage::PayloadAttestationData(d) => d.signing_root(domain), } } } @@ -238,6 +240,9 @@ impl SigningMethod { SignableMessage::ExecutionPayloadEnvelope(e) => { Web3SignerObject::ExecutionPayloadEnvelope(e) } + SignableMessage::PayloadAttestationData(d) => { + Web3SignerObject::PayloadAttestationData(d) + } }; // Determine the Web3Signer message type. diff --git a/validator_client/signing_method/src/web3signer.rs b/validator_client/signing_method/src/web3signer.rs index e6fc8f3ba2..c2b7e06f92 100644 --- a/validator_client/signing_method/src/web3signer.rs +++ b/validator_client/signing_method/src/web3signer.rs @@ -21,6 +21,7 @@ pub enum MessageType { ValidatorRegistration, // TODO(gloas) verify w/ web3signer specs ExecutionPayloadEnvelope, + PayloadAttestation, } #[derive(Debug, PartialEq, Copy, Clone, Serialize)] @@ -78,6 +79,7 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload> { ContributionAndProof(&'a ContributionAndProof), ValidatorRegistration(&'a ValidatorRegistrationData), ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), + PayloadAttestationData(&'a PayloadAttestationData), } impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Payload> { @@ -144,6 +146,7 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Pa } Web3SignerObject::ValidatorRegistration(_) => MessageType::ValidatorRegistration, Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope, + Web3SignerObject::PayloadAttestationData(_) => MessageType::PayloadAttestation, } } } diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index e26d5c3d30..b412db45f6 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -45,6 +45,7 @@ use validator_services::{ block_service::{BlockService, BlockServiceBuilder}, duties_service::{self, DutiesService, DutiesServiceBuilder}, latency_service, + payload_attestation_service::PayloadAttestationService, preparation_service::{PreparationService, PreparationServiceBuilder}, sync_committee_service::SyncCommitteeService, }; @@ -83,6 +84,7 @@ pub struct ProductionValidatorClient { block_service: BlockService, SystemTimeSlotClock>, attestation_service: AttestationService, SystemTimeSlotClock>, sync_committee_service: SyncCommitteeService, SystemTimeSlotClock>, + payload_attestation_service: PayloadAttestationService, SystemTimeSlotClock>, doppelganger_service: Option>, preparation_service: PreparationService, SystemTimeSlotClock>, validator_store: Arc>, @@ -552,12 +554,22 @@ impl ProductionValidatorClient { context.executor.clone(), ); + let payload_attestation_service = PayloadAttestationService::new( + duties_service.clone(), + validator_store.clone(), + slot_clock.clone(), + beacon_nodes.clone(), + context.executor.clone(), + context.eth2_config.spec.clone(), + ); + Ok(Self { context, duties_service, block_service, attestation_service, sync_committee_service, + payload_attestation_service, doppelganger_service, preparation_service, validator_store, @@ -629,6 +641,13 @@ impl ProductionValidatorClient { .start_update_service(&self.context.eth2_config.spec) .map_err(|e| format!("Unable to start sync committee service: {}", e))?; + if self.context.eth2_config.spec.is_gloas_scheduled() { + self.payload_attestation_service + .clone() + .start_update_service() + .map_err(|e| format!("Unable to start payload attestation service: {}", e))?; + } + self.preparation_service .clone() .start_update_service(&self.context.eth2_config.spec) diff --git a/validator_client/validator_services/src/lib.rs b/validator_client/validator_services/src/lib.rs index 3b8bd9ae14..0169335a7f 100644 --- a/validator_client/validator_services/src/lib.rs +++ b/validator_client/validator_services/src/lib.rs @@ -3,6 +3,7 @@ pub mod block_service; pub mod duties_service; pub mod latency_service; pub mod notifier_service; +pub mod payload_attestation_service; pub mod preparation_service; pub mod sync; pub mod sync_committee_service; diff --git a/validator_client/validator_services/src/payload_attestation_service.rs b/validator_client/validator_services/src/payload_attestation_service.rs new file mode 100644 index 0000000000..2f3ca8bed2 --- /dev/null +++ b/validator_client/validator_services/src/payload_attestation_service.rs @@ -0,0 +1,238 @@ +use crate::duties_service::DutiesService; +use beacon_node_fallback::BeaconNodeFallback; +use logging::crit; +use slot_clock::SlotClock; +use std::ops::Deref; +use std::sync::Arc; +use task_executor::TaskExecutor; +use tokio::time::sleep; +use tracing::{debug, error, info}; +use types::{ChainSpec, EthSpec}; +use validator_store::ValidatorStore; + +pub struct Inner { + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + executor: TaskExecutor, + chain_spec: Arc, +} + +pub struct PayloadAttestationService { + inner: Arc>, +} + +impl Clone for PayloadAttestationService { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl Deref for PayloadAttestationService { + type Target = Inner; + + fn deref(&self) -> &Self::Target { + self.inner.deref() + } +} + +impl PayloadAttestationService { + pub fn new( + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + executor: TaskExecutor, + chain_spec: Arc, + ) -> Self { + Self { + inner: Arc::new(Inner { + duties_service, + validator_store, + slot_clock, + beacon_nodes, + executor, + chain_spec, + }), + } + } + + pub fn start_update_service(self) -> Result<(), String> { + let slot_duration = self.chain_spec.get_slot_duration(); + let payload_attestation_due = self.chain_spec.get_payload_attestation_due(); + + info!( + payload_attestation_due_ms = payload_attestation_due.as_millis(), + "Payload attestation service started" + ); + + let executor = self.executor.clone(); + + let interval_fut = async move { + loop { + let Some(duration_to_next_slot) = self.slot_clock.duration_to_next_slot() else { + error!("Failed to read slot clock"); + sleep(slot_duration).await; + continue; + }; + + let Some(current_slot) = self.slot_clock.now() else { + error!("Failed to read slot clock after trigger"); + continue; + }; + + if !self + .chain_spec + .fork_name_at_slot::(current_slot) + .gloas_enabled() + { + let duration_to_next_epoch = self + .slot_clock + .duration_to_next_epoch(S::E::slots_per_epoch()) + .unwrap_or_else(|| { + self.chain_spec.get_slot_duration() * S::E::slots_per_epoch() as u32 + }); + sleep(duration_to_next_epoch).await; + continue; + } + + sleep(duration_to_next_slot + payload_attestation_due).await; + + let service = self.clone(); + self.executor.spawn( + async move { + service.produce_and_publish(current_slot).await; + }, + "payload_attestation_producer", + ); + } + }; + + executor.spawn(interval_fut, "payload_attestation_service"); + Ok(()) + } + + async fn produce_and_publish(&self, slot: types::Slot) { + let duties = self.duties_service.get_ptc_duties_for_slot(slot); + + if duties.is_empty() { + return; + } + + debug!( + %slot, + duty_count = duties.len(), + "Producing payload attestations" + ); + + let attestation_data = match self + .beacon_nodes + .first_success(|beacon_node| async move { + beacon_node + .get_validator_payload_attestation_data(slot) + .await + .map_err(|e| format!("Failed to get payload attestation data: {e:?}")) + .map(|resp| resp.into_data()) + }) + .await + { + Ok(data) => data, + Err(e) => { + crit!( + error = %e, + %slot, + "Failed to produce payload attestation data" + ); + return; + } + }; + + debug!( + %slot, + beacon_block_root = ?attestation_data.beacon_block_root, + payload_present = attestation_data.payload_present, + "Received payload attestation data" + ); + + let mut messages = Vec::with_capacity(duties.len()); + + for duty in &duties { + match self + .validator_store + .sign_payload_attestation(duty.pubkey, attestation_data.clone()) + .await + { + Ok(message) => { + messages.push(message); + } + Err(e) => { + crit!( + error = ?e, + validator = ?duty.pubkey, + %slot, + "Failed to sign payload attestation" + ); + } + } + } + + if messages.is_empty() { + return; + } + + let count = messages.len(); + let fork_name = self.chain_spec.fork_name_at_slot::(slot); + let result = self + .beacon_nodes + .first_success(|beacon_node| { + let messages = messages.clone(); + async move { + beacon_node + .post_beacon_pool_payload_attestations_ssz(&messages, fork_name) + .await + .map_err(|e| format!("Failed to publish payload attestations (SSZ): {e:?}")) + } + }) + .await; + + let result = match result { + Ok(()) => Ok(()), + Err(_) => { + debug!(%slot, "SSZ publish failed, falling back to JSON"); + self.beacon_nodes + .first_success(|beacon_node| { + let messages = messages.clone(); + async move { + beacon_node + .post_beacon_pool_payload_attestations(&messages, fork_name) + .await + .map_err(|e| { + format!("Failed to publish payload attestations (JSON): {e:?}") + }) + } + }) + .await + } + }; + + match result { + Ok(()) => { + info!( + %slot, + %count, + "Successfully published payload attestations" + ); + } + Err(e) => { + crit!( + error = %e, + %slot, + "Failed to publish payload attestations" + ); + } + } + } +} diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index da0b33de18..4e5b415a41 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -7,10 +7,11 @@ use std::future::Future; use std::sync::Arc; use types::{ Address, Attestation, AttestationError, BlindedBeaconBlock, Epoch, EthSpec, - ExecutionPayloadEnvelope, Graffiti, Hash256, SelectionProof, SignedAggregateAndProof, - SignedBlindedBeaconBlock, SignedContributionAndProof, SignedExecutionPayloadEnvelope, - SignedValidatorRegistrationData, Slot, SyncCommitteeContribution, SyncCommitteeMessage, - SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, + ExecutionPayloadEnvelope, Graffiti, Hash256, PayloadAttestationData, PayloadAttestationMessage, + SelectionProof, SignedAggregateAndProof, SignedBlindedBeaconBlock, SignedContributionAndProof, + SignedExecutionPayloadEnvelope, SignedValidatorRegistrationData, Slot, + SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, + ValidatorRegistrationData, }; #[derive(Debug, PartialEq, Clone)] @@ -205,6 +206,13 @@ pub trait ValidatorStore: Send + Sync { envelope: ExecutionPayloadEnvelope, ) -> impl Future, Error>> + Send; + /// Sign a `PayloadAttestationData` for the PTC. + fn sign_payload_attestation( + &self, + validator_pubkey: PublicKeyBytes, + data: PayloadAttestationData, + ) -> impl Future>> + Send; + /// Returns `ProposalData` for the provided `pubkey` if it exists in `InitializedValidators`. /// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`, /// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`. From 949c027dfd37408784216b2c4e5e727e6ad571fe Mon Sep 17 00:00:00 2001 From: Mac L Date: Tue, 28 Apr 2026 11:01:13 +0400 Subject: [PATCH 142/189] Add method to `Hash256` to display shortened hashes (#9118) #6689 Inspired by the initial implementation of #9108, credit to @chong-he. This adds an extension trait to `Hash256` and add a `short` method to provide smaller formatted hashes for logging. Co-Authored-By: Mac L --- beacon_node/client/src/notifier.rs | 6 ++--- .../types/src/core/execution_block_hash.rs | 13 ++++------ consensus/types/src/core/mod.rs | 26 +++++++++++++++++++ 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/beacon_node/client/src/notifier.rs b/beacon_node/client/src/notifier.rs index 0d73a6bf7a..bdb4228765 100644 --- a/beacon_node/client/src/notifier.rs +++ b/beacon_node/client/src/notifier.rs @@ -360,7 +360,7 @@ pub fn spawn_notifier( let block_info = if current_slot > head_slot { " … empty".to_string() } else { - head_root.to_string() + head_root.short().to_string() }; let block_hash = match beacon_chain.canonical_head.head_execution_status() { @@ -393,7 +393,7 @@ pub fn spawn_notifier( info!( peers = peer_count_pretty(connected_peer_count), exec_hash = block_hash, - finalized_root = %finalized_checkpoint.root, + finalized_root = %finalized_checkpoint.root.short(), finalized_epoch = %finalized_checkpoint.epoch, epoch = %current_epoch, block = block_info, @@ -404,7 +404,7 @@ pub fn spawn_notifier( metrics::set_gauge(&metrics::IS_SYNCED, 0); info!( peers = peer_count_pretty(connected_peer_count), - finalized_root = %finalized_checkpoint.root, + finalized_root = %finalized_checkpoint.root.short(), finalized_epoch = %finalized_checkpoint.epoch, %head_slot, %current_slot, diff --git a/consensus/types/src/core/execution_block_hash.rs b/consensus/types/src/core/execution_block_hash.rs index cbacf7cf74..71e63727ee 100644 --- a/consensus/types/src/core/execution_block_hash.rs +++ b/consensus/types/src/core/execution_block_hash.rs @@ -5,7 +5,10 @@ use rand::RngCore; use serde::{Deserialize, Serialize}; use ssz::{Decode, DecodeError, Encode}; -use crate::{core::Hash256, test_utils::TestRandom}; +use crate::{ + core::{Hash256, Hash256Ext}, + test_utils::TestRandom, +}; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Default, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)] @@ -20,13 +23,7 @@ impl fmt::Debug for ExecutionBlockHash { impl fmt::Display for ExecutionBlockHash { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let hash = format!("{}", self.0); - write!( - f, - "{}…{}", - &hash[..6], - &hash[hash.len().saturating_sub(4)..] - ) + self.0.short().fmt(f) } } diff --git a/consensus/types/src/core/mod.rs b/consensus/types/src/core/mod.rs index 4e583fbc67..f722ac5191 100644 --- a/consensus/types/src/core/mod.rs +++ b/consensus/types/src/core/mod.rs @@ -49,3 +49,29 @@ pub type Hash64 = alloy_primitives::B64; pub type Address = alloy_primitives::Address; pub type VersionedHash = Hash256; pub type MerkleProof = Vec; + +/// Extension trait for `Hash256` to allow us to implement additional methods on it. +pub trait Hash256Ext { + fn short(&self) -> ShortenedHash<'_>; +} + +impl Hash256Ext for Hash256 { + fn short(&self) -> ShortenedHash<'_> { + ShortenedHash(self) + } +} + +pub struct ShortenedHash<'a>(&'a Hash256); + +impl<'a> std::fmt::Display for ShortenedHash<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let hash: &[u8; 32] = self.0.as_ref(); + write!( + f, + // Format as hex, padded to 2 digits per byte. + // This outputs a consistent "0x1234...abcd" format. + "0x{:02x}{:02x}…{:02x}{:02x}", + hash[0], hash[1], hash[30], hash[31] + ) + } +} From 919c996c18911a541493bb2c30571aa54a69aeae Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Tue, 28 Apr 2026 10:15:10 +0200 Subject: [PATCH 143/189] Fix spurious re-org logs on ePBS payload status changes (#9191) Co-Authored-By: Jimmy Chen --- beacon_node/beacon_chain/src/canonical_head.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 04c18c88e0..0e6515ebbd 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -796,9 +796,9 @@ impl BeaconChain { let new_snapshot = &new_cached_head.snapshot; let old_snapshot = &old_cached_head.snapshot; - // If the head changed, perform some updates. - if (new_snapshot.beacon_block_root != old_snapshot.beacon_block_root - || new_payload_status != old_payload_status) + // Only run on head *block* changes - payload status changes only need the + // `cached_head` update above, not re-org detection or event emission. + if new_snapshot.beacon_block_root != old_snapshot.beacon_block_root && let Err(e) = self.after_new_head(&old_cached_head, &new_cached_head, new_head_proto_block) { From 280e2f1d53fde00955f9868a328f4183289420cb Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Tue, 28 Apr 2026 10:59:01 +0200 Subject: [PATCH 144/189] Wire up ePBS SSE events and fix envelope availability (#9199) Co-Authored-By: Jimmy Chen --- .../gossip_verified_payload_attestation.rs | 22 ++- .../gossip_verified_bid.rs | 14 ++ .../payload_envelope_verification/import.rs | 16 +- .../src/payload_envelope_verification/mod.rs | 32 +++- beacon_node/beacon_chain/tests/events.rs | 178 +++++++++++++++++- 5 files changed, 251 insertions(+), 11 deletions(-) diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs index 2d9fce812e..c36c73b344 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs @@ -6,6 +6,7 @@ use crate::validator_pubkey_cache::ValidatorPubkeyCache; use crate::{BeaconChain, BeaconChainError, BeaconChainTypes, metrics}; use bls::AggregateSignature; use educe::Educe; +use eth2::types::{EventKind, ForkVersionedResponse}; use parking_lot::RwLock; use safe_arith::SafeArith; use slot_clock::SlotClock; @@ -216,9 +217,24 @@ impl BeaconChain { let _timer = metrics::start_timer(&metrics::PAYLOAD_ATTESTATION_GOSSIP_VERIFICATION_TIMES); let ctx = self.payload_attestation_gossip_context(); - VerifiedPayloadAttestationMessage::new(payload_attestation_message, &ctx).inspect(|_| { - metrics::inc_counter(&metrics::PAYLOAD_ATTESTATION_PROCESSING_SUCCESSES); - }) + VerifiedPayloadAttestationMessage::new(payload_attestation_message, &ctx).inspect( + |verified| { + metrics::inc_counter(&metrics::PAYLOAD_ATTESTATION_PROCESSING_SUCCESSES); + + if let Some(event_handler) = self.event_handler.as_ref() + && event_handler.has_payload_attestation_message_subscribers() + { + let msg = verified.payload_attestation_message(); + event_handler.register(EventKind::PayloadAttestationMessage(Box::new( + ForkVersionedResponse { + version: self.spec.fork_name_at_slot::(msg.data.slot), + metadata: Default::default(), + data: msg.clone(), + }, + ))); + } + }, + ) } } diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs index 91945896df..1f3f074598 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs @@ -6,6 +6,7 @@ use crate::{ proposer_preferences_verification::proposer_preference_cache::GossipVerifiedProposerPreferenceCache, }; use educe::Educe; +use eth2::types::{EventKind, ForkVersionedResponse}; use slot_clock::SlotClock; use state_processing::signature_sets::{ execution_payload_bid_signature_set, get_builder_pubkey_from_state, @@ -233,6 +234,19 @@ impl BeaconChain { %parent_block_root, "Successfully verified gossip payload bid" ); + + if let Some(event_handler) = self.event_handler.as_ref() + && event_handler.has_execution_payload_bid_subscribers() + { + event_handler.register(EventKind::ExecutionPayloadBid(Box::new( + ForkVersionedResponse { + version: self.spec.fork_name_at_slot::(slot), + metadata: Default::default(), + data: (*verified.signed_bid).clone(), + }, + ))); + } + Ok(verified) } Err(e) => { diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs index 5a6d3a1b7d..b40e8337fb 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use std::time::Duration; -use eth2::types::{EventKind, SseExecutionPayload}; +use eth2::types::{EventKind, SseExecutionPayload, SseExecutionPayloadAvailable}; use fork_choice::PayloadVerificationStatus; use slot_clock::SlotClock; use store::StoreOp; @@ -182,6 +182,7 @@ impl BeaconChain { signed_envelope, import_data, payload_verification_outcome, + self.spec.clone(), )) } @@ -362,5 +363,18 @@ impl BeaconChain { execution_optimistic: payload_verification_status.is_optimistic(), })); } + + // TODO(gloas): once the DA checker handles envelopes, this event should also be + // emitted from the DA resolution path (similar to `process_availability` for blocks). + if let Some(event_handler) = self.event_handler.as_ref() + && event_handler.has_execution_payload_available_subscribers() + { + event_handler.register(EventKind::ExecutionPayloadAvailable( + SseExecutionPayloadAvailable { + slot: envelope_slot, + block_root, + }, + )); + } } } diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs index 51fc3f235d..b153a3cd6a 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs @@ -60,6 +60,22 @@ pub struct AvailableEnvelope { } impl AvailableEnvelope { + pub fn new( + execution_block_hash: ExecutionBlockHash, + envelope: Arc>, + columns: DataColumnSidecarList, + columns_available_timestamp: Option, + spec: Arc, + ) -> Self { + Self { + execution_block_hash, + envelope, + columns, + columns_available_timestamp, + spec, + } + } + pub fn message(&self) -> &ExecutionPayloadEnvelope { &self.envelope.message } @@ -104,9 +120,10 @@ pub struct EnvelopeProcessingSnapshot { /// fully available. /// 2. `AvailabilityPending`: This envelope hasn't received all required blobs to consider it /// fully available. +#[allow(dead_code)] pub enum ExecutedEnvelope { Available(AvailableExecutedEnvelope), - // TODO(gloas) implement availability pending + // TODO(gloas): check data column availability via DA checker AvailabilityPending(), } @@ -115,6 +132,7 @@ impl ExecutedEnvelope { envelope: MaybeAvailableEnvelope, import_data: EnvelopeImportData, payload_verification_outcome: PayloadVerificationOutcome, + spec: Arc, ) -> Self { match envelope { MaybeAvailableEnvelope::Available(available_envelope) => { @@ -124,11 +142,15 @@ impl ExecutedEnvelope { payload_verification_outcome, )) } - // TODO(gloas) implement availability pending + // TODO(gloas): check data column availability via DA checker MaybeAvailableEnvelope::AvailabilityPending { - block_hash: _, - envelope: _, - } => Self::AvailabilityPending(), + block_hash, + envelope, + } => Self::Available(AvailableExecutedEnvelope::new( + AvailableEnvelope::new(block_hash, envelope, vec![], None, spec), + import_data, + payload_verification_outcome, + )), } } } diff --git a/beacon_node/beacon_chain/tests/events.rs b/beacon_node/beacon_chain/tests/events.rs index 5305965f0f..e943514c4e 100644 --- a/beacon_node/beacon_chain/tests/events.rs +++ b/beacon_node/beacon_chain/tests/events.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use types::data::FixedBlobSidecarList; use types::test_utils::TestRandom; use types::{ - BlobSidecar, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, EthSpec, - MinimalEthSpec, Slot, + BlobSidecar, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, Domain, EthSpec, + MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, SignedRoot, Slot, }; type E = MinimalEthSpec; @@ -258,3 +258,177 @@ async fn head_event_on_block_import() { panic!("Expected Head event, got {:?}", head_event); } } + +/// Verifies that `execution_payload_gossip` fires at gossip verification time, and +/// `execution_payload` + `execution_payload_available` fire at import time. +#[tokio::test] +async fn execution_payload_envelope_events() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(E::default()) + .default_spec() + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + harness.extend_to_slot(Slot::new(1)).await; + + let state = harness.get_current_state(); + let target_slot = Slot::new(2); + harness.advance_slot(); + let (block_contents, opt_envelope, _new_state) = + harness.make_block_with_envelope(state, target_slot).await; + + let block_root = block_contents.0.canonical_root(); + + harness + .process_block(target_slot, block_root, block_contents) + .await + .expect("block should be processed"); + + let signed_envelope = opt_envelope.expect("Gloas block should produce an envelope"); + + let event_handler = harness.chain.event_handler.as_ref().unwrap(); + let mut gossip_receiver = event_handler.subscribe_execution_payload_gossip(); + let mut payload_receiver = event_handler.subscribe_execution_payload(); + let mut available_receiver = event_handler.subscribe_execution_payload_available(); + + // Stage 1: gossip verification fires execution_payload_gossip only. + let gossip_verified = harness + .chain + .verify_envelope_for_gossip(Arc::new(signed_envelope)) + .await + .expect("envelope gossip verification should succeed"); + + let gossip_event = gossip_receiver + .try_recv() + .expect("should receive execution_payload_gossip after gossip verification"); + if let EventKind::ExecutionPayloadGossip(sse) = gossip_event { + assert_eq!(sse.slot, target_slot); + assert_eq!(sse.block_root, block_root); + } else { + panic!( + "Expected ExecutionPayloadGossip event, got {:?}", + gossip_event + ); + } + assert!(payload_receiver.try_recv().is_err()); + assert!(available_receiver.try_recv().is_err()); + + // Stage 2: import fires execution_payload and execution_payload_available. + harness + .chain + .process_execution_payload_envelope( + block_root, + gossip_verified, + beacon_chain::NotifyExecutionLayer::Yes, + types::BlockImportSource::Gossip, + #[allow(clippy::result_large_err)] + || Ok(()), + ) + .await + .expect("envelope import should succeed"); + + let payload_event = payload_receiver + .try_recv() + .expect("should receive execution_payload after import"); + if let EventKind::ExecutionPayload(sse) = payload_event { + assert_eq!(sse.slot, target_slot); + assert_eq!(sse.block_root, block_root); + } else { + panic!("Expected ExecutionPayload event, got {:?}", payload_event); + } + + let available_event = available_receiver + .try_recv() + .expect("should receive execution_payload_available after import"); + if let EventKind::ExecutionPayloadAvailable(sse) = available_event { + assert_eq!(sse.slot, target_slot); + assert_eq!(sse.block_root, block_root); + } else { + panic!( + "Expected ExecutionPayloadAvailable event, got {:?}", + available_event + ); + } + + assert!( + gossip_receiver.try_recv().is_err(), + "no extra gossip events should fire during import" + ); +} + +/// Verifies that a `payload_attestation_message` event is emitted when a payload attestation +/// message passes gossip verification. +#[tokio::test] +async fn payload_attestation_message_event_on_gossip_verification() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let harness = BeaconChainHarness::builder(E::default()) + .default_spec() + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + // Advance chain to have a valid head block. + let target_slot = Slot::new(1); + harness.extend_to_slot(target_slot).await; + + let head = harness.chain.canonical_head.cached_head(); + let head_state = &head.snapshot.beacon_state; + + // Get a PTC member for this slot. + let ptc = head_state + .get_ptc(target_slot, &harness.spec) + .expect("should get PTC"); + let validator_index = *ptc.0.first().expect("PTC should have at least one member") as u64; + + // Sign a payload attestation. + let target_epoch = target_slot.epoch(E::slots_per_epoch()); + let domain = harness.spec.get_domain( + target_epoch, + Domain::PTCAttester, + &head_state.fork(), + head_state.genesis_validators_root(), + ); + let data = PayloadAttestationData { + beacon_block_root: head.head_block_root(), + slot: target_slot, + payload_present: true, + blob_data_available: true, + }; + let message = data.signing_root(domain); + let signature = harness.validator_keypairs[validator_index as usize] + .sk + .sign(message); + let msg = PayloadAttestationMessage { + validator_index, + data: data.clone(), + signature: signature.clone(), + }; + + // Subscribe before verification. + let event_handler = harness.chain.event_handler.as_ref().unwrap(); + let mut receiver = event_handler.subscribe_payload_attestation_message(); + + // Verify the attestation through the gossip path. + harness + .chain + .verify_payload_attestation_message_for_gossip(msg) + .expect("verification should succeed"); + + // Assert the event was emitted. + let event = receiver.try_recv().expect("should receive event"); + if let EventKind::PayloadAttestationMessage(versioned) = event { + assert_eq!(versioned.data.validator_index, validator_index); + assert_eq!(versioned.data.data, data); + } else { + panic!("Expected PayloadAttestationMessage event, got {:?}", event); + } +} From e35a67130341af2005f009cdfc253fb4cc40ae73 Mon Sep 17 00:00:00 2001 From: Mac L Date: Tue, 28 Apr 2026 12:59:07 +0400 Subject: [PATCH 145/189] Fix validator manager compilation (#9187) Currently, running `cargo check -p validator_manager` fails due to missing features. Although the `validator_manager` will almost always be called through the Lighthouse binary which will enable the required features, it is still good hygiene to ensure all workspace crates can compile standalone. Add the `lighthouse` feature to the `eth2` dependency in `validator_manager` Co-Authored-By: Mac L --- validator_manager/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/validator_manager/Cargo.toml b/validator_manager/Cargo.toml index d0155698b4..7dabd5445c 100644 --- a/validator_manager/Cargo.toml +++ b/validator_manager/Cargo.toml @@ -11,7 +11,7 @@ clap = { workspace = true } clap_utils = { workspace = true } educe = { workspace = true } environment = { workspace = true } -eth2 = { workspace = true } +eth2 = { workspace = true, features = ["lighthouse"] } eth2_network_config = { workspace = true } eth2_wallet = { workspace = true } ethereum_serde_utils = { workspace = true } From d8790f66772e05b49a1a4d815810de121d6af094 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Tue, 28 Apr 2026 12:49:28 +0200 Subject: [PATCH 146/189] Add payload attestation to op pool and pack into block (#9180) Store gossip-verified `PayloadAttestationMessage`s in the operation pool and pack them into the block body at during block production. Built on top of #9145. Co-Authored-By: Jimmy Chen --- beacon_node/beacon_chain/src/beacon_chain.rs | 12 + .../src/block_production/gloas.rs | 42 ++- .../gossip_methods.rs | 37 +- beacon_node/operation_pool/src/lib.rs | 315 +++++++++++++++++- beacon_node/operation_pool/src/persistence.rs | 1 + .../src/payload_attestation_service.rs | 7 +- 6 files changed, 386 insertions(+), 28 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index cf5afb089a..9da64888c2 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -61,6 +61,7 @@ use crate::observed_data_sidecars::ObservedDataSidecars; use crate::observed_operations::{ObservationOutcome, ObservedOperations}; use crate::observed_slashable::ObservedSlashable; use crate::partial_data_column_assembler::PartialMergeResult; +use crate::payload_attestation_verification::VerifiedPayloadAttestationMessage; use crate::payload_bid_verification::payload_bid_cache::GossipVerifiedPayloadBidCache; #[cfg(not(test))] use crate::payload_envelope_streamer::{EnvelopeRequestSource, launch_payload_envelope_stream}; @@ -2328,6 +2329,17 @@ impl BeaconChain { .map_err(Into::into) } + /// Add a verified payload attestation message to the operation pool for block inclusion. + pub fn add_payload_attestation_to_pool( + &self, + verified: &VerifiedPayloadAttestationMessage, + ) -> Result<(), Error> { + self.op_pool + .insert_payload_attestation_message(verified.payload_attestation_message().clone()) + .map_err(Error::OpPoolError)?; + Ok(()) + } + /// Accepts some `SyncCommitteeMessage` from the network and attempts to verify it, returning `Ok(_)` if /// it is valid to be (re)broadcast on the gossip network. pub fn verify_sync_committee_message_for_gossip( diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 9b3fc2806e..4bc4b9862c 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -9,9 +9,10 @@ use execution_layer::{ use fork_choice::PayloadStatus; use operation_pool::CompactAttestationRef; use ssz::Encode; -use state_processing::common::get_attesting_indices_from_state; +use state_processing::common::{get_attesting_indices_from_state, get_indexed_payload_attestation}; use state_processing::envelope_processing::verify_execution_payload_envelope; use state_processing::epoch_cache::initialize_epoch_cache; +use state_processing::per_block_processing::is_valid_indexed_payload_attestation; use state_processing::per_block_processing::{ apply_parent_execution_payload, compute_timestamp_at_slot, get_expected_withdrawals, verify_attestation_for_block_inclusion, @@ -319,6 +320,11 @@ impl BeaconChain { .map_err(BlockProductionError::OpPoolError)? }; + let mut payload_attestations = self + .op_pool + .get_payload_attestations(&state, parent_root, &self.spec) + .map_err(BlockProductionError::OpPoolError)?; + // If paranoid mode is enabled re-check the signatures of every included message. // This will be a lot slower but guards against bugs in block production and can be // quickly rolled out without a release. @@ -343,6 +349,35 @@ impl BeaconChain { .is_ok() }); + payload_attestations.retain(|att| { + match get_indexed_payload_attestation(&state, att, &self.spec) { + Ok(indexed) => is_valid_indexed_payload_attestation( + &state, + &indexed, + VerifySignatures::True, + &self.spec, + ) + .map_err(|e| { + warn!( + err = ?e, + block_slot = %state.slot(), + ?att, + "Attempted to include a payload attestation with invalid signature" + ); + }) + .is_ok(), + Err(e) => { + warn!( + err = ?e, + block_slot = %state.slot(), + ?att, + "Failed to index payload attestation for verification" + ); + false + } + } + }); + proposer_slashings.retain(|slashing| { slashing .clone() @@ -386,8 +421,6 @@ impl BeaconChain { }) .is_ok() }); - - // TODO(gloas) verify payload attestation signature here as well } let attester_slashings = attester_slashings @@ -434,8 +467,7 @@ impl BeaconChain { deposits, voluntary_exits, sync_aggregate, - // TODO(gloas) need to implement payload attestations - payload_attestations: vec![], + payload_attestations, bls_to_execution_changes, }, state, diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 4083b1a3af..29306c198d 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -140,11 +140,6 @@ struct RejectedAggregate { error: AttnError, } -struct RejectedPayloadAttestation { - payload_attestation_message: Box, - error: PayloadAttestationError, -} - /// Data for an aggregated or unaggregated attestation that failed verification. enum FailedAtt { Unaggregate { @@ -4111,25 +4106,20 @@ impl NetworkBeaconProcessor { peer_id: PeerId, payload_attestation_message: Box, ) { - let result = match self + let message_slot = payload_attestation_message.data.slot; + let result = self .chain - .verify_payload_attestation_message_for_gossip(*payload_attestation_message.clone()) - { - Ok(verified) => Ok(verified), - Err(error) => Err(RejectedPayloadAttestation { - payload_attestation_message: payload_attestation_message.clone(), - error, - }), - }; + .verify_payload_attestation_message_for_gossip(*payload_attestation_message); - self.process_gossip_payload_attestation_result(result, message_id, peer_id); + self.process_gossip_payload_attestation_result(result, message_id, peer_id, message_slot); } fn process_gossip_payload_attestation_result( self: &Arc, - result: Result, RejectedPayloadAttestation>, + result: Result, PayloadAttestationError>, message_id: MessageId, peer_id: PeerId, + message_slot: Slot, ) { match result { Ok(verified) => { @@ -4156,16 +4146,21 @@ impl NetworkBeaconProcessor { ), } } + + if let Err(e) = self.chain.add_payload_attestation_to_pool(&verified) { + warn!( + reason = ?e, + %peer_id, + "Failed to add payload attestation to pool" + ); + } } - Err(RejectedPayloadAttestation { - payload_attestation_message, - error, - }) => { + Err(error) => { self.handle_payload_attestation_verification_failure( peer_id, message_id, error, - payload_attestation_message.data.slot, + message_slot, ); } } diff --git a/beacon_node/operation_pool/src/lib.rs b/beacon_node/operation_pool/src/lib.rs index 4b815704d9..de5fe9a098 100644 --- a/beacon_node/operation_pool/src/lib.rs +++ b/beacon_node/operation_pool/src/lib.rs @@ -23,10 +23,12 @@ use crate::attestation_storage::{AttestationMap, CheckpointKey}; use crate::bls_to_execution_changes::BlsToExecutionChanges; use crate::sync_aggregate_id::SyncAggregateId; use attester_slashing::AttesterSlashingMaxCover; +use bls::AggregateSignature; use max_cover::maximum_cover; use parking_lot::{RwLock, RwLockWriteGuard}; use rand::rng; use rand::seq::SliceRandom; +use ssz::BitVector; use state_processing::per_block_processing::errors::AttestationValidationError; use state_processing::per_block_processing::{ VerifySignatures, get_slashable_indices_modular, verify_exit, @@ -38,7 +40,8 @@ use std::ptr; use typenum::Unsigned; use types::{ AbstractExecPayload, Attestation, AttestationData, AttesterSlashing, BeaconState, - BeaconStateError, ChainSpec, Epoch, EthSpec, ProposerSlashing, SignedBeaconBlock, + BeaconStateError, ChainSpec, Epoch, EthSpec, Hash256, PayloadAttestation, + PayloadAttestationData, PayloadAttestationMessage, ProposerSlashing, SignedBeaconBlock, SignedBlsToExecutionChange, SignedVoluntaryExit, Slot, SyncAggregate, SyncAggregateError, SyncCommitteeContribution, Validator, }; @@ -59,6 +62,9 @@ pub struct OperationPool { voluntary_exits: RwLock>>, /// Map from credential changing validator to their position in the queue. bls_to_execution_changes: RwLock>, + /// Map from payload attestation data to individual messages for aggregation at block production. + payload_attestation_messages: + RwLock>>, /// Reward cache for accelerating attestation packing. reward_cache: RwLock, _phantom: PhantomData, @@ -78,6 +84,8 @@ pub enum OpPoolError { IncorrectOpPoolVariant, EpochCacheNotInitialized, EpochCacheError(EpochCacheError), + GetPtcError(BeaconStateError), + PayloadAttestationBitError, } #[derive(Default)] @@ -193,6 +201,100 @@ impl OperationPool { }); } + /// Insert a validated `PayloadAttestationMessage` into the pool. + pub fn insert_payload_attestation_message( + &self, + message: PayloadAttestationMessage, + ) -> Result<(), OpPoolError> { + let mut messages = self.payload_attestation_messages.write(); + let entry = messages.entry(message.data.clone()).or_default(); + if !entry + .iter() + .any(|m| m.validator_index == message.validator_index) + { + entry.push(message); + } + Ok(()) + } + + /// Build `PayloadAttestation`s from stored messages for block production. + /// + /// `parent_block_root` is the root of the parent block (the block PTC members attested to). + /// Returns one `PayloadAttestation` per distinct `PayloadAttestationData`. With two boolean + /// fields this yields at most 4, capped to `MaxPayloadAttestations`. + pub fn get_payload_attestations( + &self, + state: &BeaconState, + parent_block_root: Hash256, + spec: &ChainSpec, + ) -> Result>, OpPoolError> { + let target_slot = state.slot().saturating_sub(1u64); + + let ptc = state + .get_ptc(target_slot, spec) + .map_err(OpPoolError::GetPtcError)?; + + let messages = self.payload_attestation_messages.read(); + let mut result = Vec::new(); + + for (data, msgs) in messages.iter() { + if data.slot != target_slot || data.beacon_block_root != parent_block_root { + continue; + } + + let mut aggregation_bits = BitVector::new(); + let mut aggregate_sig = AggregateSignature::infinity(); + + for msg in msgs { + if let Some(pos) = ptc + .0 + .iter() + .position(|&idx| idx == msg.validator_index as usize) + && !aggregation_bits.get(pos).unwrap_or(false) + { + aggregation_bits + .set(pos, true) + .map_err(|_| OpPoolError::PayloadAttestationBitError)?; + aggregate_sig.add_assign(&msg.signature); + } + } + + if aggregation_bits.num_set_bits() > 0 { + result.push(PayloadAttestation { + aggregation_bits, + data: data.clone(), + signature: aggregate_sig, + }); + } + } + + // Prefer most participation and cap by `max_payload_attestations` + result.sort_by(|a, b| { + b.aggregation_bits + .num_set_bits() + .cmp(&a.aggregation_bits.num_set_bits()) + }); + result.truncate(E::max_payload_attestations()); + + Ok(result) + } + + /// Remove payload attestation messages that are too old for block inclusion. + pub fn prune_payload_attestation_messages(&self, current_slot: Slot) { + self.payload_attestation_messages + .write() + .retain(|data, _| current_slot <= data.slot.saturating_add(Slot::new(1))); + } + + /// Total number of payload attestation messages in the pool. + pub fn num_payload_attestation_messages(&self) -> usize { + self.payload_attestation_messages + .read() + .values() + .map(|msgs| msgs.len()) + .sum() + } + /// Insert an attestation into the pool, aggregating it with existing attestations if possible. /// /// ## Note @@ -646,6 +748,7 @@ impl OperationPool { ) { self.prune_attestations(current_epoch); self.prune_sync_contributions(head_state.slot()); + self.prune_payload_attestation_messages(head_state.slot()); self.prune_proposer_slashings(finalized_state); self.prune_attester_slashings(finalized_state); self.prune_voluntary_exits(finalized_state, spec); @@ -2075,4 +2178,214 @@ mod release_tests { op_pool.prune_attester_slashings(&electra_head.beacon_state); assert_eq!(op_pool.attester_slashings.read().len(), 1); } + + fn make_payload_attestation_message( + slot: Slot, + validator_index: u64, + beacon_block_root: Hash256, + ) -> PayloadAttestationMessage { + make_payload_attestation_message_with_flags( + slot, + validator_index, + beacon_block_root, + true, + true, + ) + } + + fn make_payload_attestation_message_with_flags( + slot: Slot, + validator_index: u64, + beacon_block_root: Hash256, + payload_present: bool, + blob_data_available: bool, + ) -> PayloadAttestationMessage { + PayloadAttestationMessage { + validator_index, + data: PayloadAttestationData { + beacon_block_root, + slot, + payload_present, + blob_data_available, + }, + signature: bls::Signature::empty(), + } + } + + #[test] + fn payload_attestation_insert_and_dedup() { + let op_pool = OperationPool::::new(); + let root = Hash256::repeat_byte(0xaa); + let slot = Slot::new(1); + + let msg1 = make_payload_attestation_message(slot, 0, root); + let msg2 = make_payload_attestation_message(slot, 1, root); + let msg1_dup = make_payload_attestation_message(slot, 0, root); + + op_pool.insert_payload_attestation_message(msg1).unwrap(); + op_pool.insert_payload_attestation_message(msg2).unwrap(); + op_pool + .insert_payload_attestation_message(msg1_dup) + .unwrap(); + + assert_eq!(op_pool.num_payload_attestation_messages(), 2); + } + + #[test] + fn payload_attestation_prune() { + let op_pool = OperationPool::::new(); + let root = Hash256::repeat_byte(0xaa); + + let msg_slot1 = make_payload_attestation_message(Slot::new(1), 0, root); + let msg_slot2 = make_payload_attestation_message(Slot::new(2), 1, root); + let msg_slot3 = make_payload_attestation_message(Slot::new(3), 2, root); + + op_pool + .insert_payload_attestation_message(msg_slot1) + .unwrap(); + op_pool + .insert_payload_attestation_message(msg_slot2) + .unwrap(); + op_pool + .insert_payload_attestation_message(msg_slot3) + .unwrap(); + + assert_eq!(op_pool.num_payload_attestation_messages(), 3); + + op_pool.prune_payload_attestation_messages(Slot::new(3)); + assert_eq!(op_pool.num_payload_attestation_messages(), 2); + + op_pool.prune_payload_attestation_messages(Slot::new(4)); + assert_eq!(op_pool.num_payload_attestation_messages(), 1); + + op_pool.prune_payload_attestation_messages(Slot::new(5)); + assert_eq!(op_pool.num_payload_attestation_messages(), 0); + } + + #[tokio::test] + async fn payload_attestation_packs_bits_from_ptc_positions() { + let spec = test_spec::(); + if spec.gloas_fork_epoch.is_none() { + return; + }; + + let num_validators = 64; + let harness = get_harness::(num_validators, Some(spec.clone())); + + harness + .add_attested_blocks_at_slots( + harness.get_current_state(), + Hash256::zero(), + &[Slot::new(1)], + (0..num_validators).collect::>().as_slice(), + ) + .await; + + let head = harness.chain.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + assert_eq!(state.slot(), Slot::new(1)); + + let target_slot = Slot::new(1); + let parent_root = head.head_block_root(); + let ptc = state.get_ptc(target_slot, &spec).unwrap(); + let ptc_member_0 = ptc.0[0] as u64; + let ptc_member_1 = ptc.0[1] as u64; + + let op_pool = OperationPool::::new(); + + let msg0 = make_payload_attestation_message(target_slot, ptc_member_0, parent_root); + let msg1 = make_payload_attestation_message(target_slot, ptc_member_1, parent_root); + op_pool.insert_payload_attestation_message(msg0).unwrap(); + op_pool.insert_payload_attestation_message(msg1).unwrap(); + + // Advance state to slot 2 so get_payload_attestations looks at slot 1. + let mut advanced_state = state.clone(); + state_processing::state_advance::complete_state_advance( + &mut advanced_state, + None, + Slot::new(2), + &spec, + ) + .unwrap(); + + let attestations = op_pool + .get_payload_attestations(&advanced_state, parent_root, &spec) + .unwrap(); + + assert_eq!(attestations.len(), 1); + assert_eq!(attestations[0].aggregation_bits.num_set_bits(), 2); + assert!(attestations[0].aggregation_bits.get(0).unwrap()); + assert!(attestations[0].aggregation_bits.get(1).unwrap()); + assert!(attestations[0].data.payload_present); + } + + #[tokio::test] + async fn payload_attestation_multiple_data_combos_capped() { + let spec = test_spec::(); + if spec.gloas_fork_epoch.is_none() { + return; + }; + + let num_validators = 64; + let harness = get_harness::(num_validators, Some(spec.clone())); + + harness + .add_attested_blocks_at_slots( + harness.get_current_state(), + Hash256::zero(), + &[Slot::new(1)], + (0..num_validators).collect::>().as_slice(), + ) + .await; + + let head = harness.chain.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let target_slot = Slot::new(1); + let parent_root = head.head_block_root(); + let ptc = state.get_ptc(target_slot, &spec).unwrap(); + + let op_pool = OperationPool::::new(); + + // Given: PTC members vote with all 4 boolean combos, with varying participation. + let combos: [(bool, bool, &[usize]); 4] = [ + (true, true, &[0, 1, 2]), + (true, false, &[3, 4]), + (false, true, &[5]), + (false, false, &[6]), + ]; + for (payload_present, blob_available, positions) in &combos { + for &pos in *positions { + let validator_index = ptc.0[pos] as u64; + let msg = make_payload_attestation_message_with_flags( + target_slot, + validator_index, + parent_root, + *payload_present, + *blob_available, + ); + op_pool.insert_payload_attestation_message(msg).unwrap(); + } + } + + // When: we pack attestations for block production at slot 2. + let mut advanced_state = state.clone(); + state_processing::state_advance::complete_state_advance( + &mut advanced_state, + None, + Slot::new(2), + &spec, + ) + .unwrap(); + let attestations = op_pool + .get_payload_attestations(&advanced_state, parent_root, &spec) + .unwrap(); + + // Then: one attestation per combo, sorted by participation (most first). + assert_eq!(attestations.len(), 4); + let bit_counts: Vec<_> = attestations + .iter() + .map(|a| a.aggregation_bits.num_set_bits()) + .collect(); + assert_eq!(bit_counts, vec![3, 2, 1, 1]); + } } diff --git a/beacon_node/operation_pool/src/persistence.rs b/beacon_node/operation_pool/src/persistence.rs index 241b5fec53..56aafc27fe 100644 --- a/beacon_node/operation_pool/src/persistence.rs +++ b/beacon_node/operation_pool/src/persistence.rs @@ -209,6 +209,7 @@ impl PersistedOperationPool { proposer_slashings, voluntary_exits, bls_to_execution_changes: RwLock::new(bls_to_execution_changes), + payload_attestation_messages: Default::default(), reward_cache: Default::default(), _phantom: Default::default(), }; diff --git a/validator_client/validator_services/src/payload_attestation_service.rs b/validator_client/validator_services/src/payload_attestation_service.rs index 2f3ca8bed2..24949edc1f 100644 --- a/validator_client/validator_services/src/payload_attestation_service.rs +++ b/validator_client/validator_services/src/payload_attestation_service.rs @@ -101,10 +101,15 @@ impl PayloadAttestationServ sleep(duration_to_next_slot + payload_attestation_due).await; + let Some(attestation_slot) = self.slot_clock.now() else { + error!("Failed to read slot clock after sleep"); + continue; + }; + let service = self.clone(); self.executor.spawn( async move { - service.produce_and_publish(current_slot).await; + service.produce_and_publish(attestation_slot).await; }, "payload_attestation_producer", ); From 4415cf050693a8205dd12e1cbd61b394bebb3e4e Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Tue, 28 Apr 2026 14:45:03 +0200 Subject: [PATCH 147/189] Gloas filter conflicting voluntary exits (#9183) Parent envelope execution requests can invalidate voluntary exits. We should filter out any conflicting voluntary exits during block production to avoid triggering failures. Spec change: https://github.com/ethereum/consensus-specs/pull/5176 Co-Authored-By: Eitan Seri-Levi Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- .../src/block_production/gloas.rs | 211 ++++++++++++++++-- 1 file changed, 198 insertions(+), 13 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 4bc4b9862c..a6ebc2fefa 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -1,8 +1,8 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; use std::sync::Arc; -use bls::Signature; +use bls::{PublicKeyBytes, Signature}; use execution_layer::{ BlockProposalContentsGloas, BuilderParams, PayloadAttributes, PayloadParameters, }; @@ -28,7 +28,7 @@ use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{ Address, Attestation, AttestationElectra, AttesterSlashing, AttesterSlashingElectra, BeaconBlock, BeaconBlockBodyGloas, BeaconBlockGloas, BeaconState, BeaconStateError, - BuilderIndex, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, + BuilderIndex, ChainSpec, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBid, ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, FullPayload, Graffiti, Hash256, PayloadAttestation, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadEnvelope, @@ -137,6 +137,16 @@ impl BeaconChain { graffiti_settings: GraffitiSettings, verification: ProduceBlockVerification, ) -> Result, BlockProductionError> { + // Extract the parent's execution requests from the envelope (if parent was full). + let parent_execution_requests = if parent_payload_status == PayloadStatus::Full { + parent_envelope + .as_ref() + .map(|env| env.message.execution_requests.clone()) + .ok_or(BlockProductionError::MissingParentExecutionPayload)? + } else { + ExecutionRequests::default() + }; + // Part 1/3 (blocking) // // Perform the state advance and block-packing functions. @@ -145,6 +155,7 @@ impl BeaconChain { .graffiti_calculator .get_graffiti(graffiti_settings) .await; + let parent_execution_requests_ref = parent_execution_requests.clone(); let (partial_beacon_block, state) = self .task_executor .spawn_blocking_handle( @@ -155,6 +166,7 @@ impl BeaconChain { produce_at_slot, randao_reveal, graffiti, + &parent_execution_requests_ref, ) }, "produce_partial_beacon_block_gloas", @@ -163,16 +175,6 @@ impl BeaconChain { .await .map_err(BlockProductionError::TokioJoin)??; - // Extract the parent's execution requests from the envelope (if parent was full). - let parent_execution_requests = if parent_payload_status == PayloadStatus::Full { - parent_envelope - .as_ref() - .map(|env| env.message.execution_requests.clone()) - .ok_or(BlockProductionError::MissingParentExecutionPayload)? - } else { - ExecutionRequests::default() - }; - // Part 2/3 (async) // // Produce the execution payload bid. @@ -223,6 +225,7 @@ impl BeaconChain { produce_at_slot: Slot, randao_reveal: Signature, graffiti: Graffiti, + parent_execution_requests: &ExecutionRequests, ) -> Result<(PartialBeaconBlock, BeaconState), BlockProductionError> { // It is invalid to try to produce a block using a state from a future slot. @@ -257,6 +260,13 @@ impl BeaconChain { let (mut proposer_slashings, mut attester_slashings, mut voluntary_exits) = self.op_pool.get_slashings_and_exits(&state, &self.spec); + filter_voluntary_exits_for_parent_execution_requests( + &mut voluntary_exits, + parent_execution_requests, + |idx| state.validators().get(idx as usize).map(|v| v.pubkey), + &self.spec, + ); + drop(slashings_and_exits_span); let eth1_data = state.eth1_data().clone(); @@ -958,3 +968,178 @@ where Ok(block_contents) } + +/// Drop voluntary exits whose target validators will be exited by the parent envelope's +/// execution requests. +/// +/// In Gloas the parent execution payload is processed before voluntary exits during block +/// processing. EL-triggered withdrawal-full-exit requests (EIP-7002) and cross-pubkey +/// consolidation requests (EIP-7251) call `initiate_validator_exit`, setting the target's +/// `exit_epoch`. A voluntary exit for the same validator would then fail with `AlreadyExited`. +fn filter_voluntary_exits_for_parent_execution_requests( + voluntary_exits: &mut Vec, + parent_execution_requests: &ExecutionRequests, + pubkey_at_index: impl Fn(u64) -> Option, + spec: &ChainSpec, +) { + let mut exited_pubkeys = HashSet::with_capacity( + parent_execution_requests.withdrawals.len() + + parent_execution_requests.consolidations.len(), + ); + for req in &parent_execution_requests.withdrawals { + if req.amount == spec.full_exit_request_amount { + exited_pubkeys.insert(req.validator_pubkey); + } + } + for req in &parent_execution_requests.consolidations { + if req.source_pubkey != req.target_pubkey { + exited_pubkeys.insert(req.source_pubkey); + } + } + if !exited_pubkeys.is_empty() { + voluntary_exits.retain(|exit| { + pubkey_at_index(exit.message.validator_index) + .map(|pk| !exited_pubkeys.contains(&pk)) + .unwrap_or(false) + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ssz_types::VariableList; + use types::{ConsolidationRequest, Epoch, MainnetEthSpec, VoluntaryExit, WithdrawalRequest}; + + type TestSpec = MainnetEthSpec; + + fn pubkey(byte: u8) -> PublicKeyBytes { + PublicKeyBytes::deserialize(&[byte; 48]).expect("valid pubkey byte length") + } + + fn exit(validator_index: u64) -> SignedVoluntaryExit { + SignedVoluntaryExit { + message: VoluntaryExit { + epoch: Epoch::new(0), + validator_index, + }, + signature: Signature::empty(), + } + } + + fn requests( + withdrawals: Vec, + consolidations: Vec, + ) -> ExecutionRequests { + ExecutionRequests { + deposits: VariableList::empty(), + withdrawals: VariableList::new(withdrawals).unwrap(), + consolidations: VariableList::new(consolidations).unwrap(), + } + } + + fn run_filter( + exits: &mut Vec, + requests: &ExecutionRequests, + validator_pubkeys: &[PublicKeyBytes], + spec: &ChainSpec, + ) { + filter_voluntary_exits_for_parent_execution_requests( + exits, + requests, + |idx| validator_pubkeys.get(idx as usize).copied(), + spec, + ); + } + + #[test] + fn full_exit_withdrawal_request_filters_matching_voluntary_exit() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1), pubkey(2)]; + let mut exits = vec![exit(0), exit(1)]; + let reqs = requests( + vec![WithdrawalRequest { + source_address: Address::repeat_byte(0xaa), + validator_pubkey: validators[0], + amount: spec.full_exit_request_amount, + }], + vec![], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 1); + assert_eq!(exits[0].message.validator_index, 1); + } + + #[test] + fn partial_withdrawal_request_does_not_filter_voluntary_exit() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1)]; + let mut exits = vec![exit(0)]; + let reqs = requests( + vec![WithdrawalRequest { + source_address: Address::repeat_byte(0xaa), + validator_pubkey: validators[0], + amount: spec.full_exit_request_amount + 1, + }], + vec![], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 1); + } + + #[test] + fn cross_pubkey_consolidation_filters_voluntary_exit_for_source_only() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1), pubkey(2), pubkey(3)]; + let mut exits = vec![exit(0), exit(1), exit(2)]; + let reqs = requests( + vec![], + vec![ConsolidationRequest { + source_address: Address::repeat_byte(0xaa), + source_pubkey: validators[1], + target_pubkey: validators[2], + }], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + // The source (validator 1) is exited; the target (validator 2) is not. + let remaining: Vec = exits.iter().map(|e| e.message.validator_index).collect(); + assert_eq!(remaining, vec![0, 2]); + } + + #[test] + fn self_consolidation_does_not_filter_voluntary_exit() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1)]; + let mut exits = vec![exit(0)]; + let reqs = requests( + vec![], + vec![ConsolidationRequest { + source_address: Address::repeat_byte(0xaa), + source_pubkey: validators[0], + target_pubkey: validators[0], + }], + ); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 1); + } + + #[test] + fn empty_parent_requests_preserve_voluntary_exits() { + let spec = ChainSpec::mainnet(); + let validators = vec![pubkey(1), pubkey(2)]; + let mut exits = vec![exit(0), exit(1)]; + let reqs = requests(vec![], vec![]); + + run_filter(&mut exits, &reqs, &validators, &spec); + + assert_eq!(exits.len(), 2); + } +} From 6258eadc91c10432c93ab1487ef7ee436470b6d6 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Tue, 28 Apr 2026 15:19:47 +0200 Subject: [PATCH 148/189] Gloas publish data columns during local block building (#9182) Make sure we are publishing columns during local block production Co-Authored-By: Eitan Seri-Levi Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- .../src/block_production/gloas.rs | 16 +- beacon_node/beacon_chain/src/kzg_utils.rs | 108 +++++++++++-- .../src/pending_payload_envelopes.rs | 93 ++++++++--- beacon_node/beacon_chain/src/test_utils.rs | 42 +++-- .../test_data_column_sidecars_gloas.ssz | Bin 0 -> 275968 bytes .../beacon_chain/tests/column_verification.rs | 72 +++++++++ .../beacon_chain/tests/prepare_payload.rs | 118 ++++++++++++++ .../src/beacon/execution_payload_envelope.rs | 151 +++++++++++++++++- beacon_node/http_api/src/publish_blocks.rs | 2 +- 9 files changed, 545 insertions(+), 57 deletions(-) create mode 100644 beacon_node/beacon_chain/src/test_utils/fixtures/test_data_column_sidecars_gloas.ssz diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index a6ebc2fefa..79ea78ce4a 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -35,6 +35,7 @@ use types::{ SignedVoluntaryExit, Slot, SyncAggregate, Withdrawal, Withdrawals, }; +use crate::pending_payload_envelopes::PendingEnvelopeData; use crate::{ BeaconChain, BeaconChainError, BeaconChainTypes, BlockProductionError, ProduceBlockVerification, block_production::BlockProductionState, @@ -74,6 +75,7 @@ pub struct ExecutionPayloadData { pub execution_requests: ExecutionRequests, pub builder_index: BuilderIndex, pub slot: Slot, + pub blobs_and_proofs: (types::BlobsList, types::KzgProofs), } impl BeaconChain { @@ -647,9 +649,14 @@ impl BeaconChain { let envelope_slot = payload_data.slot; // TODO(gloas) might be safer to cache by root instead of by slot. // We should revisit this once this code path + beacon api spec matures - self.pending_payload_envelopes - .write() - .insert(envelope_slot, signed_envelope.message); + let (blobs, _) = payload_data.blobs_and_proofs; + self.pending_payload_envelopes.write().insert( + envelope_slot, + PendingEnvelopeData { + envelope: signed_envelope.message, + blobs: Some(blobs), + }, + ); debug!( %beacon_block_root, @@ -769,7 +776,7 @@ impl BeaconChain { payload_value: _, execution_requests, blob_kzg_commitments, - blobs_and_proofs: _, + blobs_and_proofs, } = block_proposal_contents; // TODO(gloas) since we are defaulting to local building, execution payment is 0 @@ -795,6 +802,7 @@ impl BeaconChain { execution_requests, builder_index, slot: produce_at_slot, + blobs_and_proofs, }; // TODO(gloas) this is only local building diff --git a/beacon_node/beacon_chain/src/kzg_utils.rs b/beacon_node/beacon_chain/src/kzg_utils.rs index 9641aec47d..b05a896777 100644 --- a/beacon_node/beacon_chain/src/kzg_utils.rs +++ b/beacon_node/beacon_chain/src/kzg_utils.rs @@ -296,6 +296,35 @@ pub fn blobs_to_data_column_sidecars( } } +/// Build Gloas data column sidecars from blobs, computing cells and proofs locally. +pub fn blobs_to_data_column_sidecars_gloas( + blobs: &[&Blob], + beacon_block_root: Hash256, + slot: Slot, + kzg: &Kzg, + spec: &ChainSpec, +) -> Result, DataColumnSidecarError> { + if blobs.is_empty() { + return Ok(vec![]); + } + + let blob_cells_and_proofs_vec = blobs + .into_par_iter() + .map(|blob| { + let blob = blob.as_ref().try_into().map_err(|e| { + KzgError::InconsistentArrayLength(format!( + "blob should have a guaranteed size due to FixedVector: {e:?}" + )) + })?; + + kzg.compute_cells_and_proofs(blob) + }) + .collect::, KzgError>>()?; + + build_data_column_sidecars_gloas(beacon_block_root, slot, blob_cells_and_proofs_vec, spec) + .map_err(DataColumnSidecarError::BuildSidecarFailed) +} + /// Build data column sidecars from a signed beacon block and its blobs. #[instrument(skip_all, level = "debug", fields(blob_count = blobs_and_proofs.len()))] pub fn blobs_to_partial_data_columns( @@ -728,8 +757,8 @@ pub fn reconstruct_data_columns( #[cfg(test)] mod test { use crate::kzg_utils::{ - blobs_to_data_column_sidecars, reconstruct_blobs, reconstruct_data_columns, - validate_full_data_columns, + blobs_to_data_column_sidecars, blobs_to_data_column_sidecars_gloas, reconstruct_blobs, + reconstruct_data_columns, validate_full_data_columns, }; use bls::Signature; use eth2::types::BlobsBundle; @@ -737,25 +766,30 @@ mod test { use kzg::{Kzg, KzgCommitment, trusted_setup::get_trusted_setup}; use types::{ BeaconBlock, BeaconBlockFulu, BlobsList, ChainSpec, EmptyBlock, EthSpec, ForkName, - FullPayload, KzgProofs, MainnetEthSpec, SignedBeaconBlock, kzg_ext::KzgCommitments, + FullPayload, Hash256, KzgProofs, MainnetEthSpec, SignedBeaconBlock, Slot, + kzg_ext::KzgCommitments, }; type E = MainnetEthSpec; // Loading and initializing PeerDAS KZG is expensive and slow, so we group the tests together // only load it once. - // TODO(Gloas) make this generic over fulu/gloas, or write a separate function for Gloas #[test] fn test_build_data_columns_sidecars() { - let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); let kzg = get_kzg(); - test_build_data_columns_empty(&kzg, &spec); - test_build_data_columns_fulu(&kzg, &spec); - test_reconstruct_data_columns(&kzg, &spec); - test_reconstruct_data_columns_unordered(&kzg, &spec); - test_reconstruct_blobs_from_data_columns(&kzg, &spec); - test_reconstruct_blobs_from_data_columns_unordered(&kzg, &spec); - test_validate_data_columns(&kzg, &spec); + + let fulu_spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + test_build_data_columns_empty(&kzg, &fulu_spec); + test_build_data_columns_fulu(&kzg, &fulu_spec); + test_reconstruct_data_columns(&kzg, &fulu_spec); + test_reconstruct_data_columns_unordered(&kzg, &fulu_spec); + test_reconstruct_blobs_from_data_columns(&kzg, &fulu_spec); + test_reconstruct_blobs_from_data_columns_unordered(&kzg, &fulu_spec); + test_validate_data_columns(&kzg, &fulu_spec); + + let gloas_spec = ForkName::Gloas.make_genesis_spec(E::default_spec()); + test_build_data_columns_gloas(&kzg, &gloas_spec); + test_build_data_columns_gloas_empty(&kzg, &gloas_spec); } #[track_caller] @@ -784,8 +818,49 @@ mod test { assert!(column_sidecars.is_empty()); } - // TODO(gloas) create `test_build_data_columns_gloas` and make sure its called - // in the relevant places + #[track_caller] + fn test_build_data_columns_gloas(kzg: &Kzg, spec: &ChainSpec) { + let num_of_blobs = 2; + let (blobs, _proofs) = create_test_gloas_blobs::(num_of_blobs); + let beacon_block_root = Hash256::random(); + let slot = Slot::new(0); + + let blob_refs: Vec<_> = blobs.iter().collect(); + let column_sidecars = blobs_to_data_column_sidecars_gloas::( + &blob_refs, + beacon_block_root, + slot, + kzg, + spec, + ) + .unwrap(); + + assert_eq!(column_sidecars.len(), E::number_of_columns()); + for (idx, col_sidecar) in column_sidecars.iter().enumerate() { + assert_eq!(*col_sidecar.index(), idx as u64); + assert_eq!(col_sidecar.column().len(), num_of_blobs); + assert_eq!(col_sidecar.kzg_proofs().len(), num_of_blobs); + + let gloas_col = col_sidecar.as_gloas().expect("should be Gloas sidecar"); + assert_eq!(gloas_col.beacon_block_root, beacon_block_root); + assert_eq!(gloas_col.slot, slot); + } + } + + #[track_caller] + fn test_build_data_columns_gloas_empty(kzg: &Kzg, spec: &ChainSpec) { + let blob_refs: Vec<&types::Blob> = vec![]; + let column_sidecars = blobs_to_data_column_sidecars_gloas::( + &blob_refs, + Hash256::random(), + Slot::new(0), + kzg, + spec, + ) + .unwrap(); + assert!(column_sidecars.is_empty()); + } + #[track_caller] fn test_build_data_columns_fulu(kzg: &Kzg, spec: &ChainSpec) { // Using at least 2 blobs to make sure we're arranging the data columns correctly. @@ -974,4 +1049,9 @@ mod test { (signed_block, blobs, proofs) } + + fn create_test_gloas_blobs(num_of_blobs: usize) -> (BlobsList, KzgProofs) { + let (blobs_bundle, _) = generate_blobs::(num_of_blobs, ForkName::Gloas).unwrap(); + (blobs_bundle.blobs, blobs_bundle.proofs) + } } diff --git a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs index 351783832d..293553ef54 100644 --- a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs +++ b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs @@ -6,7 +6,12 @@ //! and publishes the payload. use std::collections::HashMap; -use types::{EthSpec, ExecutionPayloadEnvelope, Slot}; +use types::{BlobsList, EthSpec, ExecutionPayloadEnvelope, Slot}; + +pub struct PendingEnvelopeData { + pub envelope: ExecutionPayloadEnvelope, + pub blobs: Option>, +} /// Cache for pending execution payload envelopes awaiting publishing. /// @@ -16,7 +21,7 @@ pub struct PendingPayloadEnvelopes { /// Maximum number of slots to keep envelopes before pruning. max_slot_age: u64, /// The envelopes, keyed by slot. - envelopes: HashMap>, + envelopes: HashMap>, } impl Default for PendingPayloadEnvelopes { @@ -38,19 +43,24 @@ impl PendingPayloadEnvelopes { } /// Insert a pending envelope into the cache. - pub fn insert(&mut self, slot: Slot, envelope: ExecutionPayloadEnvelope) { + pub fn insert(&mut self, slot: Slot, data: PendingEnvelopeData) { // TODO(gloas): we may want to check for duplicates here, which shouldn't be allowed - self.envelopes.insert(slot, envelope); + self.envelopes.insert(slot, data); } /// Get a pending envelope by slot. pub fn get(&self, slot: Slot) -> Option<&ExecutionPayloadEnvelope> { - self.envelopes.get(&slot) + self.envelopes.get(&slot).map(|d| &d.envelope) + } + + /// Remove and return the blobs and proofs for a slot, leaving the envelope in place. + pub fn take_blobs(&mut self, slot: Slot) -> Option> { + self.envelopes.get_mut(&slot).and_then(|d| d.blobs.take()) } /// Remove and return a pending envelope by slot. pub fn remove(&mut self, slot: Slot) -> Option> { - self.envelopes.remove(&slot) + self.envelopes.remove(&slot).map(|d| d.envelope) } /// Check if an envelope exists for the given slot. @@ -85,15 +95,18 @@ mod tests { type E = MainnetEthSpec; - fn make_envelope(slot: Slot) -> ExecutionPayloadEnvelope { - ExecutionPayloadEnvelope { - payload: ExecutionPayloadGloas { - slot_number: slot, - ..ExecutionPayloadGloas::default() + fn make_envelope(slot: Slot) -> PendingEnvelopeData { + PendingEnvelopeData { + envelope: ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas { + slot_number: slot, + ..ExecutionPayloadGloas::default() + }, + execution_requests: ExecutionRequests::default(), + builder_index: 0, + beacon_block_root: Hash256::ZERO, }, - execution_requests: ExecutionRequests::default(), - builder_index: 0, - beacon_block_root: Hash256::ZERO, + blobs: None, } } @@ -101,33 +114,73 @@ mod tests { fn insert_and_get() { let mut cache = PendingPayloadEnvelopes::::default(); let slot = Slot::new(1); - let envelope = make_envelope(slot); + let data = make_envelope(slot); + let expected_envelope = data.envelope.clone(); assert!(!cache.contains(slot)); assert_eq!(cache.len(), 0); - cache.insert(slot, envelope.clone()); + cache.insert(slot, data); assert!(cache.contains(slot)); assert_eq!(cache.len(), 1); - assert_eq!(cache.get(slot), Some(&envelope)); + assert_eq!(cache.get(slot), Some(&expected_envelope)); } #[test] fn remove() { let mut cache = PendingPayloadEnvelopes::::default(); let slot = Slot::new(1); - let envelope = make_envelope(slot); + let data = make_envelope(slot); + let expected_envelope = data.envelope.clone(); - cache.insert(slot, envelope.clone()); + cache.insert(slot, data); assert!(cache.contains(slot)); let removed = cache.remove(slot); - assert_eq!(removed, Some(envelope)); + assert_eq!(removed, Some(expected_envelope)); assert!(!cache.contains(slot)); assert_eq!(cache.len(), 0); } + #[test] + fn take_blobs_returns_once() { + let mut cache = PendingPayloadEnvelopes::::default(); + let slot = Slot::new(1); + + let blobs = BlobsList::::default(); + let data = PendingEnvelopeData { + envelope: make_envelope(slot).envelope, + blobs: Some(blobs), + }; + cache.insert(slot, data); + + // First take returns the blobs + let taken = cache.take_blobs(slot); + assert!(taken.is_some()); + + // Second take returns None — blobs are consumed + let taken_again = cache.take_blobs(slot); + assert!(taken_again.is_none()); + + // Envelope is still in the cache + assert!(cache.contains(slot)); + assert!(cache.get(slot).is_some()); + } + + #[test] + fn take_blobs_returns_none_when_absent() { + let mut cache = PendingPayloadEnvelopes::::default(); + let slot = Slot::new(1); + + // Insert with no blobs + cache.insert(slot, make_envelope(slot)); + assert!(cache.take_blobs(slot).is_none()); + + // Non-existent slot + assert!(cache.take_blobs(Slot::new(99)).is_none()); + } + #[test] fn prune_old_envelopes() { let mut cache = PendingPayloadEnvelopes::::new(2); diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 274f41d1cb..f67b5015c5 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -86,6 +86,8 @@ pub const FORK_NAME_ENV_VAR: &str = "FORK_NAME"; // `beacon_node/execution_layer/src/test_utils/fixtures/mainnet/test_blobs_bundle.ssz` pub const TEST_DATA_COLUMN_SIDECARS_SSZ: &[u8] = include_bytes!("test_utils/fixtures/test_data_column_sidecars.ssz"); +pub const TEST_DATA_COLUMN_SIDECARS_GLOAS_SSZ: &[u8] = + include_bytes!("test_utils/fixtures/test_data_column_sidecars_gloas.ssz"); // Default target aggregators to set during testing, this ensures an aggregator at each slot. // @@ -3789,24 +3791,24 @@ pub fn generate_data_column_sidecars_from_block( block: &SignedBeaconBlock, spec: &ChainSpec, ) -> DataColumnSidecarList { - let kzg_commitments = block.message().body().blob_kzg_commitments().unwrap(); - if kzg_commitments.is_empty() { - return vec![]; - } - - let kzg_commitments_inclusion_proof = block - .message() - .body() - .kzg_commitments_merkle_proof() - .unwrap(); - let signed_block_header = block.signed_block_header(); - // Load the precomputed column sidecar to avoid computing them for every block in the tests. // Then repeat the cells and proofs for every blob if block.fork_name_unchecked().gloas_enabled() { + let kzg_commitments = &block + .message() + .body() + .signed_execution_payload_bid() + .expect("Gloas block should have a payload bid") + .message + .blob_kzg_commitments; + if kzg_commitments.is_empty() { + return vec![]; + } + let num_blobs = kzg_commitments.len(); + let signed_block_header = block.signed_block_header(); let template_data_columns = RuntimeVariableList::>::from_ssz_bytes( - TEST_DATA_COLUMN_SIDECARS_SSZ, + TEST_DATA_COLUMN_SIDECARS_GLOAS_SSZ, E::number_of_columns(), ) .unwrap(); @@ -3826,7 +3828,7 @@ pub fn generate_data_column_sidecars_from_block( .collect::<(Vec<_>, Vec<_>)>(); let blob_cells_and_proofs_vec = - vec![(cells.try_into().unwrap(), proofs.try_into().unwrap()); kzg_commitments.len()]; + vec![(cells.try_into().unwrap(), proofs.try_into().unwrap()); num_blobs]; build_data_column_sidecars_gloas( signed_block_header.message.tree_hash_root(), @@ -3836,6 +3838,18 @@ pub fn generate_data_column_sidecars_from_block( ) .unwrap() } else { + let kzg_commitments = block.message().body().blob_kzg_commitments().unwrap(); + if kzg_commitments.is_empty() { + return vec![]; + } + + let kzg_commitments_inclusion_proof = block + .message() + .body() + .kzg_commitments_merkle_proof() + .unwrap(); + let signed_block_header = block.signed_block_header(); + // load the precomputed column sidecar to avoid computing them for every block in the tests. let template_data_columns = RuntimeVariableList::>::from_ssz_bytes( diff --git a/beacon_node/beacon_chain/src/test_utils/fixtures/test_data_column_sidecars_gloas.ssz b/beacon_node/beacon_chain/src/test_utils/fixtures/test_data_column_sidecars_gloas.ssz new file mode 100644 index 0000000000000000000000000000000000000000..554b27844b297c735bf8e16c0340f5d19d724af0 GIT binary patch literal 275968 zcmbT6RZt#Ew59vUg9LYX*93QW4eqWXxCVE32<{$&ySux)TL=&c1Sia?sk-kocfWM^ zV^>%8{`Oi6KmlNl2*4}`06c^Mo0O*|nfNl|hgiQb*4goN}0ATbl z0BrvNNQZ*}fC>TDxDYT)3IRNH5RlIX0SLSh;3x_KtFjOvq6Puwx)6Y71_7RS5U}e4 z0Wv-iP#XdPc(D)=kO~2(IS`;y3IVOv5J1)f0TDeAa5Vw}+OrVQy9xnxdk~QD69OKt zA;9r02>k%NXPu|@kA||20)glzo-hu+X zM^J$75(*^TL4k*VP{0@-8jPYs12#NpkWK~-06jFYW`_o|ywHG03>xIiK?8(O(7;g- z8myW@1Ch_rpxhN2p!q@r&roQv8w(9&(x5?YE;PU^g9ZUL(BQNM8mRO_gVs@KKsE;r zBG#b6)gCm^K7$6mf1m-~Gc-tmh5-*qFu)iK28Gf)f{6;G{j$hO_}aZm$(I0kwcR`~TEXSJDy0k-NOB;!o8J=u1~Fe3 z9%;gDMx%Q9p*t0DcodpI9x(R{=an0lFRm_qP7~%qduRrpmBgMmjlpZd0C4O zcaHiV?Xyiq)b@g&R7q_gap{Kwgqf4D5FSAEU03dFYw}E}r|}=eZ8IyTuNjQy*y!qx z3wY1Th4w&$^xc4I>Ih*wWFKA1HA)RKfzw=_7f2MI>F0HU=Z}A<%)yG-e(P2NA4w|Btz*`SQ1GhWKE<|O zp}N3>0#~kqH>f#3SKW!b7nf_l=>rUmv{#tjH(zl)dMDFqCfbK0fJkya50WtveydmP zC=Inbvl+s`zDK^$uHTqEb%}o>5HQ|AKEAH**MpY9#bHPFeLQG+$ujUh)|kH)e|*UX zl?-= z(z8A;&#H@4Fs*$6x;XA*&%LU+l8g7=bFJ3(r~6q#{~uyc&#Mkk!}waVYREutw)<>}StaFT`7VsIPZWgT_Y0p?K zU)YksKA?sF_~frZ1@q*eq=a=D3w8CRhS&YUi`&p>fNEE77~GR|J~6vRO=;6|;A$ow za;fmbmaje%BUJwBKXTGs0caBhcxu8-!e|yDtjGC({pVwkV^0NMy*&%k(1XUv!7ra{ zJtexwEN+nYj;HkC!RfvBd}-*1y6-W#Te)@K0PEOO(enZ~=EFMo3>k6dE8eRlv$po| zFrup~B>tET;J}adgf5t;_*CGgo=kYYi+v0hLY!YDo{}_$&hq^LF5Oc)%<)liuJJz( zTEJt`5Gvog_pk<$>UYK9Xqi)h1Hp5z=->qQnwm={l7qwp`jcdK)4T z;!bvO=Ehj*AxRv{+0!m6E>a&!c&>;4Jf~#YN=Ta* zdfMz!Er|To72E?!lo+MNgTD8fJOpD>VKCNpJw^I49XWbmM{rHEA0Z%SIu7X)=W1ppYB0n-Qrvr~jHoSY>0P2e-<>ph8oud<}*@$pVAY$Dfl=NFs=x zK`uZP5x*d&!AWry5Bgu17@iz$03E_4L$!}KUZo>?uQXt5Qg-(7MNQRKu(bsli5Y0lM$ZGt38w<@No z^<86AHP(p_jv0n{t8%Q6X&wkn`Ta0BQHU)`rmShZ@CdG?3aHZlC+?UMpi60%$g>@8{ zVf7gLuWtZgTSVHsVGU{nFi@R5SS(qQ5V9H@*G_q_0CKAGt%S~Pw8qGLzwZ*@_``eF zXT!D?sJox?g+b43Do~o*QT4-5&k;n*+8VdO;<|*pDrbIA=AlLzG`5cCZWY1FcfRM0 zJvPv~!XN{(@lLBF33dK-n6l5`KkVtkGKm#tWpVzK4TJkcKdu2#a$;2O4ZO>;O{(iM zbf(rqcojZ56o z=fGDFvhs4yx!P$RPAM|s9k*`pgq?DweDj8s{C$wTI>g!@(6hphKJKkDrf7T!H6t{J zOCVstWAHCJgA%lD4VZbP|DYtB!SfV*uKy|0?1N6P(ek4V!Xl0Cx(K{OBFy^Mx24wj z97A+I$^NTZ^_9Hpcuzxsch3lgZwvYbg3LoYQnOYa*@Gj0ttIJ)&_-VDOV;LVs}4tn zngC=gU(GdCC5IyA(a3k&30)fyfn53#^NXQj?4U3%SU|Yt7~9tw;S96y?m~r!5G`&W z2SG!Qh()6LIHk%F57If^qwD!}jTakPBI-H!UYgj)*4#7;omsrGq-K+VI5pVSSqOBoafs^oo$(yO^EyI#fu@>G zT(uvNxb(7ZeQH!TD1TZp39DDFvzHE>huUPS41!;j<1+;=8NW%<7fEA`UnVVnL(^UY3Z0{EM)c{*h}svAl$g;+ zBEG-dxK(5pZN|#RAx*23*a^mxD5ACKb{dH5&nVNBQ_Sy3FSn{XCTwJ^pBadnti)m{ ziFP6rc7gw>=GxV>sKf0bd(vxG@hcAmk9Duo{d^~u&2>G#1+>q#ogMmj;;OVn?K?&U zz=TZK2zYi>*o3cMRqx?R03H2@*`~l>A;gOXL(2*glXTJT2`54JFFi3|1*dkg0N+`= zOVY(g%U;rc%E(YDa>r)#kdPKi43@tt7JEoMK!g)?-)G>M?}XCD$cr^c*nl&E5P>Erk_*L)O@@&Tm1rGVy(GxwlaFa0!vd7mS@TFS`4f zL7OGyQWjce=qiwB5=LsY0{LF7r}*OhMPismrcuk3MoCGE8s4jmh6~{P{>z!k5f@pU zb(Kp=o(&z!lP+q0*d4jR>Z(R&m`}r+sdI1hJKs(b(5Yl z5MT~dIxT?DfU3t`uVoy%N!zJzuip68tO9G%nyA9%1~9p(9lDXyx$-}-?guhT4GH}G z#K_9qs*ZFI55&c>fvBwa00HBjFg3L(S75Zzv%?E3rg4HlMUKGX-0unD)AhiAw z+#pnJw785$a(IO&Jby=kb8LD{alusr7b_tgr|M#;>Bx7 z^^;q`r5#S+NzR>#=L20oX8zjzG}2CO1#eqv0cUBzA2JErnZG0cfbgcURK|4kAx)bOxl;HdagtxD}3indD#cHr5T{? zJ_@S@ZHUZDd<}os@2T)BjFscwpchFnCOfD=I?0>+b_SD~c*RA9&ex7QZgjM3RD%=T zJs{FK>))vjf(?5N!B2Viob zCzS7qD|zG?C;iNw78OJqpRmsQ-%xTa8rNX?1VRK1m1@45ci8=8j#p*Ld~|NdGymS@ z_yL1IjL&8(1Yr3$)m~karHg*m##QB z2VB7oJEgA>o;C0BSYy8NGtJZ;e0rJi`phR%>(?AXAis~3hsqh5_~Q6v71JctT(G{r ze%X_GPwOjWoG4Wc?)VOQL@_jjV~6mqw4)nH^h(gTwhN~S2~C&=*yCb=nWj8#yq3KJ zX^nJ4@s*rI(#hx2fA3gT>zn?t<)AqOhYMk>7jZ6S)L9K`#cU~YYVkN7?H4ko1nYu- zQzC_+b2_fD?JFApgQ1Np?!5E`MUt~#$I=Ql6Z5DjGjCo0o`DDYb71b{Y zi5q-}=74wXu((6lkHZBp^Mpi+3u31zr5O4z^qr0KBdRiv%c**17o5!d(y&1MpaOTs zyWbgOS_{*$Z84wZ0&q#x3h~iP0N0aNKLeoLK(O>6PKtT=zy^7;wJSP7P)n5|w&t+7 zYRN(am*C0^Gb*2(6g@4MF5um`%I?dcra(x8Rqa7(po~P*4VcfBSeHg`S87%|Ny@6j zDduS|#1;*}fAYH>wvuY71-Zp&V&#t9^EBoj`-XouN3LzLDG2W^=wOh=%gH=`0=?QF zR+n@y9AC6*(ypGl5Fiq(`X;un8I|%B2;Nb7h$KTFo)=hPk z*_VN?)|kg`6)+sE4}|QMAEroMfTC=Rjcw-I#(bzkqJlE6Y+0Oh$_H&sOEWkQ_ZsT!zT0(KvVxys ze(WsRW@55=Ix&pJ<`=3BrbzLB%k*`q)x7@35hiTsARaYzUjH2OMKB7HJ(NzIy+ez+ z+`;><8|Np*e~Qlkjx#>*-z6RF+pzj0F?~?xoIZ*_J+NZDRyFp#OCY;k!2%m(c8_|0 znj^W3R1HqCRxZw0FuxX(XjKUZ5GJL18H3>J5%EfkewIqMUJN)I2UR-ro2+eXDH;@`D?R@iQCQbk?XY!My{JZQ+TTHu+ELcufs4b>W< z(K-<6Ds!QFrTtA@ytDqBGiaVDqGLjMeKlIXo_j}K*6sn6Iof4M&TY#CNg3Dj!Pmrh z@;o&$Kl!wNNlEM1QOR74k-b(;u&?-4)V%8r0}oFqP+>Ia!=H`VCkwvIHT*}Ymne!9U~yzFta3}fLHU_%(mbE8G|q1$$s3ZT99#Kj)S1Z) zWO(jeP(8B2&X`sa@(r=kH^@L7YoC*otSheRgwZ;PEferZJ`Yd-`3;kTZq0+DQVY4% z`7M(28=UP0n)W#80LN5^e_L&cx`S%8enn`ty9m=WJCF3fM_eB3+b{*UvrkEDlA<@J zpC{SvC0o8d9=2f!GCUUZ3Hl@>c-w;G-LZ)Bg0Q5HFJC%cc24BGoqzd8&8K9_h2=Ag ztVDwMM3?#YLl=J;n%|(|Fv;hRGO2p^Uoirka?MNTkA%P^(wQn=nVmng9@!NgGvX;- zPOUmxk7T^|R1O!)Jvv~H5e|VyTcgs_1}b}-`>x?GLFXl=0e9b~IqBlGR)LrK90zN33*;-s>cTOY!O}Q9pmkbZk_S!}LelKM}>KMnD39fOzW`@HK=Gn(g$7dG2 z8v1UFZua=EEQ{1#Z#_Xz{znV}Ra*LjO)js$hvnU?-={H&@4AQuT`!K{*rB6amGaSuXq<9@Je3NZ!whQw3mJupefh%JrKnN@-@xY8>bOO3Bu6~ zUwlhvw5GzhGO^l)9y9>KZ+Lc~7I`@%qgDAf@$pDK{5FJBefRbI)C~!&sZOEU!LBQS z?Oe#Sgy??Sq?doPEe@R6oSVMuzY?333*RG4ydDOP)+?>5tLhXZathP=EsSQcq6;`dnH+BNagPeg2Vg&2%{a~D; zYAW0^wGn0TvSyx51Yq=c^SXG2E$_?Jq|Yv~Df%dC_WY(y-tv2tKN1k92HPp}7@Fg% z4%34F5I$qM#hF$S?p1l}c$Md&|1J|u2c5Ep57FJMlzIbAKs_IV(H0b9Eu58rZzv0L zm^i+F7EXRXdi0T$y?Oo(GRGN0ZL zIf{37!uSA)q23~`;9YLbmPq3f*ZnHKhKqIapGy<1m*YQmva1Fib_wg57qqB3UU~F9 z7EhX+ThTH(3#|e{p)v7#XC`2#szf3DRprsbT9eqKY)C{WwyYK=l>&_|)D-mxdoUnN zMjQa~r4%1x^erABMF-4!Y_f6ZzD)bOx9$yE8-On^kH$Xes@K1%n?1~k{Z{KJGNLkf zb><|ExhSHO7^v3;u^-{izZ>I7z#k?GEtojp!!3Oaf%q|bWuR9ZfE_UO? zib&;?Jgk77K=)zp1yx<7Q{a@K&;+jK45D>{tmV#Qb(`$`kTy*FHBUdQU~+TQ%$ko+>7Kgl{h;OW^bQ%4+ks0``L;<$a0l1D-dY zs@Tj_v*7V+BENPku#RFA- z^L1`tp=!2Bm~Lc~?4RF=QkmImOd~q@f{$TMB0Pk_&lk|0-K^hyayFD&BsXm{;3~rP z2{Q$ptPpVidH>0wR`Q8l(^R5JJO#5gx5pX7;xR&!M_L`gT$3BW8M?j1bI5k4*S_-U zJ@->l*Dw(_v$Yx%MsR_h#M#AP0|hOY*pYX1e5SU@G|JJ{P7i-8QHNejXgq<@d9M2% zf)B55oqe|GfKnFuYXyxLeHbGl`ZSluqBDs4H4-K^nIUVW&NH_Zh$Edwi~**Qqh#3~ z$*IKm3jt2TgmNYZB1lcV_!CL2CZ77f@mUxB9|WOaZQZH-A7H-mMsEgjw!bUUTFw~j ze!qR~v1NVt?4ZckrY4=(4D2aQKK$KA1v750X`48>Cf>au6vMaWyPAknd7VuLV4J{C zHML^xHmdpl>K^E(NfWq?UtRd^H8j$`MV&>`Yg;0Z=|<|-dFvNsQ1nXEH>Z2!KTPHfAHHn zBWXiu;i!%Jl!J?y^znV~X3_!$7`%)%Ls@Jau3cKUR=MYHG!TM(&iuA*6;qc2IMFCTSjK)$Z9*on1(LYQ2_s0RGJ$^X}@6|{;|lvq0g?s-70MrUD`-D z%uMi*>*P(qT2A+UWv@?8+8AT;%V9bw5IXjjq)m4vKEECX$GzwN*f|pV9mKgqQrsiM@;N)5UKy zKKWB8lYOkwb{xiaGBRNPs$={y4;M-)kk_@@IOvWR%@y8`F=*F_g41MZ_7CWD>I+C- z9C&szPb7`3SM?TpQQs76uYE=z1|> zf-*te%}b>g{xSXAZmd_&&Ys(@=rCiJIADbMKW81t_l*gmm7hGrdQqPJW;4TgLl(GU zbU5sujejl!Pm!?%t_#SnYI~isJBtw}`vauKw?kv3IntE@H3EA;q7I!@xYe{^{67^Uj83y3B{?~~FE1V&GMUekWEtr*GvEokI@f3s zWqSq9Xed9>Ozm>vB)3H-r2{?%q1#;^3;bG5*mYs^#yDOT{Lg*eCCAd$XzMe@jK8ws zI!v%$1fr%dXRNGE=1Eam9M_KgCtt?fxtBLQ=;B%T)451GKz>qqVutM0+?;gsME>dp z`qL7Vfd@YfHH-B}8h+z9FlI&M#YH)orq8j5hK8na;%A6C1Mh7XJ4o)~Y-ErRP)5=S z+j0^j8gf-8TZ$cabpV%EiF_2(P^PhOs}DOs8_vPVaISUdUB;v98Gc;tBem zsX4vMZz)Ep&7oDIJ|@%u*gY{r82t?&>$H;+2)dm+Q?_O_JD73;ui3lBMk}k8 z9xGk9@Q`E#Kby1J0p@Ae_qe-yJD>Qd@GxP1R@S@+`GS(A^<>`n!XuCdJsqV0~Gevo!z??@R|K7fo5pL$tKG z`wr!tTd&BkC#Xtfptz1MhYG+6O^NP=o`h^tS5M-AZiPXQ75$|GCEraRz3*S{2`Vr` zOQZ{p592lZTaG7;2sQAIqH@iuKoNOpyXLM5GalEtU5B*p zY(Jh%>Jx4{eg%)!9~gZ{W09HciMWT4F}38gLmeW#IEtMmE6PLzWWngF4DYzp(?w@Y z&Uy5p`mbG0$9QgcEWxn4DVD(@OR%<0nD0xh^+_f~K{&gL!fV_k;cS6eMGdmOy-4QP z4j8en+AdD3;*4@t#CSLFs2QG5{UY_ga>Mdasj4)xgAjX^XFBNxB~Me!ut0;V_0W-+@+^$MH zAE*)+j0Q6JT+z6=KY}2wlOPmR|LO@+7VLxPQB`2V+us zGZk~Bpq{b~4^apEdgihhbvX=|{(0(s%&2Whtg2$)oiu&0d@Z;-Cu*%Vs}+zO?dc0WrEKkt{YMImy15(>xcB=Hw}W zX`>B~;3VdqKdUcZI^jpy_xDM|1c733iM`PbF79=Jfc$jeBpd%tI?PQ|DTIElI? z#F5D5^Xq293>JX}P)3-E%D-|TN-~T#Pg^f73;bq<3(N5-8 zyDADGzhI>~GcY00paL#Z5SYA+FAV-%b)+<=Eb9}~Buh|TVN_9)FyGDNXTh&i3@B>L zk8G6@mZuy{2N8-(KNBmV1Jy*#=gg1l^*}d$fH>kP=_`rO&KK2yY4wU}q0^y@2SPmP zjXz$3^I#;=uDQHpLi;YMORVw&TTo@*k`G(E4(~aKrJPeI@V`W!F>e7%5h`2FcbOx) z2?$GfDl$9265f>xyOoRKBT#MLxt&<2kWU#+Z)|BxNvlyHUv9}#IsQiM-0_4i4SZTE zg%)m^#=8Wn^{LKfz3!%nyyzCvyBI=>v+AIz5r?nQE`LX} z$(|b)R|GnE0AcJV3`c5k$GOz^>iRANwU4kGADC&;ch|YL{Vp9of?n@penLY2t=}}a3C>PcCB<8)S|E( zRR4EdGUh&Z*jpmJasC=nlHTpAG$x|Gnk)@0}ss}2Es2(o)1 za!q6NseEvC^N#|(E8jzAZGI{Gs|`W%u3iuu;??|=tz+-E2G5)rrr2565sE)x2&2?B zE4FzX%mNtG-Dt*HT5@SUuk?pU^fT>ZLo^0e$gQOIrp!|j$>BESG) z$AoLYbwpylcbut$7-9WxrE`-bBmCL}>Bk!70i;gP)HZ|K?lZz~Z9~B~KAZ9Z9UqCv zAWJHtbo!T0uu_nu5=1TiyYqwO)nsEXFWF4D`8RY|j~VG!6vWsW@SZOOvAQWq@tr_o z!1Z(8te7}w-`QwZWgPUiR~iNcOv192gvi5Oe@+Yh>zyer1EWRsZN$)MJ=t-L0{>(i z3>887IDY*iAlyGZMI9~XQHL|unmOmI!kYB}HpP#DjZF5t->j*H>A4d8lNil)bJQee z4E_F9;ygFcXe!DF&1+!@#KZp!9KL?;wb(pCL! zbkqw>Eo_N=Ci6b>{qPPN>5a>wj7=cW7vox6N>yC4Y88#dXPx@=(4jEi?q<}ZQF9_f zOX+?m=Y?;vUa#yfYRQU%*iK?^aK{m9o$klX%M1hiztuAyd=KV%gcb@R(UvzPeXNFk zUSuP`Fvv#2a;7X7gzo`1EruO+9+r*7IeObg-`C_O&ZvUqHRmVX=)ci(2#R1#dQQ$_ zZa_#LX;F%AbvtLoqb#_O1dihdm99@)CO5z zqb4lpUiq+g$*hRHK*Hy3j*#_;zl=v+MI^Oh9zd;9c$k}p=qV3J*;WKSDS!I#sjvzY zJ@N&mtSR{)1+Z-Ej%w>d)kf+ISXm||^sAn>j!GB)*I-MYD1>7KQ&cb$&_FjrQC-xp1?$}Y=C96xy{4RE%G;YPS;1Lv&m-ssii@q$jRb$+ z**hlMgkI+%-J@G-hX1<-x{Zi~x|+{`7N1DW*?;YG#>vFrc72PHbElc0Y}&?hJm18J zCyNbG_tb!_%O-Rk_eEg&2QKUcX5_3xHXB#Yhf=XALpckFxcR;Z+h z?%r$}3rC2UIbOMYLH`LFQB%8`n1mR$!ck>hUIz->(An|_R~fxnyH_I=1RQ{oB0AYg zBw54#DCXaEpYzSB!m8wtkG8Mdm70x-$lid%Mk{Aw*!q|0dcGaX{4F|JP^0dFJoFUL z;Xg#Nm3@HllV}uIF6=W>2Yd%Ix2{ytzH%1X)o`3VmjFy_06g$-?+US8`p)gB+>=3r z`9P=wJvO4&Ay1!!i?J9X{s>}=$bSfv_Zm}j&QwF39T?e*CD3+Km9d7O)H{Q`3qZxi z3w_@%_9Ur|d49qpi4BAesjtiur(e1G7rL5Md#U`JJqZ= zfo-y7$~^I^TO%9;bDv(i&8J@Nw_9;8PBq>!PMiEQ{HY1QYwPauwx;I-28H(`c&=0& zk_C)drt~F;*{4tHk6V^)a~=MM`7REDyUWZ&=|7Q~&0eEirNt_;p0>&FEr4Gpd}ycP9DHj6SUGTEh)=?cLVa(PF2xuT-bhEP%U~Im zggZ9e!^!3Ee*VLA70|n4%jmcL)I7?w5m#Ii11wDx53pQ+w=%n7pQ1jxTF-C%^@Fx0 z4>rM5xnUk81M)#WDtFF0DGPK(6e#G`&;_`@jFTzURK2NBP1H%a^hqGRb{MSzw5Z(9N^gvuJK~4Ul9=DCWyTW4E$zA-LIk~ol{;d< z=#&Vdz(q(!{25c*&Iw}^7J_MuF5%kcyjY&Iti=*Q(GSj}mS=bR7>ZC1#g)`s*Mc8EM_mC%_NOnu3Q}e1 zvR05;4C;7GInk@F9;#9Ugew*OO2z~V7_F6--CB@gKVRB-ash^1h{)n~QGP64eaK`)< zL^3~x%EN-X00iZyElbBbI2psN=vQ`*j=Eq!p_pfh? zw54(b=Z|m+6);Aed-iuWn#&V~QE+ zN7w%nwk|Gjwr#w8)j}jUlu42A0T?vGKqcpikC``xq5rm4^!g@shg;3G+m&=w4r!y+84fwmi~ctkx? z4%xbMlCQ#4d&gHz0PDhxW4-rpo{US=J>CxGN!RFQxr3j%EElyG$5Uq&V6BOcoTydp zW!Wnu zdo7jJHcA;ybvdu1d*m9dTXkvVDPX#IgxLCK%69cy7gEik4b0-*V(G`{WKsgI8HI!t zV-bz-NTUy115lB0^T-6>?eZM8|8VCOzAppd*<6|T;grriz-hum(=8|4h3(8|H@lT@ zBu}QMc@X#*{6J6jq{1k2=lkSVK8?TR+^%sSNi6X2BlBOKX+E$kSvhp&Bl3!llcJfG zt8Xf3)|2UC8%hGRzn%4ekpS;M3731q2o%-yG1*LI`e%claJN&iUi-gfpOi6!m%$c~ zbgoA@W16b&^zDyvQR~ZkCc|Gk?-|IBCzNOPRY8Ov9sBpwZl+j+CbgmT7fF$njTb(b z5`D>%QSt0SZBYD;>dHh$XE$J zMortlWU^r*gE+pP+^htXd!s|;BlyC;%~asJG~smL!%)vFgBXZvsEzt z0IuCL5pL=jP-R~{PlTHNzkTf|Ex*@4b3Oa|J>es52|zv|7TSrQG*8`?eR#AyaT10Z z3g^`6+rp}?r$Gml04?$?%7|e5+<&uB@+N|iG-A{Kb2u-Zeoty+`DNM^cv#h_FDYz+ z)5Er#Rw-0B9LUaxZybY@PeSUp)G<*3L+xsj&qO2z=aq;?e_i{dbTgNggazr5{TVzg zn@8~gt2CBhgE16@~S=2$xv!Ek!L``KLxaELn^|A(B0ORxKXyIH2}mx}gHW z$*d?03xSL{B8-v)CP5bP2zq=~<4`4r;H1Cbo_OZamf3d3OzNJ;#-A3D5UdCEFZ+L* zx-0HTU{VdfM~9Lq6WS=C^<3~afZ?=@DK4OTvc!n@}D1H7lvNmCWvN3i$!~3k^a4`$BhmjTz#o4a0a=2 zb5>Am_{u~pbTU~d3SUhe4Vx|XtSwSuYQ4T1_JGV05{}RJL=i0GMmGX(GO8oR*j6hY zhb?_?<{GZ`#lSyN%p)~#iiumlD?W4mlhM<-DDfc9i(7w9DhlR@91uvBxyi|J8DQz7 z$1X+9RlA*)xPad%_wX_FzGvP10=S(Bk8`KBXpP;*f|gin+l)_YYmeHU7JefGU@|RMfoiq)eh?gldG1Dkp-` z{eI9XYT8;4UBPq{P?;D)H($7I$d5@8a6}EGX*m3{8Y(y)>V9OZJ~HD2HjD|udz!hZ zbbG?jUnR*TGhaHgjHd61&PnQjlE3o+%au%cCj=7H%3JgK7%pmL{CZ*8xdP`N;-Q8!nV1_>z*99rdV$)zc#dC~BKR91s#JXg zPyd^kIn$&M%<|96uId4-BNXQ!@A!XQ!pmxcJLC9b-rbb(5aO^4T-lg^60od2g8JbPbxong|O#hWEiv!x$6&+~+eC{jvtBPPCCrCN zniDG|cNw_wrD06S0K;rT&3VDUMj5G4pBMhmN4DK<-|7NSGBFa<4DQ&h1HRbU*{8%0 z?BUokOJyPs*D;>MASJlH8092?8BfEr|C32jX5Td6EJF3e(J{UG4*q}38P}mgTz*FG zv%lt`C;xLf;scFd|Dm@!PqGUX+3f!}51Y?A=H$^4wccCe9SO_+A^6-wp1L|R-*5HW z9=`n!0xBmeZ*ynyL#6T>zO|IcZ!i$Dd8V@sS}SK}?9N9!f`XF14I$c2%ZZqHPd0}3jk%cjON%h_&hcp*5JPie%&Rb~Uk{KoXcB)1>Cfp;=g zIL}MRHRl=^#u*`sb&J8^VOH=(g_eh_*eT8m=-4W+7)f`r7yMQ!buEo!cw6A|;I_c! zdnS7J2CLc!+Cp24IJ+sH+j;9@_aMFFeVDuQ{S^?bIh)43iIsjGddPd$~5@r8A^ zAz3iL;XC;u*Rvn6PN0(dzN^bfqky!Dmx17<&*d^d)_~#7ohg@N>I?-7!{*Y+~|JFet%_H_WDm)BADxY2}{)x5**3uqaLr4WqQW2c@Z3c!v~ z#cl@Hkp+J5nJu+(Eo$ggu1V@vllvQe_qgX_Q(vWc63l_3n}C&|d2SI}fb3gXzXDEr zwHl}1sWMdM!gca`Ll(Fd-*zMQNDe9QZ|iiWK6Kq`6#0x+gTG*18L#m;2>0JVw@Yu; z*Co9oh$Q!dtw^j8?7w;D$>2H#SN+AlM;Y{@&kCl+YKh=_X(A?#EL93x6E%w9&80zC za-k#%V*)|w2JjrHfdn0UAJ2~b_VVN~vOIk@_4M`lxxv}U3=rPQ+x(HVYf0@ilT=ds zknk$V!BgZ|S82BX-eeIy5?rO8F0YDPM_T;e$$tft@)~wXh0F&!kvllL&G zwK=Rdz)r)gn$?EtoNV1?9Y+mG#S ztC07`?#Q2L{f4iCrmI<3N0V0Xx(g77U*-2uSr8Qy{=8&z%R<1Qma@9NbL43m`Qf}5 z3&2shh&0cJ3>nIm?CWuui$%`31B|NI2X)O^$prsxSulp?Zg>?jUdHapq4J&6eQpBk z=%31Y_Z@16dXQep8&Ir}LOP%}Q7346A$>FT;XeE8f7L(`a`Q>S(TVi^G*BMGwrkbB z2<+mvmxt{%x#O;jh$9`uxhUdMGZy+d28vNU*;g0N56ll}x(E_>XH3zb=X*MYWPe!J z5p**XgQm;(!vBY}v)~G5C>F#cj?(Pmr=?+1(zsB(u12Z-8cOe@;Uwu&*`#M);899&GX@ z#`%KOBe{Yv1~J#a(Of@cVuy4B2o|}_{`P?m;Er#RVhDwh&uyJ3GAVe|^}eeT-gaRC zty#PkDOo?Co%1^pBO-Hq$_#()m_Q9>(*Hr18Eh{76-5nOAR^zt$gdVCFXM|Gf80v1u$pCK3dR4*C5G{&gR4Ov`R!g( z*h&cKSu7p|VP=WleW>h_>2Z}7k2=~|S*c}i{9y@&*p3E52g0Gl-d2_B2R^qiwpaPN zJU3+teZJ#TqER|N#w-QS;`fjj9vVBHgh6Ky)$4x@ruf+UJsKK1BBkhupLZU!#KGp2>+h(h)S`r6(+vvlFV1Il9VH_Bh|G7@VCh_!b!ms}e!JB*%-W3U?2DHqN^>Lproo{j5WNwM{)L%qGoH*$cvik!bC(m-)1qDMdIvx!oZtusDt~{tO5Im)rCuORO zZ^4!{ytvKvymzU+GP+0dZaAnm5(LFaY7Y_7)xyQMRfuITn^kuaHPlITa60|TL>3y9 z4B(9?V{=`NJb2Th%|OfZ8QiQ$Ca?!B&tx(eR9$BH!saEp z^N@-e9OnO03wUy>()*!LIxL z|5IlCzxfRHb|-aTIh}I>$OOhQ3!HKxgvV)(PD#1Ytk;yNcLsnLW|fgI+E9h2G_)&4 z6;=lGWs<4x5a+yulS^of;~KQWs1Xk;^kXIu+LswVDiJ`7@`o>Y&6RTUdd#L;x`O6= zftSo$tp2_PCgm<91PYiJEhNh6k8K;ctZ_*T`QTkLKZo6AYv@4=u)GVyAc|8j;lH@JiskxqJXA=<$|P!&GiJPd4M1xQore`$=fHt=>Z ztB8KlUwr*zp!oV*bA^HLOFA?48Bm4tB6le5w~6Lgzi#5~Qr9dcG|T6sVv^IP{wNl$ z0>O2#ex|NF}ry|}T1Fr(CrnA4-y3fLXm?Psn(%Wlq zagz5>3Fdj&JFP;6K$y;dM{0VOe!X>jOnDwAxP?1ieVsK`+ZyXcs+3R{bVa>i<+|+L8V|=$_3Bn^XLfy1;WqQMB4e z5JR$b4|}XX=eC=rYV-}*>fvLL&?(+~*Dbu?@8FBblRH#tPAUBMaQr!mu&)+$>vG(a z>RLamgieR!!(9`9m>R`7Rn+V^h&<2lgF*$f^}(EFn8;de?X($Y8x8F7ar&_nET^G= zlP3aFh}*zVGBYIw_SDx4;aDS2Q$~&aM2F&yJZqWbvPGkdqG<3?T1mm~{6T`UN{sr- zMoh66IaNd+*Fil{GIZHBHy=D(Pq~g%Qk>Ps3ZrpT(NkH@&RmPXvHqUIF^PG0_z1>m zB}}TBKX?mz2&kFXWe7S_D}8;%9;ACGtwpkeO99}YJl9y{U@c@kq$(n~p0ccDn#vvg zs0rP*A3HGE27#)MKXxAmWZd~EqRaPty$cBy99*g#!zzlX$r$5sTCw;^j zjL0+nB>8*Pa3O_D;;3jB-<;|?iDCgzdeZR*@8?2N^GR3gabpYfHN;QJ!bPAtKBBf< z21^h~>X!`t;&z24`3X;~=fXl4C)F7i`>b@1+gqGf_a_8c3-{*3dX9A&`Wi6b&=<#^cSiUW)We z0?OP)2BOMRG0ZC0TzqR2g#Oh1U9GPHl$mwhlQ}Z$;O%}QeqM_|O_|}NgX9Z|*g|-w z)g>1{WN;PyfldksU=5IR)As(6Z6?`jmY)>Y*-cj?6L05vJ!~j_d2Lk&$_nYW2Whec zJL;g0SAvY0KB`zHVBEI`vWdL4bINIfLo5BNdCW2EV$9$%Il;mBD?O2|?nwDsD5xPm zs{nlf>q`%LuAqpmSg8|#WM3;;F4}mT#~lWhMlkh?^n3|0w`9|CzojI+n4-dcb`n55 z(r4Dp(j6U&Hxye}Ka&A9f2~~`F8*XkbPiFAUu;`ViND<_ByMe~#6l5a*|~rz4?Aqg zaQX_7e(vwYQF_xgzP2g9o04U_21hFl`H_J{&{%y@q3bhCmWk*{oT(4Xw+jga`KO~m z^b;koR=9s0>!I$-6@&W>lhZ1yCCuqA0fnVU!xhEV|?8T18Tw6Wb zDUyNAk}i8|GA{wwln&;PD=JXrnG&8p*uS{O-?LKlzqpM5h zBXG~9vs@5S6v41=I@UsxK38x$(XFsV)U{TZuM*U^$9CEHq$uCUMo@HsLp9cUZyWAx z7UHNh@`;YdD9{(#csW6)C}_4O2{a@W7MfDeTIagQ_Tx+blpX%NXR6Xf0!VrVpcH)k zK`-%=bmbpAJt$o7aBkGPSYm~!@8Y9jLoCC51%I52JLqv1!vt{(jJaVXcV;^$rct`Xsqy{Qx{2z$ z16v4v@MP0A5&r8uV?BDHZbn{o&6(bI0j4q+sS&OYuNWrOMwOoN8)e-;q1&D#_P3LzL;% z_SSl5(5H>Yqb(;1v7;w=xhdWq&_z#f&zDu6gR7O~#6DOL=HpKM%$eplS#$=Np^Tr< zd)@EVKi5`36kk`-X%|=n!Wt`|-cLplRdFF%t(7RAV>NdE^B!_9;)|+%t_YN%!A-;b znR2o5?@MqBgzKcO(37&;AFtv}E7hC!iq16<*3N;bHPFd&P%uLBH$@PZB@;b|VC5m& zFQ*dYe&rHOJ?P1M*=jDvP6+p#3JnkVy(=DNUWTT_usr|lJ9iHldA=nQ^K(E;Hbmqk z{&KVV)OMZ_4Ux3%?Itny9!?UxFL0T}L|1i=ZTU#Xh^gB=It$b9sgM)8e_Q&si+2m4 z$l8U_{@&b?nM!!h;Fu7aBo&Y>(sN6ZvDbm%dg}Sd5z?ihV6gU%@-(@?%X&9|kgCa< zSAamdi;xnAgbfAX)hMc0(T-i}i`#0yg$dveTlO6GSkjy*^PJTym3x7;&10+bNUjxJ zeGNJ+{_rj{hnuRdjSbeq1f$xv1(*5k4SRQHQh6K#|g+j&qUGEW!)`2ou`Z0rjYld zjzDT(A%$Qhrof*dz5}6*HDC2Dx)ZSr1VkFW&5DSvoEq9quHv+Z#bRk9qa9V59)x9pz%v@@`?f@ZqkBL31Vwd@!IO<2`1@6&Rkg5~BR&Ugb8wQ#L@@3S}qgtvxAp*p$%I&m$ z?r-6Q2j7*s^exK0X9i4fNdfU@K8D3Y3s3vqRO6p$G4`!J5jJXGU=cqN2Y}t}!lMPT z{b9y22%B(18|1@RaX7GZ!rc?Ft-u8{3P$JMb^i>Ee$ALkXLCWoCn$%9r@u+dxTFRAqP|fyxuHzu~6?}UR(1I~$ zNiJ1|Wx;RM6B?-z8klC7<$f5JI5N`DvNL`M9_P$SVRzfAsVIi89mY~U)SClxM)j2c zkeY#5>(ww&^RM^1A`B~HQ|7TdSh}RR%T`|h&w^*=<#kfzbnF2rI3@aJyU<1(Q4IH7 zj4{R09+h5|&M$Wk#c*Zl8P~v;y3-8q@kVzrl7fNRxPDtNvM=pO>}N!ke7XBIM;(BS zpa`kl(C*`Jv{b{6K4ae``)AptwT=9j;#2}wHvw?$%9r>1h~8GgD28Qn7C!dr)0>`h zT~?Xm*zdwd(F~x}T(usUa{6yNYQ3}qTk7kMAG z>|_~=KZCgm;)(BX<~Y272gGeSY0%=L^s_ax_E5<)>!TL+t+U|eU^Q78>5oO2{_VNp zzo5|WGXE{a06n-+_ZN)Xr(us$$EC;Sq66}Gk@s*fb^q>`m9BpJ(UJ&uym}S(Ldyct z7Ec#+=d$}g>hy@FaV|)PdA)igZb+u1S*yY8ivtyj)H3|{|v>;Lz z(i+tf?H&z{`Pq9e=Vj>TEPhh5FV@otJM0Pvx^A9KoaVJI>6_Z;CTE4}6Rjoe{K10) zuxp<1;hTd1$#QuA?6{t&$sq6LMx79vX%1l`5KXorRW=%}5ZVj~q&wbYXq+x9D&9t^ z;xR0AXfa@X=y>5|frGR^Ftv@&4LUkgyQ4;8W}9zTd`$|drh(8#WU3{PX6|!;y<@6` z72f>ApaWmdI7;C~e=_q=1^d6>|9hUX@lW|z(rLV}Y61)QW!$0|YR@klk)ROit7wSG zV)!dK#hy~?BZ90O@@|1X7}qT)Z$dp(acJdx$`JIZZHfh!NI6RWuzG=)6^A;yjn0sG z+>zu}g35`x(P(<}N2~z8PkUnO2lAQ35<6CDwVw2LI%^{8lCMfALQWxJ94Da3(WG1J zk-;2G@-XX~l+efU5u21%9SOc}g%;T)sRgmBKM>3Q%=jG3_b;=yR6x3W>->~CwSlv>;UP19y;q$z{3plupr=Vu?Bv>!ufB(VN5 zIVZ}p{WaPN3pJJ7`t?VMGR{ofb9pxJ*bm|{79d8(WwmQvF|poK7ko6{T_Z#GCK>WI zC$9X<)|Yf0Ou$qb-K%vbuMRWrOYju#pZ)e8dR`byVU{%HGeRaI9yqc~_(PmD=@lC+ zOO_vbADQXOc7Fx` zEBK+}tYUK`BMwKh_F!-8w8CSozN&6nzmM#>);K-l44zr<)lSjF|G)z^xBXN#>FCSu zhy>WhSWLn2U$o)B!HM9vpPFUzmn07C`LmIT;_J+9k2)T*B@>-!4Agep08#*r$Q)*W z<~0Ln&j4?BrFn?ilbOS!C(}~$IJ&11n7IdSxwSGt(l8W29U8pii|DT6g=toM(zFvh zn9n|ff&=qA7OIcWD8__+ApsRy*G5rW#C7zQWqC}JTpgWYGhm#5dB2@>L*Xb?=Tv^^ zZmaqMR97$L$$ zr#qb&GPKG>2oJj7wz%QQS!kG$d3P@V5`#<^&%^M;$I>AP$fMkEKR~`HKV@l!AvJUf z*EjX4Ff3nc>_4!2OVns3!9h;y2%vj8Wp8h@F6b&JJq~rc(>cd81H#(OL2%5BKjZ+;@}sEp2EUQ!ia-m++;*l<2C}_mb$A)p`ct(PV`T_=X#~S_khecuhw}|4utpQCHf1 zyWr-gV$A{JvshA;302KDbI=p z9QP&a8;(d^th{YHWeG1;Sz)ky(#W|zQ~CCCB1(j;Er9x}`*90pNT2nS_n(UMAp&1GfOnpHrD>3f5i#|` z)b|Y4^)7CU9E3zJ`1sZEixpfu@c!iYUTfw#6f8=;JMX}8PX>$`q_Cc#;j88&!mv;i zsH?GqEuZ3ZtyxZ(IOAq2rHog%|lcRLUu5*?vpP&!%lnUIQ8owFHYW7a`3|->BD{$^1Im4 z*Pvi&(uZky!!~YAwH{UgO|k(tDo*)wbvJf{Mfo!%SG^ZJoi6x*pIs@Q<{L-)vQ z>0eUbtAitiL!c6YZW~6fnOWkALzaVV4?o6b67WX{;*2$u)ctjq0FZYgX_rgGzD8m8 zxV68(Oh{&SJOodq$$3s&JU6My| zxzk%lqemn!O!;B}p6*XwW(AO6h#KzBat{~>HqwQ52Vxx?;zUF>%uD5SOoRA-1v2_&6CrcV^5a#U`f3tR_fc-GEzQnb>NDAlmdV+&HRO zFH8#1hO|sufZvJ4htc;d5r2FMuX4?f)tn+OukM4oCBg6L{ALN-2 z3;NxFK^k5(4L6mLHWVOa_567HIj;AsyPov?A8asWP5E9#$QB=^Q$}b`3I!0x?V}5% z=WRN|5ViSXeN;~-v4%2LmkGB2@m1DXJQ-ZtASw7pAkojGHMe2zy@rTu?#EeJy}@g} zmgFA@Qh>;@lj(s-r2xfPz6Wz26P8JHG?&;sl$S(!l zV3u7m5B*0>wRRCx{}8%OO{!TYj$=dg#{8&sLUfQLxM#|9xH+kNzx@Hz*S6xu$1=qI zz38On^N%uo1k~?IAn+*kL?;qcs7rXQl4t9yWTeXtlzUIijh!Q1vi>G6=sZJb)Kqhl zk$B~hJ>k*wDM-DUYq&kmS?tit#qbOP0$EcC^s%dIVwuB39Ryx9+P`_!3yZuK z*OFAZQxj7?dHsZq5L5E*#fWAp8us%>&p!^ZqcRxY??-Zed_mwoYYArdob2pJn+OSo z@+5?fEgWLt^Cnv!GoV>Sj&72Oh$%t*#+RrwD&kt%){IeDp{EB({aRsh3(v-9kK+?7 z^QhCh{Q`i!$5P~>tnP~e=`_%&`5^6)8+D9lsw3l|Z2tRnwKR1Km1->Q_=VubkP@7c zOz!mhP7m+-w|z^x|ITD)TSAF$o#V{!%irF|n*|os;k9yDlS`s%{evBwAl?>D$J9(( z>?R@hF-j&U2mv*KRMLdwWS~r3w0P}jy?ZQQD3-?-^>N5R80aWX0*=}Fmf{;-pQ`dE z*bXDJ=GyigayxNj1(gxHCn>sCfwc#I4uZ)`I0ZDJOEm>6Z8j%Ma)XiK@dekXw&$H! z(5K!nivrx8%mw_X}Wmrvi}Y8WB^fJA^fdpI9YMsG>6ZnGub&kT^Pv zR0#h40Du=!srmnWHyPv}ARaDRJ}_+Tn810Lz!{+dHD2LHLa_A8`& zVTWo!t<_u1r;jOjX})J{0Asy~2^FS`Jm^rBDErwVcQL!hAY%XsDd>d#QISOj8P018^ zNZvhM9vpnoA9%YX+;x^!+ZV9|g<0hpMM8((3h-keWYE>rrjhAWV9*RI7?| zrZQOVL7ppNR4&dyY)CP;R79mI;O8)$2UnGvg}6NT6B5pWO(<7Xv;P5t;*+yr$gpru6zN4$i+>25J);um`bea#fUI03LCd!_GlUx#i4{o{}vXfjJ9~$&@WSOmXsdffXn=2hf!Q2 zmD+B{Rp1l>iWSzpW82HLIB-0QopH0QY#pm%7DE27Ipf8*Yo_Wgc=D1vbomMSM~v^%i9EF87|3*6T9Qy+ zkZZIK40V!guE-ys{`taXc-LuH!k+zO7(_E1`_5D@jjJN+3a=gORPFq|9Y&~SCZfzZ zw6YOR0c4@oQ+N?(%UEXjUZeeiA)r!g?++xn4fQvv$?!6th~6j}TL{@&=S?cQZ_ zpc=W8ZkZuz@*mK-9}eiK&THPpLEh14*v zDr5SF_9tlpYV7?VeY7Gc2-zr@I}YVrb{rWD7CupRVGR}0)?rmI zUK5UmfOZuzDl8C9rxCx7TP=p`_^DvTo#JvZhluN2-eomOIL^(ku?04CYhk2+{%Zhf z{ez{fAcIQ?TP>?#{Be?A;{`cjNI_QJoUs4=t4XvzHq{{Wg{vXqpz8}f3Qbky;hKHu zPoU}77i6R1DZqT3e}Fqutoa*CV254~i+xMU1{G!a4=7})>HmbD1{G_RYijP8xIomr zV#Euf@h9YbVWVa$0B~zlh;a*0I!%5S>Ipv=hAb4$pduZ|>nP>stX^RL@`8_g@-SL{|7L^Is1XzmXAAWPp4KpESv+qQ3(TocAGmus z{Q{KLC`hJ;Xg}HvYYbu&+T2|v!q(T%C+bNOit`Em<@7dJxo zgy)P=vi@9u*MPp$r6a2m^4%%JIhAZR^!FaPz{Avs3ZBv(hTPz84x?qkN@i5*U-jJI z`3lzvAA<#2tGdb3izyV)b&~hbvg-Zcr{F2Ws+PbC2Xbj>{|W#Ht5K?1IkmqjTKEUi z8b-QLvwi=(m8C>Vl#C2VJzjy2FqH59$Xhn>eTfh0+cY|`U(iE-wfZuUh+?uT1Sthd zE1IbW&Ko|gY){Y)vupZC@iua$i8k$fVnVcWjJp9<2{+RV zMID4vT7Nd3pXAEJ&Hdi*F_-#|xM@M;_tS{|!PTb=tWiKTyF}Xm=TmSPOBzX7#?Q}V zTNL)o7mZ3ND=^t`{HEZMoA2wA>~zu?JP{?IgKKs1t38e-hwAf)O1e(R)O!F0Nvs12 za>VC3X({f!jcri`8-B+7BlrlBC-lQ%LMqIf8`pTS02TaFt&;4}m8U zNL_WD3(Tve*U5OtAckWE%U*mItNYxJXTq&XNHev36^e#*f!0^z<`9@V1d&(RKNp7) zJ0v5XDeM~%F2;<`=0#c#0DWP(=wr`r*XZLQN?RaB=IWx!NDmI+npLy0tCoaUk)VwVuRqr+F& zQq(+ebXMGp5dSTd<7|NO=F|6YZv%b4=<~3gV;khfD%f?X7tvJxHeZPC$rf2?8D{Y#DsY0`hYe;e zVwb;WG1!QaZo>Oj$-sAKX;H*x21y8&kQ!Lu z!)~zL0}yKKfYY&2mN=`DcV<2Xx9(lXxU%^DLf>7{8snWF_*&Ou>{Q3s@jJWhO6j!i z@38~cHb&$|7!bYvw;>@Gu+{Amk|>BvpT;&==p!lq)~9dF{E$#n!bjj9-20IY@SMaY z${x9ttd!9n`+b*o{^|PBb~JVzDZJUAQ=&v1h(h9FlJB}_tr?~L-ioR*%s8eiDaVu8 zUK%<-g{PJKzsDPJ^*ANia$^ zVpMU4gol_nB3TwQLgf-h>0}62dW-9|+?G%fFivqp(y|hHqC*vheF|ju^OYqd9z{Wx zeS$q)>v%%(Q45}udzP&I@jp0>9@;dPP}`p2ALsP0)Q$lHP&AzB(WuPh{lHGKCU|SX zxe7`6Wi&5A$qDGl_gd0M-$}Y`*pp98(1b4IGK~^dm;C(A+v}2pJr1@xt}uI(J{R`w zM!A%%k&|DDgj__K_{@*jJ5DI%aRS!$721F5I!s#r9*&`&GIVZH^M&q_=nLU{y)ki2Kpy(<(+6Ve z`L@3v4Wjg961MJY&5wRBr2?in$AqX9fS+0DohzB&XT0hAcc-xrs|D(Xn-XZ9ADM*C zRWu(20RD(C55MRZ*F*dxQTfhJhsc*C)3`hnv-d6x$SBLxJM!f zAOC7)$g|juJ>>huwX`4@mC|ZiLTnHSgZeWMd`pb2tIK+SI$!{wbuYx>yJttPgJ zI-KkPmDqe}?ce10&e88X2;xYAAF?jazn{b(iO{L8*8Y;_F~YJkyOg*N`$uT87{^)= zL2B}&L6f*GqP++)A~3 z95mNZ$$iKsvcCj)$ZHR{DyzhPRjP+Mu+|Kzedb=05e>9urGKC_HHrl@Iv3f49z#br z(k!#b>4BaiBAM(|uqsOa8tD0}o2kH6nW-GVBqYh2KIwvn85O_3Ei|!K>m56Sd)G&G zEPlYrT&1}8C<>9*R|4%CJ5n0l-652GuJ>)eJKVY#Ed?BFS-d~J+21GXEyX?$H_OAO zN#{;jQ@vh4i+951TL+{e6wOhhMgYtH=NgX2E}YJ37f;3$`N*qz@+MKg#fH0AUKLEKBXOUVeL0;fmQ;;EgjV3kydIu+4Dy2R*@u{$~E)LH44<7 z?|&Fn+j+g`Mok}|D$!F7EH8rw->eUMG=iZBt8_-inig zxJ1QT)zP}<8#4hQU`6fX*z#$EI+{`XRCMW+0wuY_fA%m8F<+;=Ww+2!Yg7o8|>HMT>G6d5=@amuRiOyscG#iga(lJCl)}z7bOR($=n|-__A$t%ojX@O8H1wIhNA*d(@KVg6tT_DJQ1;t4dQ zPqW~Qw09&FE>N-f3_!^R)yOZoK)umMK?{C6iIEz0n&dAt`1y_{ik8TaaiGN_zp|UG zZss0_2+6RsI^g@BR)tO~X4ImY@!#uxCLqB}KlggOnrRb?yz5ct>M>Y#kf zr4}_k7#Mj~8&)#r!>Wh-WQ?r9+loahr?!J$iFG6)2Iv!of} z&0SnU6`WcW`lD3QtZHikp6H&3;;Gve*#&&`6P?=PVk-5Qk?j;2ev9tryEvWzZst%} z;4;f@C7EoODZ#h65!2zYFi^5vvez+D289LW|4EBdlvtvxQ5R7f+wodCib}mG`pcn< z3%s)ZH=#hI?`(qL3)3{SjMu5db3Xhqlg~F@fw2ViL^#n8{Ix*gbL#l;&n|mKkwjvy z)<7fabfF0!$JMYf_HWszes$p6x-fZoV(DXvWpep}Od8w}*C%eo?C6BWR3ieaN5F2Wk5TYz+}sMWQEjts5g z*ve~-N1>*2hysj-)|b+8>o-KOYF)3LJ>yIZB2biW?~gjLEHKIt3V>9(&nCe#R75v? zxm9zb6$d{LRZ)I{ThjWb>mKvnU%;*YzBzZ384&UzC`)1YyR|dm1yq~!jMM4lN>Cka z014YCWbwt$1VoG^H#IoB7`w{U<+FCGqk+^QPi7k<5aCCW|M#!wPtBy%O!Dwy41YR& zj6A6w#&`O;0)aQAz-3NWRJ%iqGM25Of;SW?&an9?CH+&I!12v1Jbn9Pi>mXyKjXl2 z+{kVGQD=EsqTQ;$^C)|3Kpif7xUTp@;Xmcs*kU2-;2F0aYev&C>VM4{vcGt=tGsnU zIKi*5vPf!ye!)^}bQhvt?md!h+XhvD_p?ig7wgv&0~1W{n66_wwwEOKA6!L+s0-yU zmeTN`ltukdIw7Tqu0(%yE|c5}uei_{>Ydx~5XG!uX>Lgi=lc1 zrp(!r;P>Fm9&*Rfs>B&~S3G+;_&T?)o^Vff*EpycB^?9lbO!jl(hCU22fx>B1WCYl z4XzROC{&Fnz(5k`)U!1}%Yce~440QEshLA{i{;xpD59egOITSiKRG`YHn4%l4&tch zqr4`c2^k$1IuisIrbYKFRei5;Z8hl6#FT@c0r|3JlB}|tEUH@mRVE#xLZ<{Wir&U}oOT4jy9+?NC2s zT(q><64E1aDE|<<0972f2zTBaXx-yjpK_%o8enzDyqZ88GIdp;sPs$VtU4C0jB6@@ zD!tif_RAR!yHtTgMcd}wvGek}p3*Fv$l%k%U*HxnmhCiGo_x}ZM>-XTB<`luGj>5k z$w+aOqcqfqbx;HE9g|t)^)u}(Ka~~xIy;SaENhjRUVjLCc-$ZD^mhYhe_YOPVkVv1 z8gMzs{wbZal!vaI)v^B8_QsLF1f~KR&WpLsd2tG{gcn9^D?Xw3iK7Q55#hf{-)G&{H#PIJ>8+G)$fD6lAL4Fib9MRO{r#$mYSWx->mJ{0*+vVXoSAp6Ra z&{raFf(ml7@oMxPI9u#t_Tq*;R5E`%4Qg)jbn6bB3m8Gup8z66o`)|VvX*I&XIAhQ zMLlL|!;4bZS>@=RGB@och5>`V@cAE9yaVx~KAqev_?CXmz|HVn5f6RqzL(U$QuuCUN}kC!k@#RR|k=*Oo<{k;YjK$<`m+X@|mk zjPcgiMhS7yMQ-SrcN&OLGfl#Ch6&uZD1NMCpYJW7j^e~u@ zkVJH$nAO-@!K)vT2INrjmY(rdU?nCuj5=!D{? zAOZ61;)?+JEdOP+gxB@#3IF<=9IdUNYN{T6#iyx0@T>+MTw@*?A}b~*dy{nqFo zIlsq%L3(}8Ukj)=fMc#-itL8FJHa!bv%S$}E|@%sw97Kql{%>4$Ihw%$h+V;N{iFy ztP)`j%d?E$?@ZN35bU1(6)D8t>1vJ+YJ^fNg800@H)@-O9)G73ChHZto4BH+NHc0t;dt-&0*$#c7w z*%1!}iJArZ0F)P**Iy>!gwpB)zqWRHTM5s2UNY#nP~m)sQ;4g4EnTsKYv=wmfp$W8}(P>Mr6DJ$%Tg$4e}FEwNLwJ(RVP%-fr z;;G_ou{j|6zTVaDkm?C~Go5x?uKpBupn%Q`wXN?i`0n~$Rx+R+g3!O6x?XZZlF{4$ zl1<4&dGMYcyD_?X)x_MGItzq*%}NBt-S|8$_%e<>$}&6EyL?(aVtOvU4%%Ec7y7TOx~?Dpv-GmkW~NH|11ZLcTx>p_1%;^^(QW$1dFEw%BYiPw*5Jwe)e z{M2`Iq#TiJsK9Am%9-)a^yVy{F)6n~Sj=0uiZj>QoUc;q!Cm>Yf>wCQpD{-HDJhR4 z0V0BG6|^n0g54Z`8Mm|OQ;lliv$1cfvx^~L zDt9}CDA{3?Bt(pBVb^zW)6u zJFyQwC9pY+S^7_ec_36wFP>GpFnK3><4VP%3QwPS0jjfA^C<2{pyG0Bm%!ho%(-!c?2pXCI4FU^6|@d%c< ztkK`l5X+lMZ-hn)pxAB~C=z<>)@Dl{m5x!}@7P#xdvx?m17Mq0GqGJ5%Foi* zpJ*WQC4Z2>TYdG<{S^8;Q!RFKvP7SwvtD+jDbs>+E}(QtC3i5U)h%3#AML;9j7xs| zh>U!?0@sH~<4m!Jg3!#H;4-suMO7tK^T$zIfH}MZt+HoH|FT#4RKP3wR_W{$ZkjBs znvC|F(|0EqEOWs*3F>v0p|H8IJEA+M*Tznh8xW_J3%=VC!m!W-tKqXHis%<)TQg-2 zqht`U!I5>V*zCiEQ<96>6~Z3i%T*^qSU%JcW!2N+dd|Z4o5qU{NoiFa&uncGL4z^S z@G5;9q5b7xv_sBDjh&|*pL>{)?(RfpVZHm4ZihUOO1O4*w`0V3l04RX^cxt}d|?aE zslux*QB0?>ycz^IT|W8;{&mxR@VS^-@-rY(!F)N zTn`gQ^DqhmxAWD3{`zF+XOGPuwll!Kn_1ll4#_f^%3vqv@_i(%L3aLm#9Zy|aU}eN zN(Z>ZYr!=8+2)06J(uG%M28}y?7FoZLpH%L&`;i4KLz^bRs0#3OnV)+rD0}ws`_Ew zJy-?(*mMR{yvGEd9l#4DO^JEq>pT>?ci$1|ZOmX=n!s|N@Gg(BrtPSMHi(l{ysa49 z2xL*Zz{B!g68$FnW3IiuYKAf5T*n7m6nv)dh&TNGbEbc-iSK^HgWy4i#8PKWA&do^ zR$Tt~EQoR-rtDMaJu{RY_E;nx?%1cF<;UEJkCmh;{f<$*3g7}5c6?GDi)R#14A_zy zHd?jB-@*n%89C}=_LTlD`zm$GMp2Q2%t+!ycD66J9B`J7Td<2ieW=5x5v@ra1*faCM75gV4E_edOPaz7LmCVD-l%o0HKf%Q0a$-tz6f>Cyw1NUqL9w3o{jlfR!I|l4S#ONmSR0(hR?k}=jB$qrL)>& zPQzM*Ll3NQ_B{nIT9Si~^nB&(B@QDf)dY}|*_~n2WMxU=#v$UTmL5P5AxPDONr=aO z-gNq>c;y(Hp9EF9n3IDQnaamV{`QZuF*y zc`C8@@+=K6AB~57!#Kh3kqbcA%n<)UH=ctG)d+PDVE_ReX?nAtFylLh5eG1i;?+8; zw28Nbp|S3W?4RmbeSWwZIAso44_EAR&465| zXLT9NXiyH({$H;>-t8^$+U5qPfu|$j*7SKqYi6e?rtM{Hf*K%3Au~aji-)78!Mc4LxacPXb1M8ed zOBTX9r@FY457(Wn-#?lN!2b)}f(pXIO|edD1l#YWCBUFKStI(3XY}4oPAKhj!&<9j zWiFOU_k(L1AS`5&VcR86jFvB!UhP;(sB9n**9~=ny?qel{D;*KXcf;WH@UjlW%5Ny zGF{2>&i+%h^b{CQ$7u$(q^%?%-B8qYd`-^Fo~4E+LQ&J;0o$+Om6j6XV-Q~ak4EX>m76J{E*|USaHx9_9&(cs^7T9Tlh|xu7QyknqLX9uL5eyKGO*GL zRV1}{pXE~}p64YH=p$(L>s>E)UdrDR{tw^LZM8M-UUtA@KkFmT$=_FlQ|Cb3D% z|7KE3L_++!2s3s&I>ZL);mZ>~?79?uBtFID}0XDs}?Jt-3_kp+tMbQ8!w~ig} z^B&}j`4%lUv)!qyzX0=Mkfoka?aw#gU1gBer|+T>DQsUIh6T4u{;Zp)u(3S0CS7E$ zi2HX2zZoD3Y36HAFLJfUohs<^XfE zBbY~FP7lKF`U^eEUtXo;0X#F7dyLMc5mS(OYmqs2n7q2km{790nqwy@n;xrefvNl6 z@p#$ubKfJP`f;O8DVdBjbk)N50PgXYSYkDPP=1rTQ|-zvPKZ@g&HtZpP$s0Tud(rJ zuX=k`A)KuvNGPuEU%d+TFc*TvbZ2`w>HJ#RwLSjJz{vg8KIm!!Z1rVglQ$Lbyx~0v z>YAo{T&+&@2x#H=3fpBlPSPs@^AV%V(6LzpGZ(glreA6NN)b_uVX&)W8xun@0R$L; zjOOf*xn)4FN;c}7bLHXW$_Jni z#t2lMla)DE>*8I|q94Pcnd@T0Wai(w{%$kW!8g}xLY;)$cDkEJi=UCQxP?1iP^x4< z#FNCV^uLsS`6{xl^W#>d*KnW2;+V)vAo=eV4^jH>VE1(7-^2mRQ7`{Sm3htTWp+qV z|KFUEPxiVXj>fMB?IJ`vNo0H5xwj`5wPDuZbQeJ7`aaKiP+<&P-wW%enC6ohW>Rlt z{c{FsBB{V$l7P0yQOE)0wgoC3laDCT-JPwSJ=<}gYTGx->YB3HZhG)3(ddDJ63<{j z-D@<~6v~hdwcM6Kx#*7(MRk^Vv!Zj^_x%!X>sN|85CI!>U327RxI}lM4nq|=J87yZ zk>g43y9vf;?}RU+km+vXzTXjgX8C=gjMxcb6)@jeC@;}VVFto*BGI9v<3~7#c4;Z> zTVhE8Fjn%vPHd>L_`kLe^8taj$jNgP>c1aHlNhLi08d7 zZ_oDknuRzG1>Q`Fr2f)Z*FI#?QVM`8eA|2^82#E?x?AyMV++GLC{$a6X-Y?0>pCYy z%s%t0J>ul2PZN1_a#4e8rt#JQzwp0|6Kj3wC#>pVXzkZ(HLjg6LqPJdVe)9wi7~@_ z=b?`4pS1-2?6>iIC*yjF8z|@R7mySGWZ@oBps~XRL0PQF<*#;+!3J-Gg*kALYZu4h0ui5}uVloq=dS^rU^7c^zBl^mOpBxGzH`x{cgy4d+=HA~VT zuHfIQo`|Ot9~%oAn@*Op)SE3%238#4G}JQpkT)G5coQ=JUH>BC`gZ!FG@~(HZaURC zJ6Q*2AQygwCC&QaylyBaE3S5?g6hkQr`@29)Adr~vQ!YY-c{A>lsQE5nZlx$LW?xJ zaukqXn}{BfMWPvpH-o9V(b)oo&%=!5wtp`^5aK&8k2-^&s_k3gBT_fq29Di%mh2$BkJ+o*8wuR#kgTR~4r! zZ|jIl0krDL6}S`}Hp{F}#D5UXAZN0-!#Ifcj0^D=m4v^f2Gfjw=o$s|9xIEr=(OFs z_yWcMY&)OYtQesGJE2CH0JV0kD-bpmx2Y-92F8d-SMeW&KFVUoZ0nqrb(8Qv0oo5H zG*EWf1KW)}3)kZF-0NcjWg#5S#a}}03rsZK0M~CfHw+5QDDtg^=!j`PuVHN%hUql= zJVCtQfwL-nz{9*!)cPM|qQ&v)zae4XOj}t9P5N6?j-Y6#1UtliuwciEy#k4Q?$v;L zbx--tp2Kk`=WV&j*Vc(BL4Cm+K;sC6r>N#jq3mgVmlygW_Othu3mTn{t+HBmW0x`r zI5bf8`*`Tn#>~B}BP=sO#1R(f#F+G#j=)!TnQs0C{5uo}jSQdezUpMrn(*|aEI*yz zs2B|oyS%*W+YeG`J!Qp+Si`@0qVy*$<2%pR zW01@xcKTD)`Bj_x(MBe&E0}Mnae3CIlHa$H!mbyQAe!Vsk-fI2Q`N>6e<`W90-Yop zINHR~1zr8qQ3>QM|JF55VINfNR5*nk5m<h#t37{%v~eEMMH6W7gL zT*WK}!j1GIAYUO!eU0tGi2A9e=IY&j)91G{WBRpigA=C&ZvO=hG(%F(6M5IT5riLO z;Syd^51?#f1}j!!M9M9^2(S0E6HU8 zqzZufX;{K=$hv%!y#7wZvx0%;t_ z5}769KF($nh*l|}UjIp;cvMPhI1&A`ZuId-;YphVDhqiQF>^C%%XtdqALyVW4|z#S*%>QT7_jCrD(~N=p+61So$w!J z=)A$rr8Q`NhN|Qkp4W~&{rIR=(9<@!p2WgEXNQRs|FH|^X#D0v} z^2#OI_QT06U*us=rT?XSgYM5WRRx+pn~45@cSlU-oX^LR0vChM2HCx(t}XUMX&)KJ4!>?YRnHW@wcBxFpoH+WnQEHcjOa=P9}ps29#v@;-of(AQLsys>>+@g~0HXo$o!WXG;`e;vlv+ z$S2E!_W$M#$Ji5jd+Xm!dlkj(u<}sMCDy*M9Zn(euZ=u0S_?;OE?SftTp#N6a<%$VA{~J=OP3Uy64Uu zq2y`fM%QS{MdG5MV$aO@k4_}|n_##*H02ziVtFvQ1 zy$&k;zwAb9$7RB@I8DM+LbL(ZVxs1W^>@Ciw8vY&c8~E>ZUJ;+qYRufC!Vj*F>FA4 z{;g&D!+k+eh}^yNll94soDAihDPk>1%PnIKc)1jyk2d}dk!dS3|==XJix3ko_9Z@#;8Qk-aJF%2j zEyjIzOl7O8&$n5jTfvPX_uKr_6UZur0ElT26T)3Z95t8{!yK0NjyD*{vsg#3;rf2M zF7d=gfSdWEaqaNrnXYieDe`p(?^|P;Z}*rTUW)xG5DB;dSQoWRgGtxWI(+mZsc>dl zwZ0G4dNL?Q+n>|aV)mzj{X6EZ?kaYwKxv)+dy$zEca!QDRVu1LN@RkktTe3~IBau@8ABmL&d*0z9F|BcZ4X0? zAsPK$7AFFeItJLT#|BPEe^0R@U@l8ieCzskdQcx96%vHBt;IpdX%tR`S8sZ#lKf<; zK$@izrtKbfg`ogOYWY(g=K*lIcFtutGgj&H}uU;rlrHwELQ^lZbP3>i8!y!ll|y z7?nhaIof-sJ?1SAoH?+=_og~_1}{>L$rWcWA=q|h7dZxhEQ_Ol+AU6Q_Rc~T&^9?|cIR00iO2D-2>LYFy30zC*U3Jhx_(dX+t&9|(dCZ`#c%&U z>^pm5g*-bQxMVyh7MvH7_T}L3l!b+7HKp7}8qsUWIx7 zc%eGGtNbJP*`&MJtzW1UbObtiGr8r(DZ zo;2?SO;HNk8>%}}qP8x2vpj9+T|2&eeVn5-pVu@r#Q#Z8(54EE*1 zbnG#vmeR?vR&D3!rgzK_$o845!Sv;f_z!ilEe6bFFzH_m6lRGydIkQ%On9Cj5z9`r zWxvpNbRMC7QvwCAUz-G((4%3FUJfX_9`~d8JP?a>KZjewM~op5!~%%@b@(94N)`qf zqT$}AT-rHqmy!j$4=P=1#d$)pdf<67)2!S#(P@$@~}+-SV_d z7Qn}SStrP|B)Yo)jM)1;C={8u)`fdU`i4b^+)^mZ&wkgZF`NIIk*u39qG5 zo@<_q%?MGqDFpRSc-0UdqW`}+L;msQ6LS)IX#FD_k&iYmyUl{H%|hfrG*MLGOl^!JaJg8+=O&u| zh4>9J#dCe|XQ$T~>Kv&x)@XIhu;HQ=h-wRWhjE7KDTzD^(rEDa)SV+*;N)9+$NY)q71iUk5itgX=b&^5KUbSN|{VR~9n?1jdJ@H}MTP{YHxU;~zlUWc(%c`$cJk7EnT zM|TmJMa80_Wh8%F8fUOr+W@?mvg~GC6_llcOvZoVwYA$8M|uIGB5qln6f|OZOrVCk zV+cViY#_JRB>xF3SJdS9tEkcEmM4n&1+sj#a&XQpTB5ENwa7&3Z&}5I^2X6^`?WHt z=p|v1H$78;{{26qdj{wK3ET5HqnK6ZB`Wlq3PYmLb5P|*eSF<4MGpMQ}=w=a1O zRWg8T)25jdzFYB8LaiO=Lcs~;2TBsONYi6<86Dq>Rc2h9_Nv`FjlZF?wU%MyG{u-F z0s^d)wdl|8%E->yR1LvG)GxDrb?P(c@N{4Fi%nnLLD7y_cDCvI=?4sa&(qS5h&8Fb zhq-3L2WE&bc$v(J0OlE6QI|$>HbPS{J}9E{*=wO}?pX^T;dqQU85yes{L4{YwUNnx z#_7s2Q9m>N0G(s6#@FJK;l(-0StmCI9y2Na3d3rWBLDh!_Dge-5oWa8aQVgb{sfJ|kh6EvtS>^eSZ6+93ALNh z&$1G5O0UW6(DnFoiOSke1668w3HFrp zjPRf(IB$Svd=Cq2`D`TWX%2yj6)qKGaaFME=LLTnxK-1o_8h>#DTP3nYI1AN4!0c3 zIvZ%PL!86meEfz>itLRsC<7!MXnw8zBIU3dGjc?rpORJxb8OCU{KmTV5d!uw^$eK* zxGZEZ3_5-jT(91khFpmcQA>gUJ#}m=E0T>i8`mDK_7!me|?p%%4yLuhM)D z4VY0~st$kOUx5L|ZazA6B8r%@&MO*47&y4R$t*2%G!+Nvo_Ku`Es&sZFzlwIwCK3n z{r!WXzP(xpbqwv(jUr#m$Zr2zCeV1}T27s}a_wVT@$%n)2<=wGWcTzCtxxr<#uDM= z08iM~!-z~o)~Cf}9li0s5^*{+jXLX1Wa5I={%IK*O_;5=W9S zU4wXNuOF3)aOcl;FFWg2wT5n;5_@w2(+Gk2L0-noLGgzxpGgc7*BXER_nM0?AmYVw zB5I!py5|YYX;JtzQ2$0OjY}+ZZPLi|6`7MF@p~u*Kj@2s0BMKyUKC}<&mO!QY;o&W zG_Iy=I@E;E8zCRT2NI*di2p(lt~Vms%V|YImi`zH#1{bX%aynt zsxwc_D6`7z2?D;z8MF^=SZEEX0t#Gt;c7skC@kTpHX0254p&B&q+Oj6qJCV9SdXX0 z5Ph6WOEy4Arck^K`1?6C#cp+!>xt&e^nd@Ns?_KcYqE@VsYih83w~us(Kk){MrZqK z5z`kW5v+NR9@~7Z!B0{aazy}b=x@xQ5Zb~qO&1LvtTj1adytSU^ECmTIf1r~V0drfQE4+IUmY2!(5=ws z6Ikfb^?!rz_@a$7G}45v9D+IuL|rL|@Boa! z#m&7)sWVD!4JKYMKYj1Zjtc13TYw{?h8S#W+%pBO!shbhATbx;kLah9z6eXz?B9V{ zH$bBB6K-o~;?o9BhZr0N66qw}hvj5nLVeW4~n~}>3Ku!+rTqE7u*U&@bUwx3sF8cV@27;>Bo$@;- zUED|=n8#AWXIzDl9zB%tLKUF&7iAb6pN18P+Eyk0+Ozn6&o?y6!QWHDdO@r}NE1>h zNPs6bZHbDPu9j55i4=7OTBqDmqsBDWrsv}pw&5LVzhn#(cgL}4kugU z!yrwhf6%++NBhHrVdUT@?2>2!A7XDpl3<{BiSJ|6f?BXUfKn;z+xXYl^BMXr+cejJ zp)l3$xqk7`DbRF;x1tW?|IHcIt8$aY&HUu>Sr9G)4YW&2B~P4=k9BktXXnh2hW9{l zI#*OH=dJ=iTnR>ft343cF*1+~HtP&g$J4Ay!wL+4c;DiV%506R@zO_?zk?P0l2y(uh>GhM^YrINlV2ApI_gSENy#*+TH`*H%OWm&gqT-`L| z(GWiPUzAbL_X02XfE5O@1WN-t`)>-ZPmf@!^M7&EsWa0SvY0;xN#TEsH;4HHXV!gZMBho__H9v{BazX!|FaXxmF`vp|Ga`rzSrl0qTYO$tM7FgyGu z`tB?VgnUFhnnZfw4P?2_hH=)7vJ9+g_v)~cHD7~0N<3@=VQbrR$+ih=g4}4?IACH{ zpnHy<+hmZ-ib~r`Zbco`Wn(LxuDVJ=g|WM;wB7{w=|F@(Pa-Q9nMEbOu|Di3X z2+q&Mi_O=6Uu1QHptwK%Bd!Ci0Re-qG`fu&d5Hg@XovXJ`Uc=>N(qh0gTpeUiQ~bZ z#JAq1FRM!ndTJI8zs={ink!E0G7fh}QwH{YANIhyV5cWaMZ7~bJZ8AOXdVoMX=JD> z;lSrj?A}8E#w<{gD)YkmxajubQfrR3W2BNEA!CEZb%)KjKNSx+RRfX-vhw-NtE5b> zW_P1D6Fg~iH>C4z!sh{_s|<4<9x%LwrA6(1-C5cJr6DB9gDA@mSJkC4dMA3!m2-|i z4-D(`#WRZ!TIS=;p(1`$;br-Id8&@j^QQ}c5no#O0v|W%vu%cbmL|HfcF4Y-N|Oh) z&0>F)7`@gwCjWDO0a(jBk7|4HA*J~9h>`YnY!bP3Gh?bpgetuL+`Dv?f$W!fCzvPw z+e}Cvbj%p68Df0`(U%B8oFVxia*BiHpr^+0zmtzAaEhVQ-=`mqk&N85Pq^F){!pRl zC{&kbgL4FGdv2Lac;sIpTZU+>l}qr7wOY8NNQ{LlcVq9IH}g=P(_QBYDU$@r^L-B; zlmu(V*4$U#PDWI|rlu5S5O;-rgGfO^$APf5THM(wR*p`ye*8KB;UI@k3+Kf+x6ylw<~_nS*R9FqI5%aC;Qv`tYgmCjB^Ez|?c$&e@MEcAdO@d1S z;TpYUT2<!ah5Y|W|1rtI>&lf7m>tfU%}k^#B4ey8RSy8K^R z?fMV6n$G%m5+Kujr|X;9OG=m0CV*ZRg}?@0UW9#5%B0=r$m4^sBOUW&^826KGRKWj zzd>DcA(e{E$E|DH04zSdD4U%jRSQzc@2J-!yFrx~4q%cEDG>GbHUn}D!)zjNIObYh z>BZgE;UF*X7Me-q6S#|^^vn*Qh6AZ6&NF1NiV z+mR_LtfjY#h@qg6{T@0e7@g@Q-97l?Ssma({i=xH2HS^OfFsnH!t$Y?yUo1~q(32A zBl*siT!kzK$qw4PwEl%u0@`PKZyo_+C4G4yX2=7T#7bHwp4dKaW;=&STdm!*f+i`~ zUF<_lpdb{W9A-gzocVxJ#De_&nj5t~D_vrf#V-rW7rUwo+7K2DHo8`0K*su?pJe?5 z6E>cQ*OF)5BkfstBXSqb>LY+{?AyM63gfd0LY_Qa#KfTxEuHbKvJlIMPm@e<-I`!q zs^S(tTM7LJT8v1_svGlSG(-piEQd;%a~*WLC_{ztZxW?EVM$ep;=T$l;{w4ld`ut)2>))Yni?(2=G2>!6-Ya(S; zScgQ#!sQ^vf(4vI*$)PqqqulG|`r`4| z=r8RCMXV6y|H?!+QNf4N0#k+ZH7$1xR+yj8rE1Lvg%7bdcWkqowjN2_&R}sf5xClAU#Ul9izPDx1!O$xywn^j*FjaGiod2cX#}&-;xb)otshDUwSjn3EM0`O@ZO14 zV324wS|wkOg*um^<7I|Jv>Li|5a$1zGiVjJtL8`D*zR{K#1KP_FeY~PJzmf|mk5{b zU*l>az_tc7%=!l^eIxow94GAY?9zA}B{g}r+V~FZGpKNvG zG#had`^tAOvCH*Bocp$H8$4elrvetM@!1#HuG#KonU=V;5VaOCqm?K#hnW&dR!Zr2 z!XEcL*uu_U%AQJ5Btp6Rtvd!B5Ji2R@D7Y1RpaMm$n>(3S>} z#A>$w%z>8+esF=ib;-jq=QZ5ZCl(b#`<-4)*e&4LY|eG1joNu*xX(?+0%f)NWF?Lq z#hSx+x4W^bOAf$okz2+04DyrWuuT+g6u+b8c(rUlk+v8SK~{1penUhGSEx0h5?g23&Piql$hx0H7NYgWt|XC z>YvSgn+X19@1{zLs{_fAdSzIoleIl=W=+$HpJLW$KMKy+7p_VUNw{d(Re?*S9gZmi zws4V!kfyCo1>2aE#A6o!&{8OdrhF&<1W;fORGP__(Lw1BZ$iDv%>MCGkspE+r+IX# zG;YO}1QO=bktM~7X51W?B&`x?vW*&o6rb{>eb=xrSq2rqf?-J-ZkVeaf`&EL->UW} zCT5wG-Lg^5!n*#7Ti*5dDrRXATV7^?8U4kqx8L7sHZKNreq^$27${{O_G3FX zT{z(l(WRdPC{g2(3L%9@JOM&Ie@!EotVREdFG6j&Xc-P&6jwzc&}Bt+a~LgJkg)ev z)0waytK=bl!Ns5lLZ*n{40;RH?S=1VZP;!^nRx!ESFBdKh4exOv!{t!qxOYrEMZ$AoiSo(wN?tTO6lNIo6vYSCvrP2OdE}LhJ#7|iONtmy*)FhAc zqz9<2^B+Hi;;;L`ePUqxIdvui`B4?yGWbmOhnG;Y#SWM=>7N!=oP`KHH|dt4>Q^1T zxmlPeMBDtr+ljrnvkkPN)gho8Z7b1C{oBbY%HWRYxvFa;+y4B~|3N9V)(>P(cboT+ zkXT;BY86|RePBr*b_>v2%JH2`&-hIpq`+J-vmJ`e4L39A87pt5XzIbA)~o9rS>sZ| z=NsG%HE?;+JTD66p|FZJ*&y2V(n882*|&U|IF`#RJEnrQ0Z0as@mDll!I*vtSC$-s zOEzR!`kx%JwTdTZB9ZAOaNzFNkz~;@-SSuIMKMv~6Ld?~v;mb@d#Nq!7wj4b;C3`m zEk_*A!2WT$_Fu4YFHUeN&wv;#0a4*jaIMq`Ko?(^MxZ@aRma07rbUM}?y?u_q=?ce zWBmPsVx&v}PDq-;wVdgtR)zSbWDKlpi)QPS`sb4;C=KQl^zP*Md%G{#yBie_Y5{G- zZNi(AhJMwqt`T^4%Jf3f5YIbcq9&Oke))CyDzPFv=&DF{1xa{eSnR*Soa7zGJq|2T zsL>)$Su0pSUWi-~V1>Z@rGvnqcfiZeJU7?xElmM*e@kpokrLz4;0$BTO84^@CK}JB zN=15W32E2Xw3P!{(k%?Z22*fP6e{{Erx5w9?i9u-1L&E=^O#0e_Itp0%UVuZL!zY; z^>xS1*{nMHKr*@OyN!`|)4b5v*DpZ->(mABFpq=sd8e9pc?j1r_NK3D2*r&@I<4M| zQZQKR`V_vW&b$&3kT@SOTfgJ@ID8Qzw0S#s<&coeECvq1^Q1HWzZ~{u7jc%HP4&I1 zZIqFxg5(b-39;x8Gr-H5n6mC~sbzR%aF$eRY0^gFn)VFDwb_XZ!j9Jw0uY>f7E$4m zw4mhbX|wR;3G|xgS%Zcf5N$Cuo%vn02%=#v?$!mmY)7<<9(IhZj2tz5nPb#lBIB!e zMysdX!TlkGs{J(L7Je+7^fkFU?8d_4@=3TL89U-1^a#pFK+eaax~zgTdZ31ozg#KQ zQbbhUR(~M3m0sJfMs&cRBDGbzvome~}de7fM)VE+to(2Wh1JYsS-`FUqqOP1e zzpB*oo5;HN5Nv)sXejAlj({8g%`Pn$_!tRtP_@%ya`iH6>>ASj<&3MOch8$;zUIW) z=Vpxw_`P!k6KODf_xa$)es>j}UaHlMU%Cd&XO0m6Y2wnCLh1)&W_z>Tw*M{hoTMP^!Mp!TZ!pecRg3=+l9}sx?5cE4T;a(Td(MC=;ZxKCfNonec*$zPI zNGQOcuj&B;6_HvJcACNd#+SaeM5oh=(>9|ESu!*iTm8>Gg1HuMzfxJY&o*WXQAq-n zJK{g|;0C-$n4n-t&fB}Q;1D*r+B}IuLu9P~H)oi|b>$8Bxm;Vy**dJTlQWBig~W&U zob6v3{u1K%%?7#a)oZe<`zfb-5lC!O9Ajc<>XC%`yuvzko9(!_lAvgtI7~`>n2un2 zZ|ydJFaTizgLON)+Bm{XM|0?%M+gxY`B?7b)sb;5@+o6GIBp&L_QL+wy`kbig!BvO zZom^`w2Uhu@<3_Ewj`2LRXU?~Sn@3xi=PxuL(?PA7+V+-@BB<$$|JY2wXn;m5>a4o$|gA}X=smwDDfqoO4$vFiL zGtW67kX1jegW`p^=2&30gc3+bq|b6=D6S{b)cNM$&-VU~|NAzcU_)2F!YU-n|9vJ{ zgY#??cHkHKNp*Cp*_=PH@M5$`9D6o=&TPvec3^;R`L782l%ny|gKBQ|jruNdvr-JJ zYP2E`jW5-t_*Qxw+T86SgLY1@fVG15f`JBj%LTT0n-^y{#TEi*l3-RbftW_X4lJ(cz2gXp5ahEeZl*%1Xb|#c+#t+PU{v z*?J%A@w6g6;8qZ_*yV5WQG@Z&c$ld^e{r8$cLeWW4)9y6)qCa^Mnx~dzI3WV7GRo} z2MIcR1%Bm0gWZfS^JA>LSGucg6O@O~gpE>B;2znitSjS59b|4uRT zbw-C9wInQG3nZ`o88wF+|1rOs{kA?y>`Ksq_b=YCVNAF%Oo0ATREBMRc6&?ihuMUjkC14mW^^EspjP9Oys8pAa@sR_CK^NXTthVW{FP zV=L4hR?w1Xzv2A=!8{&6KSfTf5;TgfEn%u@S@I1)&i%O zJ>0zi24y1aSS7==_;*+@rZ5os3|I<*OdVQ@L}fLW4WlG)B`Fore>UVj?_WWOnv&=9 zVj=j7ToYv-6z&(Ruo{uIH8c(B|5L8E$JhmOfYoqXk{%P^H9 zd0Cx&v0h)8d7(s%8di@c#SZaOJSAy(v*b9Xp2sGa5 zY)>G5@Zedhh5v?skd5_xxrgn^ZS}+OJ-ELC?ala+I6s86uNf#OACqs1(=+88vo+^m zre~wz7K6w^ABYPfsILS9`#iNztg0jO9{-i(T)H^#E!|I zZ@H*pdN+P46ncx~>KtlP^zwU-V#srw7?RH_bIY zR$;6UKKzR8g&CmI$5tp*y!98f@|h`?=PA)Z>QT;A@sGd*#!8QtUvj{Ox^{@Wf0hjP zNhP1NQHK2x3rmNFe&iEmQu0IN{V+gs_V)O;MzSbuRTbX=ud{d$22t2809 zI)Zhi7Q6xIr@4@~1d@vtuj&K=SG})V88uzOzqRVuZG!smVO0IiylXty$FCWgLlV*8 zEQ-NcUdJD8zckF0+>_d-S5(cU-y>W2JBON3qc#9H>b4B5$T?qvrz_vMTSe%$<<$N1R+Cn9SR7-QL1lum&AXPC}0k7oY{cyro%f*^QL7_ ztpXTLI-&nWg|N zczyS+_tix5A5ZxOH!bIs9dF=vtae4dCL0J7AB=PbxI~1m8tV9Z7_X}bFzd2*-D3?37oW3TkkIpmLxQe&C zU^dO#p=060ObA*URs@hb)82nD3A@lTq8B9Fvr5X3{#F(f4wV{HqJE8}X9ecJBQYN% z5`Ux?)iL*@aup5gk#u*t8`HMGk z@@xw-h8FPPMn2XJ%W-k(b*{WeD5-@rCcO#gs-M7CGRTxNtp!-hQclN%GyXhoC1bh| z6D^q2d4iIp1gsWG7U2XRB0*@epm6oRx5BbO#guv@J|W2+;eC>SwpZ^Lj(2^;EQsY7 z%}~oq;XU`>k{%l`+7wsIhc!1(>TP4Ov|jt{03_ukb?6jPvlk*^dR(bZ%rQDji#?)Z z;GXg$>k4DgK-^5rQt(!DU0HdE2WKs1f3S;Y{)pt&q7%bVVbh@)(6gm(w!Ygz=tujg z>>m63)yI&<;>XuruYu6+kFNNml@myOg3TKBntiHM1hF4f7joZMv( z6taIBe~p|;p;lCL2}rXfeHs_xrA?DeUc~b#tS7PtiF^V1-`ARx4PPXx1!_^sZ>U0z zV>>vHMH2q;B`>A{q{~dQAa%O~{lN2V|7sM8$;@@t4+hAVid=pe+}A+N^pc*MMmgg8m*ELIfD9@$AMM9YTY)~(to}w@SadCI?G6T zt}p;pBRg?Pc+Vtq5uRDvKtyn9u|sB#BAC(c9@-0WrcZ)Zy;Id%kr300IilTgW)pPs z6>5ZHOQBl(UgbcG zb&~|&C<(+?KNpGQYmz&2gD(^1S(o7k)oZNHsbmo2)cG(Ua&^u=dw^O2(D)^M9 zf%-Ws8EGV&n4yLy>sKz{BQA^-NFaCZee8Oz<=4V@^%vK8B&CG#(9dRDd0-yv9cID+ ziz*GgL&A$6LtwOB?-!69hSYYVozjw_R;YU!%)ej*t^!r55+zCD3r+%Q*a&?4R-@jB z6gFA~_8h{`&V9PTO19Kcv8*77llQkc9CjsT=YsAT62}TD2DDc%pL;E^D7|J0E~iLP zN1~zQN-3h0At4}B8R9@;{wLZlL`@5pS#Pdfu$khS&c#*p?K1>|3Y{=P~B|0T?kSDSnPZbdF>Cu$p?*U3qFqgDQux^g5zBtOPCh^z* z6_u!#2XPWQON%TL+JIw8>tEP)e$L4@`W*F5q!gg2Zp z`*-L9W&uKm!^3|a!K#@rfaR~xHHSw^G)-fAyf4qL^??lgD4imH`WKH;!o{p$kaaOK zJC?*+F)})8Xe+DW(~YGc9^GVuA+RQ5Nws(hNT|2vR&vtTLgqe3v5#MM6n%f$;M%S~ zE#=297bDmJV+9I?WJdwe*u=YqGB{YxUmQuGdxf-G`oH}_rIy|Rm&~%W%H3Gwj@U~E zSmcC^0aLWz4f`Cec|5z=CDFX#jJf{%l%8FR{nlwMP2-l71EL%W^a_9Lw<(UPb3bEX zk^SAQ%byl^KMSGsaTD3U2J#y%R)YoHNI)+lrbYpv39|a&m~snecAuY=$9W?|C>|sR zMYNR*8NJ9Ty5tHZ#y{d22U$Yl-h9w|Oi3RR5)se$m2`VX7JvO6Wnu(Iy;dY?tK(KO ztG8#+ZvE=-D|~h?E+*ka8PO??iJpLx(Wa6R;9P)|;Vhx$ls+mj@cDUqw0*nc(fK?l zzy|WRBM7I$aoT!g5ph(i5l*k&rzAIxK8gHtJ+2RY@9BP54y?AI&V?z>wR6M35uMjp z7gq^SQd8yI>|0q#5d$>^H1*AM5LV2R|B|FvEj-lKZEY_Vef5SZVF+sXOMouHGany@ zDWJFZ^xjI;|Mo_?yIV=)B>AgD`)a+d8tjur%P`0!kDrP4^IV@v!lNVp*Z9+%7QrPF zdZU?N4W#YwdtvalK^Z28eZPHm=*`rbOLfWggJajXTD6NrNQ>Ci{4c6g;=L0m-iku_ z2yK3$%6qdu#+}J{S9AVFg4StKFEK7x1n&?`IfKB-%fWN?-udpcR$`w!q-07_37qdV}GAM)%QiLh6TI81=+z zWFW4cpNMsObiYqsumqawaD&mg#BBS!_JatdmgZ3~4cHnlA>YAMutQvkL^~r_AETNV zpkm&&46NsAY{DDU0*;84oDMO!XWeu9S81fp1I=3~+KM z6nPPoCiLPA%a)3wVkmzshXgN$++r<)fURe{W&%BaB1Bv~RttUy*j+(k88rL)bEk=k z4?#~K97X?3_6QxOot2!MvPU}0r0~aFUQwgXJx^ETtW8-3;Wu`Ta;35n5&w1msT;_C z6liW=fnYrGX1Ngm%zzjP1bW%JZ;Uv9^+1QILSsSh&b(#8w?+-tWs3y`zPHbSu7*~y z+rO8;-UlR@iE7=vD8V#Hf~dX@Dzp90a6X=`rJe2 zlb9jZt->Ne-PZk$}x{=4J+;@+AbhCn_sgn`H_z)V;<@Nt@c8=X)v|+oxW2>>#7)@ilL1Q!x z8rw-@H@0otHXAi+)YxonJ8$=1?+@7fnGZ8-zR#>V)?C+l9+@&LGRX`9U35s!bky+A zl?9~5&Va_Ug_EX+61I+6>0%g(D$Vn-5iOg`0%>Z0u2jgLKJ4s-e8}aKi!n+YY`bcA1Q)7} z)-@gmw&T=JZ0pW7r%7lH+NYb{+miHlL7OAeKx`eR#+OSPVRNov7>cfCwiE7-YX!wM z4lkf`hr);jz}aWeMxZv8(^zGsmUFwClZfZlbejgB+@sqz{(ig%RJ8`o1z%2UU-1-i z&1z%wk2vG_P^WX{1I%)y$CM<%RlbjSX8w;^i)L}55AS#`zJ6>g{|e%DS(c`cCph3i zJ!fH4Y?Qc$za#C8r{?eS9}ch>c|EL}*yAYrWD^!Zc9678@RmmSCD7uCgD_gW3}ceI z{e3U8b=Qea6<$B6;>eGoi3vA7_=;DC)DWOnh(3hX9`cd88Lmov z(q50l6ntXOHV|{`Q%;D2l0n)ALOjkOUSISo}IMUmmf!~-4|FO$b zP6S3Sd69IAlVQ+vB7!Of8=~6(oO|(f`@GALr~6XMP7i9hc^D=Z9EM(n8}1kPyFCM$ zc@tAZ79Vn5P|;CL1^68}YDod<<4mM~!`vB9az|j|OXc53G0Kq>B<~e8Y zu|m-(!^L?{cb<!)5CU;bnhcpVM! zxg>8=H#$!OxZONZ)-`25Wpn+taOLb3goCI80BRmP9EE8)eX(v6&y zl?-jBR8H@^t105FYEW7&lAOf>6`@Fr5nPgddLp5Mnp&)n)7g-FA6(%5){PIHNFLoxe|P~k?oZCgTJsU> zQ~jTChqu)#pyyDFsI=ScMaUcAxiG;#;d{f~FR_$<)xYeIHp(>Mu|OnZt!Bs_mPfyr zlrZQTE|8`4I;*VW{}5r}5<@xkVC`ORp`O$fGL5k0Hw*j^W%|ZM(&5R6e{f!`y?2zw z^(NH!NsaVSik6bPxCRjDU!JW_W1bJSe@bq_4I!^EX3IrT{(BsAT$c80QNWyf5z$QX zH=~yC9J#ZansHo(({4F^cBL>#=QTeIj=?=5rJu}k*g?@KylaIFyz*KR`Ec~0w|DJZ z*JBo`GH4mjU-@`Z^+na!N}9{$xQ+EQ>d$HHuKlYoyOlcptH4fQ^9PKU%xerkeen|% zA%svQv6Qb2bIzMB;ZDGxJrEfYOWj*3Zt1+)c`MnN_9*gcz!QG~RW8-*;x`0kF-Ws} zw}v^%WtZ|*1lX_qO|dx6AH zUA0m-K9ls60#uer+j^oAYPtFQBIwsPkk^gEb{Xw6YDpu+F87ytK(9UMqQjL%6Hd8i z*mPd;N+0UO0C)eU`~63H3(*!sFpE$10Tn4k<5cvJ06AuN1429iUl!aD=QuF?pADYW zWPIz;gu3aVTwQ1?(imy9vBC0$EdJwgzION;PFj zPKy!dg^QeE@a**DOiSD+V8ktUaXN-YsVa`}Zb13<hWj)(&$WQ>mC{@IkfkqB z?=-fzcf(xyudj3UH`GWt1C=U=z;x%f(Rx;%4VrEK=@&VU20hB5P(`r#jI7gy3$a|@ zZ;2F;FYs(yMh3X+D3q0{^-i{Ny@)j}fR~EUTl$TCL>na3{qJ)fdLzf5tBf$oB7odyK7=tt z<4G>?H6h7S09Gy%3mg2y_RCLO^aI~`i`4ow4d>y@?#)j@#;H(;fV>3O_N+>P(pll5 zK;bfB?1hot457a9O4I!40_VonT6}kPxtSr5Z2>!~5`W*GX5MV-W7nph)ETAwy-q=E8Dxokxy$r8M5>`RDNYIjoUqBc6~W3@Vu2m8 z_Y*iTx!!H}U}5tlJM~;&W-K35{0#kkwWqLsa;!%Z=mvb##nV{cqQlDO!o7r$l{EKOQrM10G{iN?ohp80jmtF9NV2w@vX?K*0JGTJn=IqzwWI*|w)^+` zOD0xDEYQK>Sv*y5>CHHuLsYQzEjWnhSsKW$?;sP`#@Ir{*R}EpLVGmcs}?slETh@1stG zWR!=nlHe;fFgR4ORHe%J>EPA;Ff>oJ{#f*3r^I^~UiK_>EM~P7c;%)rA2l1GS~4Il z@HpN7rXVmFEe@!(iA^g}T9H`+z2!5!;UhT*&=BO9XR=A0mzrKh+3zkS$C=`(!UGwA z{L*W|@F;x`kzN87t7CEHq(2#7GUZkx9nsaA?lF+)NMN=12o#c#}fNalT56n??yL>{wDxGIc<#u(kP~46y-tj}B=G-JrVN zr06owAfGhcVmj@qf+*ihk+TLvHYdOW!+H}uQLf5S9{g8J-gpep4 zyeKe4kNLUfXOYQDGgsrj@s29+MsLDMn0Db^@Pg;V^aY5J#ct+}6~3vkoI7tbn#diC zy_%LLV7d_)>EiqD;Ra-s8<1J)82~2U9!qOC?E?lGxHSn$apru37}Vq&(&J3H z`8kX=y^H;I0)s}tz1rVCmmD_p8-$@Ar)>YBt>s>R$Ps;9YmYffe8)zt@ z{P{C=DtNKvI*dpY|9_d{|C=+mWNqv1YXh@(J&Lj`M2rrx2B2dow4MFnBcJxo0*!(4 zP>ogQ4|;MtEJ0{J2jb^;#{jdiY|A5Q zgX;if5WDaSG=~nFz5| z0%ikV+>@5Hh$@ZlgxSg<2r!~a8skmeU~osmyY;5JIPLJ~uOvmX|{zWSS{czRk^FOEX@V5#Ob}dwzctb>QgJr#43@6eb z4KobE&#e#Mm7*{ZuCCHHJu7f?1naXfC!~D4fi~CStO!2=nXA4|kNYA#Mb8$@WQy!L zo_^21kY%val!erUpynEYjah4^dx)7>lUq#pcX(Z>_-hdV&Ji8%u|os$;-7{{OWQ9N0^G+Cpp}DZR>#8ysDvKLC`RF{nix>WijMXH=DjiR}0b#q5V;tj%m75!lK7- zGB5p=b^0%_lX9Kt5o@Ydqi2|G>}d07w%p1ciF77GE25wDImiZxYwVRX5e6O8!hVBs z;-=P|A{2O%{&b)p4VR37-jnnHojM-T0jzH z%hsZy`t)&g9As*~rxb&X4Eel%_r%;L8J%e}7>)q)tL-P2HYO=^B`|hrrQ$C=^;6>* zN;8%Vms`BJqh`P;m(GZXfYTDfPTZ*=oKx^NB$AleDdZnp^&*_ zGa3#UX~`1o1kCJIdS8dXBUw)1c7tM?Y8iSbDfQy`MY=)CKjb|5j%w%6S1K)YW8_#N zHvqZh`NL!Zaypen*-Zl-e2n*4Igghv;wkY#(xWp$KZu8l4L+X}m3M9XVMo?%gVonW z2#MqR=s6`afSiE@JYQbgnfe+<5^u}Kd zcW;bCB!Y`kqIPGuO3jt!){tUNk;UYvfmkc~MHYP#TrTG$HIMN}lu4`k;xI8|XZ8PIK@TWQi+%Y$HOybibaD{T!zI89ckb2G#XOf>yB63BJzZoFu<%Wr%4cY&B z1*8Y>Rax(RCbnz2bb0-LC!i&y6@Q&x;8}KDRFMKpt*^7gSZW?{v<}=|j3L*U`l%Zw zqMs9#ei~RC3D*KSbNZ0dzRyDiZ90F=jQ12iz?o#!iK6&qOk2P)QbdEsc*&l5t-CA> z-aCX^7^tg;V?8f#k`x=Z(8p89f4R$ghs)t-g0uBGso<9K&vsZLpwh8N^xQ*w7Lt$`<7;1Y{oP^+*H?n2Ic^L_k6nDl`6CqGIUX4F6j@Oolr9`qk{2E%l zwNo6Y>B7{|1AW>D9{_43eBQ&xcvaZHXA_@Li?v^5LXTZZuN{S)D-1D`LIvw7jO1VF zZ6;Lq*7)1b;YCa8=(0v~Q6}&#Mw>=s$iNVx`R*x;f@4sJJvqwe8;`^<5-Sr@c%`L* zi5-41yo1{9cff1%Gy0OdoaY+&OCnS1 zHdO-0EdP;35n>XTiB2wL1uTvbiLbvLKCDcoKktI_NVFy4lCMr)r5=1HhuWVM0i03} zekS!wP6rN|^G-9mIdg%`P!pUVo~7?9ZHFv`muI;w%n|<?CZP zOyx^+O)tK7sba`E7BgRp#H|tmnXlaGw}1U3RtuLwt%?pIT4h?;=d+B{8D1Qd7nj9A z4$UUh#|l$!#lJHaCPULmW^6)Q(xZpbv^jXl*r*cV_EGd8R<&@+Xnje&wt&OAgy(%k z%oxWy<1Cl^l1d21y(8l%m2*j&&n;}9i_?)HeoGi-ORGa)|EBhlNm~S0CmS3R2utw0 zoFD1v2HlawtRCu<{XRlq+|>+$e|x~maKz5$v>jy3z1y0hq24#qi<-JIP5dkpO1%Ha;;DdaF6xRMEhR1~^xA$e{tn{!SuaXZ4Ik^Y}MdD<*PaOB6=9k`E0|DuTfP%(h zJ@(TNTATD}@Z;R>OorcdJlQ_0=X|wLX(@dzN(>n|@j(l`SwnKH{Uc&58d(NOovC@` z$9aWx9!4&krkpj#CL~VKJ@hF>n&7{3MmAEL;x{M;Yw;5%ji)nsDNhi)02?<$ZY2lN z$#x3~zJ1eh;l5%=)+ufIHXQb8jMMTn!e<{sMoy$UCv(+4Fpx1~5DGnyTx!=Sb*PEl z*^zs6ey8G!QX@;tonDRt$fPNj%`8jrBh9RZBdO!2dCS+DWbpOge$K}(=gJF$S5LvqM&AONjGh{GkB#!G2hev^alKO-9Av4!1$ah^{cK7i4Hf~b9>hCVk4yM z(9G`I%FCLw^F64S2vfSAP>fwDL3)L7mZ@cIGOx!}f#dtp)+{S^*8m_CyT?nsswgH+ zJJ3#j%0|_Xk)8QbXluVE260bWSpe5jd6l8i3l_iG!9cM&n3NBeyS4)EZpH6T3FAW! zYC#hst!y|$&nUs?Qp*x@pQPiI8KYM9jx(#tJl4SZ6F{Hi-2!_bF4c6ve%7e+>{;gE zz+ujYF;VCwF@(T>0*vyZb6Gx$>Wu6t`%#Syq?j(g$k9~?vvdDEcuJJh21tdJrjx{j z5+k%`eMCb{R|4}AG+66cfh02P$QI}wAP0xs(kT3v-0AUQPA=0YzUxpwsprI+F^Kv~ zWve|EgpAF-_fQ_kY^sk7!SS1z%bGf+Uesh~jPb!jT)hnf_k$f7GbdS&xi#7y6bWK4 zQH^cwni#)D`KEScb|BvU^Ir({c?yq76y~VeSi~WbN`7pah;S+pjD z%VyjIgV6Yll%Zqljxk}ACoKkjZA_<*mFJ$ogPHd2yxKx2RlmUk(|_!4gNPD$0)_&~ zI7hI2d}s~0R=-BXZ|UA;#MJmRXf;OEBvkrf;ZQCb$<8%PT3~~_VDY!IE#;)3OP}5* zO{Bpp-m$6l&Vv3{j+<|SKk@)~8cTt8ZO!?p&HF&9rm*iO$avpJQD8_(Aqh07relHh zg_D0EIH_5%YJiru#Si*)p_4| zsbEgxHuIM$)(BwRsU#!?&3=$>E;rN^@da=?mWR+yUnym#-)F|9H3L-jh8nIPqU1kW z<|%Xhr7r(ziak9{XpTc0o)bfluom(t? zd%?~fbgqAyAR~zkx8I_^PM9I?y}ZoD#+Pmnzxw(9R$!Dvv;q@SkiAu!tsnf&y$VIk z%gvmHU!_uvy3x2-98_ST*Z8+Wdz0}sl9R)xPt_O)QfoCAOa>NuLVED01FQ3l)*<@o z%U%Bu0#P5C9NKqAZ-PECaR-)LUm8#W6xtxs;@Evid-A6{C2&rwSksjCo6O|X)B zt6Y4~28e!j5Ho1(>NBEGC~IVf)kzLRY*%M-OJUq3^G~cJpsBmwMmes*M)7>4%I(E4 z4;Pjj^9{+e^?2`w>RZDeFs%M&>pWq&wcVP}CAt;$q>T4_jFegtudO`kqpfQ(U?hX1 zTvaCN@4H*Eh=SQGzU!{&CEaqGJ)YXf{wwecWLG4m2-{AtVT`SHCA_q^>p;J}B>hX$ zq+usCz?olxb5AtqZ)D%ax%ro?;ihEM%ga8}sy1rWJTJB2q|@pEuh%HJ=6e1BLf!=) zEUxc$=Hx=B2Q0}fRm@QeQOt;-RCijol$3ctKc89*Zk8PTJ<#Mpk5vF!3In2>Gvi9Z!SBAeSVpv#@JA`5 zyrVfx+@JVI)w{t{0&-W?y;Ou3i)kn~XS7d(NO;X=vx{cZm}D4ni}mC4YX} zkHqh8mK3=&5SO}P&Rx0x)QZzSlp*o*1(2Rf)&0ru{zbs_Y;P)_H(xlsph8WI#1snq z%8YO92Mm9+Ya|&~zlv>!6Hl>_#-$(X9S|BVtMQqDk2qXv;Kshp1@(>xC4ebatqp=L zI>nZ07f#`TQ&5LGAx`@}u<4dOIkwv~(!I3tl380{nmOrApF!jvjk+Bck*S#iZfnF3 zkDZ*cWVJ|=szRAJ>T`qGksCD=c36n7!ejV=+R4n>y%5L1RyP7q2bbTfdL!thF_yNN zl~_{ZXs#N-ZuWBWb`>v`Qo8q#1S_;d%ylYnbuLZ6cT>s{Gur?>ixL5Z5Yf(LQtXo> zh!-r_9C8ew?2lo$8tV;u!s3BvTS{B-*7kwpRt+|n3@k3Ta7X=icoI2cZp-JR{acW4 zSxBO=(kvxP`!y%rVIrnjVpcQWxS9*nUNJaUrg(KmXxG{JJlLe&VYbo z$5#Z*$k=jOle_?i-4awh9y17{eV&x;=#PZ|l{3=B$tWZ>`$tuS7fu_D z*AasfYA%QK9Y1>NdMMnnrMg;XaK+v1I_IJ}a8jwm0-atq-m`_IWpwfdUV9n8LO6^L z5FGvv;Tv*QsEJK7m^IxlqIu4E(aNX4c_v41p61*KD<2csn&n=qkEij8(Kh5TQt=R6 z5YP6v*>J1yM2@+D5Bq~a>|pOs?zHKlfI?Th)aV(7?#X@ytkw43x1$*FK{C`RZ!@Ha zjt6!0JLPSn-Q12PS_h*Zv7%huZVeHL&xikmdyNyIgyOG|>se1DmTQvNGrmNOKL}N+ zy)O#N#pstml6Q8+7C^&%K3fx-GB8jNnF|K_J7lItl-d7kO*CIc2|O6MpoCAgKjt-< zL+VwX$nCGYL3jU6)i4G*s2*lq4^58SNqg$7O-}qlXw59elAn!;wi(XE&@MAk&6+_BY2iI5+E9V+?I;0(^5jX|1@~P^DVuV| zJa=X@B0UJ<)w9{8VG?T}`{iv;q2Mr{?Ei`mkFo?Ojm1MBiebH#4O*>uEuM(vpjt`a4#JVg?Hedmjx5f z6RJ0FWP}|89uDp@H6nDo-p?^)IJ47ZPxEP8jQ|FjHBDHaAbZ%qd0#W2rF&c9I5NMY zr3sDurKS5R3JgdT!wcMI#HjobUD(!3LjS3kdr!?Tp5Y}rh9gv64zLTUC$07a-0SDw zDufmSP1#hqIWIND>@;OcB%`t5a9!v zfN2KndXoMpzmJDOh;$bQ?rj8LOe&=REe-AEwZ}mf(24ImZ%h6H=T(DZCTFV@rHjpY z!;_?sb<{h@E|{7KekEC<;;&lWyzy$aId=i{n&-Y+Wpw^l1|(bV8P$BCf7Wwdt;=W? zfnii;gs>${MvZ6g%xVA0^Kbcy-6%2;ssD?fsKxA(q&rqX{`eie;Sz9-Av5l}E+M*n zqD}%l;=)F(6qm}Yb6e_}v{CIrxrR-)z0t5^LI4%NAHscNH5w8W0y5h6rz5P_R48GGc!dv5 z_L3jDdjU0G`F=CMvc)n^`*XH|UD=YLHWmTia4pxsJ6YfP2oQR@=~Ei}Pn~!+?{?yM zj!BMI$A>X|Oegq*R!N2A4m1v0fAz$ap}w8G^T519hb|Frz(ddFd?hm3%!c`^4Yc*o zG!*40vIqY1_Dyco6fV-e5Bgl!|FSZuD&F%Q8ED|!p81NXuLN}VOq#$IhAQ+OeQkWp zgv&#|=v`|Q2ZxxU-@AXkq)8NEkurv(80%pllclBjdPEBUqFun50x~!`dN0S)zl+f` zUz&?Zv8`TM7xEkncc5FEesl`%1FU)LRea69D*i6mz{o#TDQ;xs=))R(^T5Z5#2$!) zPmZVCa;%eZ1$et|f`f(VH;y3|`xBJr7gM=rC@xSIVuqYG!-4Pz?ZK-^r>nS)qu^~flYXKyZm7$sp*>lQvLj#t3j8X;m9CLfNE*(DJgsXw5kwe}0c?DeO^Z4N%b#Ei@w z?C&}yT>IzX)O?CXdj)+&v|@3vpA8l8T;sd^-a{n8V%ds)YLjIDAxJd+uM#qcPUyNb zuIKmt#B&QHeB!bJPWo_Xn2?=y?JkV`Fey;XBk74t-Kj^Hw-F_Jor=-_$XC2~o{A^@ zxpe-xlo*`x>eVaR<_}!`U>95<7B7G_I-P6$(^|%PQQgCt)CWkV$S{IRb$S(HC%>uL z5~7)`Rb+3kiY>5-Mt0Dg8Uxb`USgOp2V8x@*av;$@41+9cYZ}x-|zFbD+S&q+5n$* z+B!xzv|*@_iF61;s*@1UD$L({vl^Y=M<0;$c0oMSu8JTwWgxU!!@^&4Nl`h=MyEaD{@mTKB-)Yll52tGCQJ_(1S-Ql?i zg@_InWTHUiuFq>}{;R3|j8R343-sb4CA!V-bc$4bG0108TiF921xv$FRIz!CT~Imy z05a;qH);|ih@J4_U7j7yz_W^Z?*}&g18xNS0pbvGpfuxglI_Nn6@f_>Bl@S(>IE-} zVU%qeQOpinabWr@m>P6}6NB>Y^Bwg3p)Gb_?5upm`GU8rL+5F>sD{x1#IW8Q$8oRk zXT}T` zS71d5q_o_!Yq|Zg{5YhSZj#-}Q?IjExKv+9r|}+${wrrl*Eu5x8x(J3(;oMaO?F=H!0??2yHS4N+^b3QUkcG(s2R9?{|DEp=-Tdt`T)m`{ifU7Xz}fCP1Mh;#q{V@e#dJkL2gpuMxOw8JEki z)!l7O_|vLgVW5$EPnFR&Ye(}z#!bkiXFEZquX+mK#$p^*LKMTV7_{Y3Po~Ow#0`j; zxZ`R;?Oz+qj^`W~AC{aLmo|b+y1Lc3&h*<3$M!Xps0=;-g2xk03480jQ3$%YVwMJ{MazQr zI%*!cqi{|Z>!^QwcIkAIz5{<&+v8;%v@0)3HKVc3vq+KtNGpQ;d2uXB5CYFfXzDZ# zy}@+HrX~MtrTr~Slb@VqW+}J#N_pf8pb?681}(%Wei1<^o$WRpNqI1)TT1uqKZ z#(zeDb$A)@D4DByUx2zqq)XnOahGs|*4Px$rH+4e9i~s*mLU7qyKH^ZcXo z$}H06iEH@m3qnxL3R!{vKy92%Ov}LR_Fs7eI)Q^T?_644qqOW1Ain{(i9xHNZ+voJ z;^P&)@xv!+?jYf`C@ znjVO+hRfcgTpsU1l0}xJ(?bB7?R`?$o^`DbmH(74$(H9&UWx(7PrAYY>s=Jw;>1?f zfOBM_CFJ%#f{Yx-t>jmMvR4?w8D1%1RHm-@J3Kgn$D&Rp=ZMuf8*Q?wboc47#0NvA zh^GnkI-T-Snja#u?Wms-K8y~cJ3k(c23qR+YOTG>GyehPKG7Mpmt#5`Yj%FB9_Sa!leJ5J!$@W+5;^uILn21ylJEya8dyDBg-Go51X zBk!CmWOs!1g{zp?l%68h?|LAWP+`_o}ZMv zlYEeofCE~7e`Ou#mFY0Vr|c+`b<^YpEvX5;5VEml__))yVz+Rt<&O!2 z51t%xBO?5aoN^MDj$_V4?*YrAMreC5g$q@f3x1C|EKrTln`E7L{<=#P%B5%}$gk_SjgU7k1t->@0g<93ZKAN&9tiuRWvYsPUZ#Cp$fE9^DMwd=ZAs#|_SM6H~INEPtO2}HW5(zfo=<8t7a&gEDy+h8-{4>l|sGg5rFv2(SdHy@Oe}&x<C{fDm$9QX!l72r*&vtwO2)77YeCfA&K+zT=J<5eVnAD8kB+KD#O}u! zvLSJV{6>eZ8|kHb*arH))tek+8TWrU4?Dy*kXF_N)vlNI=dBu0CpwWLGXpx7T{Yk9pe>j4apI09glDYGZaH4zLP_TS9Ua zB`SmR3zk(whqW;Bjq^4;;IslCR}0G+isUx)=`RtL(whj0le*igW^7f(Q9r#4$iVS^ z=IvD~^N^iPx;2YuUcw>c5#O=xSxkafpS}DEd;(b|*mZR^T_-&UmWz#v?el1fvd&YL zo9r0mUaEJL?6y`^anYB(U*W!P8=Q8(R$($#Fm}0|;^_7h|5wf+fe)+shW$wN<$@x@ z&*hC3)XHak4Q`~0R-qAOu1^GZ5Fe86)INuxuFaJELruG^!05tMXFbKScXs9S_!A%^ z3-47*Scu*88Jiy7VO#QzxOyp91hKBCh21xbD_JW!fc86#chM= z0kACDQR$A#)h_Wr2{&vBv*m?)p)9vL37vMAC`eSkft&b%dWPV;y{JE^4oX0ASnRy* zd3fCdv?cV@6*rjxos5b882!ONg)l?evupXVV-X+vVF@>mX%aY4o&NfPyOg%+ljgg7 zdY$NgDA;f2B@AhEek>~#M%I#^dSnQo{mD?EfbZ(DAb^4sPntb3obaQJN{-XEJlZwucnD0h94@bwhLWv)$zS5jG-$iC3g{ z)s%=^SL(257DaeQKsUxRJml{qioP?XWy!YAbXIrF$*867<>x2Ez-oh)vwm zA2^&>NZhz-aPlJ#BHj!AA|6P-Q4g^MF1M*SA(~0HwXyHABBrOhyF21;?_8BH8&bdW z(znb3OBZ_l=>e6+CH5iMc<1!$gK77R``W)RpO$u%;ISP5Y4$mH%E2^ z1V3stjs(NV?6!L)ujchX-?1BUo3ads8m&NNJ0b~ghg_ix1=ivzdQQWK8f+K9daHOd zy0z;BiQf>);^`iZRhBx%8-khO!7viXZGQ?41QL)DGb`OncHZkD);|9s z9iOp#VdDY$N>mvotLU)^4hrW+tpZwC{T4`t8V*}_W((}>6;!YH?fLd2+aKhq#27@;fr^*|WY~FqD#gQ9=z7 z51fp;dSnq5A_D0Xii*EH7q#0s@KJ7qn9BOj4g>_|Rjw_ET*coZm;p@nh?5S+R+qzS zcZYOA!SSwlArr$sI_tzHHm}(DcEI+oN0BGL>Zg50oS2!Ll-PNWblxP1;Q;&buaLXA zCjj&_9JMqO;RPkX9Q0D?K{RRn)MP}F2@dBx!Sl@N1NknW{SsIl9x-Mx_=^=(9o(#s zB~MbFCyC8jeDs)`K@nERTEm4+zm4`c6QHJe-6WE6`h1GU%lh2~7X9DH(~rE_?lQWINtL%Yace-VL@Ur}7K=GqZRn4+&y* zfI{8l`?CNUMrQ&)MNWkXKr<*IN%B;taro5V1A8;Aa1|Wd&q3tm`JkVRrPo#nYS|u5 zXBCJ`e?ugBqRJeq_g!w7AlQV*_bl^yOTV>&CqHpF$eQ+Cinod`Xx7ph!X(on`EuP7 zQNc!xxPSEx|6J`2f_fiuN!j^8!` zaz!XqDFX~Hy_>7^p(s#d#@v^Hd1yTF7(bnZ<3~3gS{CWQfvkaUZyzL~c&;Hht3d>( zj?R2+86~FOiRUh#(fTu~qDbF$BzAqyIC#y8S8M}VBQO>#_H=UN{_H}nHHT@zYLp#_ z2}f!}*H{Yk>)zn5&c7i1NjpHtlW{FGhz4_>V~<8B zrz77O(4}sylBl@XQhpxon}@VI#0R}n9P8_S^F$Bf4ao)+96q>Qy#?6u`kJg0#OpU$ zTi{0Jq(3Omuaz_)o>lNKn*^o%DPyWPvlZ*y<-zJ$6d3W$CEfJGTRlg0`$IB+K=~Hx zqg$O`TpN6{Cg=K%7+}IE%0>Npwbv^TL_&5B*Q~8(eiPu6ZSK01P8bxwt~>g@!kMDl zX=RVW`Te%7P~H2N=z+vI%yOe@(=0X{uL&Re%@(E0^P=%&9rP|OHOYVFjClvTsUxR` z{kPkfv*P`%`esh+m!f1tIzC=^HEOQDHQpT?iv-S-2LGCIi?%wYsY+qm6SVN zw!*1wZ4Bv=?2Dr>f5vBk5j6I3qGK!9U`@U17NEr8IagalAW1pV`Qe|62hV}l#9y9- zLyo%379=8xgKLJwz@4u|V^U9i2 z`r{Nr?VM*yUZ`8QJ;B_hy$BBDW%I<=r2P>xaG|y@Bqc|X_%*>!7ZJGd9bKYQ>Do~q~=?z z`)!2nT)X{1GvF=39mA@7dr-nBH2-A@hm?i51^1e#smyHKVk2?t4Bp2f*68Y?Tk{Cl z*ZWFOHE_-Ce#k1(78gTWsz!8u2SN{SEm?CQ*BAu``iMvGaGSYWb|XtZuJ+FR70xTd zf;Q<$|Ejr=HlC-&avuwKs-cLu9>^v|rkQ4wNCfR`bm&o^B_Epeh%)Gld)c?|s> zZF?Hc2c=|Y`R`P}0L5a`r}Avt8OBtyhwK?i&Rv9l|9+dX85aDyRCqR5;K&8@4F9bG z(kWnMeqo6z&Xh@{vObh%GCb4!)3Wa!@Hd%u{rC%}yRzc=&~3SwqUBfmqG3So!rvRu z-bCjW&;f&xc=|W$DgQ?8Z)Zk&-eR%SiuAX+>4y?+`5fv}uwf6u_cw86T>kD;#zdaS zS{)4@s|e)@RNu`N{ggo z*|+Kym?wHGF?u(N3F@{I3km`Z?eC{)cf!T?qc>Hbe)_y)R}TtA6Q@C?7nmKP#J>S+ zWmWgZjgZ*d4G;OG>B53`_W`bJLqX{V*=?Dzhj0Lw+P-&V9;>@^WZH8OLmpPv_9s)JH=t$`APuw;M2iaX(1Z8W&qu#q><7el1Ej28< zvjEOtchL@>u&T1J<9oF2h6ZN*Z1sNZ;iB9LA=jE}BcM&?i_*U5WtlzC9ZrJN9KSS+ zxXILODMR}(VjB3m1-@RllGnbG2UN;l^WpMF2RpUipNI^bL~_``H5L@40@I`2YxrFG z=P4>HzIWM+XZkwJ{#~YhXw$y3JMg*qfZLj0wBM6>)h8RjFJa!?1woyg-FH0yA3i1D zoc-%x+2gY#t(L>Z2wb-G6+FsadE#_>_k-S zVx?Mb*R$%^d~Pqss=cxS=4YhbUM;^AJqWF7n+7KqL}7WTDk2HQS(o?`U}N zHMtJChTc2*Q>!7$5ZKNi`8;RuZ{G)bOW{p{Q=D(Ix2c-t^mC`PJPx;W=KIr3N)?rF zSJAE}T(e)n!9IIH_aJc|M%!dotjFOE>=6-nxE19VIaG&V(5o8AVHG1_uyK9pxuh3D zWOLAz%Nb#jn6JmE_C7Gr{44}W&;QzyJI2X9WVM&{F=en!esDg?Ga>mXe)LW8H`_Nb zOX^1JYv`aR=RG}~)(cr7?D;&WgGoNmbShAF9ryr>z;7{m*l#D$@vUE!!ce5bxmy;J z+?jZxZP~tR0|6Z`H}O$iBcEX3~;MmZa^7RX#*$R}%RsccT06 zH3c0B+2Rzk#vQ*Cq(2UFgr9{-FI|XZKz|x7tS=>T{s7GJAo2bF%?Q#G6}DC?>JgEG z(^T396)oS!a3z8WW&U~Zk&Egae|v9GT78G!&K%RHcekdliq}xWw_6PO2>}8i>?HH- z{X1glV9^p`^;{b|Aws{qqSlD$*H{`nK>-SC&v5n=cXB)V-@|3)#^DYMLE@7Tk9y8l zF+4G@SAZLZ%~?EYEtAVvkCH<%r|ifQ+RGbDJLi~8YvnB11%x$L3ik!mUrnsf97V$p z`_a!=!8ul;t>!wR8?h!#g0GHGIJns`-5h-tVzzq&31uHn!rsV71G$AxLn90)0Qx(E zu`*?cBTsHkuk~#G8NZU}PMm+@oEFF(jL)zqz)7N$u3gyt99z&oIn!oFV-Mk{yf>5J z8=M+TfnLZVD6-J~5`63|T5GnFU6nO8K81BJju?ScWNjN^!l7-x%O!6^jzyFVO({YWe@PKb#uzj zVIpO^AcTN}OqZ9ZTi}mp{(GwatD`JZkxOH?jeyD-RyV-IjL_i^Q}#Ep%<#Q7(*MdC zDmA&~rCn&a`kiXgXJ2zCN6n63bWfLp(+R(&Ol6*c>`aQOm2}s#SIs*S?Wr8&ul%K! z2;v6G?Y}zK=1Q8tWuvh*^WPNhK!c@K@TiCInTzOCCN~AckL7Y|!4Q5hyB1xBlz1>^ zX$=#)nko4!Kk~f;tsaiD`FX8KKav(`Dmp~#kjG)f7x_pk%JjaaqD}&cnKW3{PEjwy zuy26f2F6Kl@`9mD{Cj~7L9NXtNW(i%Q{iw?&DE%|OL_o1A^4!}iVXJ=Qu1%-a%WMM zWvH*7c)R-RiAa3kfg{MW?#?)#D-^m}lclxEu4yTMaody4^ZvNbmGGU_cnjc(zYV2F zOUh@V8wA9uYauCK2(c`zY06gBNEi=#zXzpC)22}KWyu_M(FP-H$Gag-_Lxvc)|vw| zPq6>P**W-y;k8lx#$~r`+gi4bWpCLFtG29Vo6D}{wQSpN*~PUuZ8V@&=*TUO$D@kD zDJHJVHmUUqKVq*UeOT-Q8Tl3zb^E4~9_XmDinQDu3-P-@Q3gf0e|q|Qla5ya@vO>c zW;E%rZefK~l2&6SN07EOru`wla#Plb`t%#fiexZ6Vwkb8fiuh88u$aJgja2WP(2P6 zRx$W!z&;BubKbdE5G(N$%~RyupG$CJ`f@%wC3os1km$TbUC@HUwWeP!%|g;a3Zh2W zyVs6mS>Z+#s&xayLn%m?ho1li@-u>i+ZufQUf#rC!eAPd!+CjvE7L6@vsbGa@>$9O5e_JV8vHQumt$zqxJ-((1_u2x zJ|$aT&LuqCW`Xoprr7TJ&`8z`SUqgQ^RoA&rY}rOh@VJU_S$bls6eebyCPB3;IhV1 zI9EZR-y`KS>ww>yJL<>xmRiVt4S?W9ZCHun*R{~MHEc&cidKZO+S9cw@qJb9zYkqM z+re1s33eC8_o!UOYq?$e#MmX}>Va+<#>(75Mc~{19t1fMB24EtU21BOMdx8wCVcA- z3O4<4EDa%j?Q9_F3><2Ipw!bQ?0oe~Tjdp5$st^qiz)a!ryn^=FIGw#1-Q%jo#`!f zd@KV?R46U4B{>^ra_1@+5Nf2!AJkwq!4d@)I3La|K9Bx0=V(LBQZ_RRgO0W+_}Nn$ zihg$w46-~*LhtEapi79km;b~|h?kDw2_D;y!u74hvdh8*cvw~)%_^M8_TC%*POM99 zGtAq)vZ7FzTa}HFxkDeoPerRg0X7t6IB-qyJ10rq0Vwx@m(Rxv{03)7@82JQr22!3 zkR@i->Hw)zk6xBaJic~DX+w3RBX~OTgDrQ!u>XPNiU#`0>L_`kVDI>duoTCX47I|T zEoxO;wPOzWDoys;#~|>kMD{)VV(1)d+rV89Jb#(A(;T8yNx}fG?LiBFhpG#v4j*Lu zMlbs8AWC)e5Uspn31J1&tt3G2=N`YU^l1ZzTwQ+#H>ryV?n5i<38!onvF(<1e?@Tp zMhRD4w}!tWls)x@>eEV(B zP4YY+WOIiJgu$ofKFvgD$tCvU^e762v4z#qJXs1yz)E$2_Fr^q>Va;OKCbpoiiee zqhfpjaH(ZRscxf($2yvbkAFTza>UeK>O~n|&&S{!elC#%C|Q4Vk(le?L=mtaoj#nO z8gAcwFSZ+J&*V|SIOshDcO#Fladwh-2}}yK0%%E638+7}YD(1?O_)0&*L{}2^^<~p zDK#S*#+H#En-KY!8qxq|A&8h3K3wMxMnkPrOnfnjRB4)YBoyYELgN|FWUMn zEfRC?`Q>6LVKI%}&RG5%D}Z4Zj%At+TW~#XT(2-HWqo`IEp2}Hz3nvZ;us$56L1lL z#TseED_??EsHV@3DH0t{O}0UInvP%yy%_yB3~)8zHiKJe_evM6zKyn~(DJB>*G(&` zusOlD#ue{mgKD)1U4w#P3itw+Cd=>gqh;la>XuJ@7`p4Fq`Q8tAYbU<#Rp^VwZfLZ zI5YhDr*n~?b%=zMsF~ge7NsFq(6~^$r7#K41|v!eO@_!s=HXE}e0OVsT_Z$5v6p=g zB1Y1@sh!(+JaYX-qRV4My&U}1oaYq%} zA^!G|b$pRbJj`AV?%IqJTIe9?7BeA?&DgSd$y?)e(J85yXC40tR>^$>8^Qk?AC!4e z;yqoE&5V}oVX9)9UjDf;Iu&5C{O&yfmZfG!8M(Kc>VGS`xyD8KbzA?ja=lnp1L9oh zf)K8POB%k#6*tKF42uyi)Oy2dMB;)ui4!Dz{$d~C<~xZ+UC2@an(_|;f^PI+CaeR zXve`F?S`0%gyzcphzGt95VK@lq^D!M)U_f;!%*+K7?3szr-S)I7J>6M;Y-6h zVX3o|r0PKqMos2KgWuN*I3STw92e<`>X$jN?zAt%#WPHgV-(cwk-%Os%U0Tb1=iX3 zm-oi%?THJIkzH5u2AkWykN2$Hh0lCu;n+L~2Uh7k{nje_#7W)m%xQ3fVSD73#Np>x z!!pH32I=jnAiXS0Ch6Eb)=|M5A}zO$R&Z$+odW|M_8T@jgLW4iAkr6(b)3tPS}I6) z;+ar3oJOXtU{}-aH_9=HP4^7}DLnIWqr{jljm~H^1QgH(>N&BUqd$vJ8DBTX?3|>Ac+o80-?CobZ%dQcd2St zw@UyNIaIzr5i}XRQ)7P&|3T|>Ki|&v353pR%qH!+Y91XHEV{0 ze1#1ugHm?w)G#cSSnCB(!om_&W{hOzM!>{~*)t=xwlRZXi4eulTP#$Hv@-k1DP=WU z^VCdlG*I{Xp;zm**7ZGYsDOt=pv-ohAcN&5FE;7Q{_c)R#2e2LOP17ML(w|YO{ZS) zwjuAB6H_WGqXSKpleZa$92~1UL-?paeq?f}jLC8Pue02Q|j zp^s&;Ag85AZJ37$`T04bzl8;DI+*U?Q2blo0doBEmHK9!J|V_d!tNNSDPjj+N+@S+ z`CWUE$2^WXz$j+k@iKgo^q@F&@yN?)IlZNF;n}>4JZ+M9BRcyG92ZFY?EXk(_+-xw zS?MSW1YKjtp2HZa|r(b{KcLXcd3SdnkIjOrQ% z9=4LJM```&G~6(}Z7hb;1r~~U$&sSbdehdw6?hc^^(ONgE5)jK&4x!*3Vv9lMk(}2 zM*wrEUyqvWCry=eWE$FXZIQMow-zJ_>i4oqz z47CGcvIw^Z^RmOx>n3-K>%F!U3Ngw=lEo8wm=cMBODb?twdIunV~dGaBbfGgZbzjA zLlTV=^dOe1h<|}ci~v@9gsDe?wLUEt54_V^q24VV1Gf_kzr+^ErKerQoI!Y|Vg9X! zr@Wi_Alc3Cxb-;Z@6Rsn=L>CXh*g}%$6$FPZip`99^YbQx12bwQT2PpG!mm2iAo2; zvudrGC4eRJ<9FSM>iUq4U;`gOf$mLZ`wTg=*VBq@RYxrD2o4d6YF^KuLjrD$xVNae z=;IOtL|agi$}7`VmwcAOK+RM+0XI=mSjSgW7;PQ&_|*4l!Cfrob5)$xXiAlcZy>HTD&v7ZayxpUN?@})uDRyuFy^RIHuhYlbd0G@7dG+jjs(im?wv)J3 zCa7`AgTVHy6-EcXc|HLzxQoo13SIKISs2pHjR)iOJ(QDXnxv>*5&~e9wYPjc{)QGu z+7s;@Qx*+bNZrMvGCu$u*>yOHp@a0_KVUn%4;=I%8nvY8Vr*~ahmIO1xmex3WIL4$ zMu03&8ZUIXf)E7EK=(ANFhv{oYO0Wo^vt_ZSCdq-9l*8qQdlsU;eyW)g)C^@6l3`= zqv#iue!&guVu3fa9(eac>czl`OmUN@Dbb+x>wv4EKGsj$&ol|`t7(EN6;LFvTkLU_ z4M;nlgu)FU6+F)Uka^%00FCjL%$sjY5WJf4utuDy)SO4cXocPacv4SiPtDRG?r^0| z)sN+2;Oi#NS;r{*d|A*mm{UJr$=gry<9TD2Y@Xm7<+aiPqJb4Hl%h^)_(CMTx_#9e zdk3~=;mp95KMq4ntepE`PMY{^oAll)^=6KUCP9UfIEBRV1(E@7Ssk6LFCq*m#VvOP zyap!PkyuiYKcTEsRbhqXSK!8F3sP)D3H(#^;Ic!ru@G7jzzM?s>mD5vl;^x!&P)1f2f?pV0$n-wl@IRe zzkiAe+y9mCCFRq2AJ5N}y73_~m~}C`nOqMMH4DU%Bc9nTO@Q}0lYS-*Lsfh=k}Oo= z$IK`NP$NlMdVr}_t(yn0zHy^^oVk0EG3m0ON!4z{0?s!oBIiszX=My#G=;Axg-V+{+tp zc1ECie`Rv7XV9w1xG;drIk^~1j@WHKW@Dn#yGN`Xm^FP2uc63Q2>Cu|bg}&+$9Tt; zPCZ;3)xIfP{gQ74#<->ZKF;`}q1$}InB`56+xxj5{lICs?G5K-6({Em9#HDW4*n<> zK7vRb7Ufe%2E0U&yAsaM03uUzv8%`$G?|1Nf<4ZB>@WH+lF7S#-J0chs{DLLI?^|`>!)D7eTu%A$mJ#zMv!3MZ?>cFknobS znKf`ezZyuxKZ9PNi0DnEl9;WrTb^b6J6^A7!59L17KLKxFJZBA0LX3cH1#piElo&a z+f_h_6BZ(WI6t3TwQ_5 z2GOw<*1g*X8gYTR(C*Pd@cv$wOO}pB9zp=4^!UXWoH1q{QkLuyH0rQuym|f!-Zy;iJ``wi)P}G} zwCd^y6ihf1128+^VCFT5P`*D{@~Fv!z<1ef*?NupeT6VjSN~w) z^ie%7L5p-k7nd)Cg<1wMZ9T-`L%d_lpQZPHY0cJ(sQK3_an3M~{mj}^ev%Iesoq&a z{oUPEF`du2gSqGBeJ&}#O8pRsd9J~Bw$BX0#7?NZ6JAa#vEFr+{Jr$25E-KTUGNkd zy(v4d8>zQAr9jBHm?pC{}$0F+_%$BDFN8~teVExXXly89U{K*Wm&ZpIeUKV-u z3Y$dQ2U{94-m^f}3Na=k6&T=&e{(tNJospD^>i8Uuf=_LgzCkE{hpH9g2f2p+5udV zDlH1(iM42R7Fvdi8>LlVSuJiheH+72B_%o0vIS1mN*#anS@1Us*y%HDw$T>s%uYUP z2}I-DPyfn>O#?n+Bix0vr`Hgvd@Wr|&NcY7Byi&UmCu^t{nNFd6M$)a21~qyzm*D? znOMYT&&^Md@3dbb4Cu*{N!is14Z*{;XrxNz$ziojV#i7#jb7n!eXU)*cD)#(nDdVx z{Q$eeQz9se(INbePhs(ICOYTIDB#Ep1>sTaN9v3ZJ( zIn?htB%{trP0leH1XAZ~wkE>P%)={&1#EInO>3Ry0vt#5HGOqF=4jaFfe=-^AjRZD z;8?#3yKw}C26~~$=)qY@Xb{FxN7B4L$o&Uj0vXQ(@nZlze!+u8)7#lV^!uFQdvce@ zaI0*1K!YFpP?=d|MdQC)2qds!9Ug6TK^o@oG2A-o2B!8RXCTDy$SuYFig{c3U zaIP5u&HHeB7rSJsX&?OsBqLL$=^@VgO-_5P{*4Y7|J(Un>s#(wOL&S0`XG@u@R>uf zuKw9<%@?F!<)K5%Qo4l57QT&Wo(%N~kDkK;_%{2+o5HpJkrDiNtEmrXTi%Sy_c40f zl(aSro{DN8Ai((t0onDvaX=r{Vzqk+p#w1CYjUSL_FfwK8qSAV?HTq7;8M@7k%7fRMX!728 z&)m{)y~ULW@NXO^+Q^iiA;@zyie!TJO1WX0V+w4*qy=Nwt^GzaIw^dq>)2+B{Yerq zKQ{CGV_l&}Slu9q|73DVI9f<&MbsV8yYQJ+v8tNHCHiElAdPJDbejsy2>WTk^6pYR ztG}FuI>wgEm~EtXm_JX57JvSA^GhC}QHAdt$rc!Xml&}olN5ajEvmYB-GCu)AZ@0` zNwWoexIZ~;;N~M}986PwdLzkt6_MhQ32x>+hRs@GHO~S9?!B%i0mVx(YYUYc3VdZ2 z?ObUOmk|LQOD#Un@|c`J>{UNkJaQF${QZxDPgmGKUNL;-2`)Xq#tgM6mbotpfYI2`8*C}I+~)YUvV-K3j;;~o*9E59(DD!G5Yl3!%(&5{N&&35buom_n_@D*gkz0)w`ztLuH})5_3Fn*gl$w zMk!a+a9GKI&-GJx2{suLe9_oTL7<+(mpot{yy3~FE`8Xs0#gCt= zmH|FmZ?aYJhRx_ZMF}`EWN#=QJ~DbgOxWssqLjv*p=Fk;e-NIZrn{LaFa)T%1k9RI zOuv>8*M%&1-HRlPXsfhc6lmO7VutodPJ#Up!r7}m?X+}g81x{k@x`m@_lC3e_U(TN zvm7`fr$H|S*KaY7S$fOmy&`n9Dp5Mv&jm>rklKrjV?#yiGkaz?lQ2S2{{Ngega`gl09T5SJ|)zfA+~ zlG1)x@KmHZ`1O>rPendWMOJSUyIOOca#{qfu)S_nD3EwKBFrlVpq#AFI7q_(TIHTR zBx@{9~v*Hn^pAV(x~7F|$}VdzH?|6XGX`lJB) z*T+?cVpnCl>tp?UfPcTGefNz_jAp(~nQ2mlKkw2RrJhjxJ=~%o?y-R)c-=_zSKUc) z+WejxR8)g!yOlvJ98<+aDMa}4PYv4wTnETdH+Pwu&u(aBu8aTBz$~>f?=}5Z8--zx%#1u)8Bl|M+@5=E+<+v*GGVJ{ni}bNtaMs=>KDpkjm1Cw zDCAoa17mV5)8OvSZt_0n`C1oUY!k}*dNAUbTT%X)2F?<{!0ff750FQD4cKG#Mb0(E zgQqK!m!NNgq_-&Z?LSatlV2|<1k0}9#F9)$iB}LJnWZypxK7$n23jAD9$;>=dQJcF z0UV~*GRSpu=$O%ei!KjCZcLnUzdgGGKY8@XJsrQA130n3GD0c+7}qUm=KdCfKZe);}(f!XF>h@zC$Kzn44*yT`{%w*6zk9G(38L;BsAkN;rD} zX*h*moj0PQ9OB<+lv7h0qveo2VD#xdfa*saMcnyE>%8UMHYT)LuDG4^Dj;vV+|#K{ z6(A7^bXd*KZu8S7DA91y4KC02K3HU&-0ETT(k#oIck^R|B-*B%OEtl6?&I+;7+UiP zXe(#h6GOOy#c(PTgiv!Z)cKT@T5X~PIYwXYF`4|1i$5p7pz8#iZSS08H<}%=-YM*F z_0^FPHRp2qEN@SmHCM0PHHyZw$J@j^-uHkCV$q2>{>-p}nQ%eYtmgS8-5t&~W!*4q z3OK%B;?aP`A*Vd2!f}PoQiXusm~eu==6)ZUo|*t-?!4K)ItAF+&tH?#{=piZD{)^H zO#$%eD4*Z?Il?52-hC<@wFQn^M-qFOj_-UjO?uHUkzybXOnNe{Okd(MF49BoGl7jZ zSxJ_W0GGFR%n`A48`iKz`=>1Cb<4A^=t^~SZIEyg;-Bo03zPX6Ai^RSqUBTpw>a~% zae*uE^cAra8i?y$Z(N3Z=S4)vCk2SGHQ~jPjUGk>JK(6UqDgP6fg4WZxyB#5B1qdF zO$(GHEE%wsck0jQ`ho|Asbu*Ip|YQO z7gl|q9GzK4LZ~Ywa2ulH9G`+k+_o(&%T7r2!Mt!9Ji`8Yr$7e|qvP{D52#Vfz4_7` zNgQKQdX>Mq0D$h_h8nRL9Q6kE3f^^iOu9$(YvnS4x{4hka2HP_inZb|+%IbZSHa_~HOke2}rXH4&6 zdR}mREG>lGxDiymfR=UMd$?s1k>d_g`N$0@)X333Lb)3{_>9sStd`1*34V_FMc}e- zyBQEMacB>MY^DIg+wua#S zrojirRRQ-()(P2suXXh@7X$vlB2PIypY-U%_1m`l#WkHX)FwlRLUp9%+mwdrJ%G_kS!Ww%na1akAFqN6j{C(I)`QX5Nh6Mh^tYZi z1l%D6$Hzq(2exT-v~SAaPm)?A?JQE*RY_UmAz22J0~_FP{`4PX-2*)2vig}U7r$2z)YbdFe>p59@ zXu{th{vFj9y-|xjB|(P{11(KOE#ytuzXpfzMc5|RmTbELnROMd0Oh+ns35{bHzGox zr{V1P$BL@*mC{NdDh&02Cd}lNp@`%~E1tDwILx%B;grJyzL=3p3ARIeJp>k5CcDQ+ zyw?2t`IoP=ul@JpeOpTpuX2M5MqA{h4;EHn$xjSxv0<(1ECDh&Snju&>E#j%LJW8IPl-SV>M~LK`X>(Oslx2wTzBM(02h7WF7q$O8|mM)9%=yU zQuKl$o4nUl<$S*$dPs~}d1svWR%muni*gm6>K<$j&0-ew9JVFMas5^G#Y*ag(i=MO zmm0?ZZHzjJkeD?+X32Wiz7Z(p2pZ|Kb9D!sJ+TSe?uT|Po zX$>&Kk=ieY0pfSU=jtB`KW+~OmV@$rlxnnpUQ8thM7ksp&R>g~3c7Zef2Re$`q!{7 z5P`2wUy|vKH~)UYAR$bPSil3w!G0y52nyNb@fTe=pMj@C(2nU$;6q>AVZUOUqD=oi z9?TNT$Hl3n{^Yu`Yk+T7q6T9#{1^E!PuSC!*ZaIc4ML7&eR`2$LvkzZ66_uq5}SV7 zdh7_8BSwd{Fb_SwO2|SS!Dw9i(Bdh%4=4!-$JA`Ua^CrPzjpA|o9q{~-?haLs+G)< zsl^8)0UmJ=)&6-)ZFZbMR{zYLQZaNwuQj8ay{VGho@M%WaGc^2qCJt&!ZJvG>V_jv zYuZiDc+{YnPLlBPu*CHOq)VTk?-VAo{;}7`Vp|XHS{O%BLtE6aJ7#89F0~H_S80Oz zKOuMDd3j?zcEvAa?4)%gL#)6}RbK~MPAd)qk^^gW8qm+!`GN;nbg^eosnhZySW$J?vBHbPF z;MMfEcr2}nN9orT(W6IMd=wg3Cx}e{^tGBZdiqD5j*+K|dh;-k|8$ZeuJ5?h{@f^d z`+D@$)DdcgcEhQ4x*SE|f1wk8-(Qt1s%Kn9x#tEh*>a zlNo();gK0_0`TPoRG~_@in-nRmce$zC&y?6#vVAovL?%ez}$ScaF%s}CMz78%zVp3 zKLGNntEmWg$^y&X>;+?gK3~`&P;0(ds_EBRT)$-E1n~C~5)Myd5fnLD>HdBC*vZf* zZN2DuU-n2~TmA?!fJ0-K*wwB&jvY@LW!pRNrMMKhtsvr`LX{fSKM9(2pv07rN_$+a&C@0S_ zP=5hrljZ4fA>oEfOZRy!KE^j07i^SprOsxZvE&e-?@R$RrOvZ)#rdhutS?qmMkSe! z<`a1>t|1e}YAZn>5~RU>kMZGffY?N6*!abL>O9{AmUJ;*?(Gc54|zV01{9Fpa1)C( zxo~bK&GFBg8S^)F#dX`NMIt0J3FNQf@00~UGFblTJQ)a94etEES+QV1eod~#IE{YU zsjk7*zp3uA5yOk`C9XszWG+AuRfzJx>I~JCm)h*y+7&6p+Xf2gWW8tK8b~|F zMZ);i9+$@~9jdRbbol6rZ^Hgqz$%fWqu_R!0aj8*EK9vzHDA#$HJ=Q(LN(wOg*mFP zxQ(R{dZFjJy0a_uCE7Enz1x2qb z8Jml&qa`BDHj2$hqDJ~U;D7}cFpFbu) zhn;9j6qN~8CnUoL(=n+|_)@c5PL65Ib}>X1yT$T=Pp(SXsA_1>sB9kt#OQ}YPNCLU z_c2hu#{JoJTMYpcv&oCChRkzC&|Y2@+3lDj-G>M##cWtqQZ=H|#1GiuP*{1qr2ilh?#(j8$XYXXG%C zL^9Cuk4hI+Z3oxsPm#W|pU6IY+!6<=D>mBpqJo?%+uxJy zSBmh%qav$~65X2t;bPokmweJ~$mSTsK;z zu<2ZBSk)3l1U@LSWI)`B0(y}cb_t`Ga;6_p#$Re1PdY>XNOukfK)E@IetSQ`4XlHq zAO5MzH_)AKR)45)f(%$#9AiBmu4Up|W62UF1Y*}kOYiRUq1m)59O@>?tyvb3h!MZN z!}WMSH{!0@0D2%Vpw*@SKD$PVV}3M4hd0NQ=v!|>w|gZW$aXAG0iIuk>X+_$Y8)4(e&q>eslB)f}O!M$bz-Q%{XWh~jMfXHqL3}l6d43<(J$5@k%k1B%Z2$FK0 z{7Z7?$@9&U3o5KYV#E-RJQuF9?4wpO!EzeBZ&M57E!2*W_mbP~nrqqOQp^X;zAAtDgqAuYI5^U0a_q;MVkDG>A2*t%2=<&-|jxf|wrSHohZPPB#BB3n6eHq}swf!l!UERCdNW=I)Ps(x6qK;@ejF-ZiC?)DLE{DS)_2 z+5x;qw?S;jciGrvgj;$>p*!)L2CRr1D9~+E8uW1M=8Tj*-eepijJ|8X(QL#eWE-{1 zi8YI=42)l)OfiGryKFr9e>Y4KBQO2X=@+&wGd{zrh=hE50uCsKNRN}t=@^_^AsQpN zKlpLjqA?>^#gf7QCJMb62W#LT!hue=h%y|^5>oD6WW-ws5Az%Ye| z2JapbuKS@cxx;|0C67@-6i(YIx8K2jl=dqeP|QgcJo|avz*y*|w3eLz`78R-GG}b8 z91ZoE-L6h67-xd%EqIoz@$|Ay%ZN7bDImp4^v+miC=VU9sEe-x*hR?!uM>f^MOWNa zeG76~ypoGM-ZQkX$vZjW)F`2VG?XIVZXypAL11L}ErTOj=lznS3b*q4Gux@^jYrvA zpEIL;VxFcwZ@XUEH#4pF_ww&6hxawxB?+)>VrKNf>DDO;+k;mjA+5eUZ!Xlyg`GsX z>hBcn`W(qvg6dmPc%oh@K;n=?)mi@a(aw^>G}%rx1$hbc1vC6mWY+_%?w#Y*7l{vx zKqo$zK8^?|v6R4j_y#DDvxKAX*4-MxB^%qaGw$9nj!xF|wuK}m(R1}7j*PmmO28`C(>?FfTpds9EL zq!ILd+i@KF*$P_n6E!&2{Hy8HCc%?CqOcu_NNfhsFFcoglj%!5-)*s)n+y zH$K_%ZAfJZhRq&8P*-u2I8R+Rs@T}#pMfgJaWPj?3Ks3X3Og6;qACsVh_C7@NN(GL zk~o9?nSk6Br4D)zZiwOoA^E=p|CsBJpzqGkO$!@eWt~)AvOw|$Z`f?;F0M3|lXNov z?0EhvZexAa3<=aJ0xdv11e>-e!Zf@nLOvnFZfHoNzYnA>%kW+Jb6bC3ZjacY0_Bud zDCEhSl6del6Z}cu7pyOCjCW>PcC9rT@z(l zKRYw;8UQXrD!)O%Fd-eJX69knD)MN8D!JiMEJvz%T^&Aql{r%W`p>Xtoo}Z(sz?i?9px&_SlJ&45FwK*`%f^0+CxhtYsim`I7qD`w=5^7U98sye}Oy$lgC+u@JZor z+-rGE16MgXAFE060!TO-EmxU-66^+prt?|cTeg!#44F_^GNoTWVG%RX8h}AGd3EU9 zo*NUu{E5f>Z-vwz;@L)rwWI4woL%+*)xZoOtto;Vz?RmZ#yK2W_e!x zjV@Unh`y+YEkJ=jw(*Sh+O?<$zft z-Lf6++q=xx$|q=2rq%e?qi`yY1f^nJuQRGIWx%yBp9n#_)V_&kpgreXNolYb5BzDF zE*XtG?7GsBA&5Y>Wv~s3aD-|rs@C16B4J^w1=vpr#WECl}M-&TK&&KN8Ojb$aBXnp7%AqN)A{LwiCHq~~gpvPNz; zOG+kN7LJG82~f(?bdKdJ-*LiotDkv+FLGLFljEmNbXGTmrI)r*f5*S3<#;FuxXh+| z>adK0L){xJMh@yx{3Pgq%BuaWn_R?N_vf4?R$e(AWx|ob?g!-awR(=G#tq&XUVkxv z@$a?szk^LBM5Oy4AoL60_EJ<1Xh=$rLF!n_7y`d`OPv=EidjE<$=FsP5#|K}8{6$s zq4z|&A7C)*GKB=1Pnhiaa0ddAze!3c?uVv;1~!{gvCvQn>4vTam`iAMuQ|kB{}1#O zL{Ta!o<2K3kXx<`hP$tZ)d5!YeJuG`nn#N z;)B@LoF{m{!WIja&z4b8%NgrnHNAkCmaD4n=ar<|y-5QbXK5nh@Af*o+40@DZy|f2 zSijk`*6c_3nd*bWJ4-aRaE=T1p4@HxD+OorfUdOluPJFfB+ zIEPTDbs-9}E|S0b9B24Z6wlM~i=Zelwe`HIA?FR_keh_>*Y}8$xwgPOk=H8oy()nz zUFWC1XpH58ml+v`C>VN&1@;WI*m-c*F|IU%_Z32$Dq6%Kx=?CM`#pGmUSLbX#Zs;4c+k!Yi`9VmfOi<5T-V%70ob{%P7o+Ho^AU&wyj~igHk$cfF&TJy)u1HCFe!`5B#Hx z;e0|TxH@rZU}HsZOMz}VWARB!T1}*?(9hO11gVH2>o?yhcXZ)Gzr@;{N|$2Zpb#;` zzMsSkY)+GG0l6*g*LmZj9l4fu4j3Xi9M`b{lS1xn%H{_gD|g+fplLX(yyC+e1Du)n z_PBDsg3^9;dI7JHoRXotrjP>~KDjAa2*IND$!Bh@Lfg{HOlaw+bUHo&#&CF@t*%!B#T+>1R!?hLF^rXyAp)Yy^c92AE zAS`4O0`*oTtw3x#0v*__jUL8-xBEL?5B2KR2&LSw9|@HX`Hi0=H?W!T>o@gnNR)$ecw2+wKXUlrAzUjp z0nIOKMEhW16j@9bPRtaxf$G3zxATS2VCoV|b|~RT$Z>V$tT&)9yPG1xPj=<*C*Hjz zUR6OE??B^J-@D>dYaT2Lj0IcCU&Qc5zm+wOt%4ZDaxcCCsRMz_nfjf|-P%ld3y|91 z^LOuWS4Jx}c55(k+aBjiNaF%x(j5fH{^&oj4KRsh36dU3XTEVDoFT+?9>`Bb& zX9g9_^^AU2qxFgmXd#Ur8Y2+oJ3~fMuSXxh&G%_-CGeFJh1a7<&0sZv`(wTH_roYb zVM>kmMq-635i>EuL$?7MnlK$0)Kn{g-hE?6=?4FmC*1E`zGGNeBpijfz>T|yGVura zd{G6I11xMlKKZ3PcMH?TsKJ(@AQY7d#>+NnI4_qxG>kZw~( zV?Tm=@;5CRN?V z`}AQJM-GUtz;=@b&DQZo9+H3~J(Je}sikTof#H_R<(moXdrveM_TEjg5!tn`G)mye zrj*TY8`1%G0bjI$im5Zk5yK5cr1QAndInt4lLO%k%miII3FP^=RMOtp*n>ua3~Esx zqxDIeVo{vIAAnXWC{=2R5x$&;!kg*F&qcd@yt{Xn6ss2a)oDLF0}q>=@Svf$eF2nQ zyPo^dk6)b{&AA63UGX0!wuWmg!Qr&O42q`Qy~|&WS)?hc<0W`XFn+55)4h*IDCKL zyN3;?_ALB{Ed5kNJ|Jrnt9nYS$8Mpej&Jepd1TFs{4xTpA7rR^CZie|CVzC@*(v1{ zNW%$=zx)wIeUN$eeu@D%b@cuOYe)Fb-@x!>sIUXymyA!vnIgFr=`t3Jd^*7Ueh$^< zDXBNBu+j3`#uu_HSsVHTwSM}9V`3g@)*M(zQ{y;Eh~6*f`cn;4PN?lxGRF2r*?J}p zV~_}gE(Iu;_HSY+4YU+Tmt$}a6m{n9w+3#lVELfWW1fbghXT_ig)x(_!w~P6T$12l zE6%L8lf?qU5Qtgp4r2QmH2}WyI~QQ{oi4S%=`6aQjN#r=u>)`NK>H&QxFD+4cuY-Ghh|LuMuMSNt&+{DSr z4=NKY!(6D>3rn_|eX5U@BMa)oyf_5!k=^lwq?zed6Tk46+pR<$6cPQ#oFEzv%DC6| z|78g}xtif*vzQ2`t&XixswSZW_)F5wL=fZZlyTl!F&KiZhHEpqjNOUbK(e|VVhI)5 z&Y4|>2qDVBkp)8x(hwjmn!<9)?LKq7X0|S${%5_efb0k0C81KrjITSHhe*If@+I4%U5~jT}v==Jf>`)M%Tm z1&(>%qqrm!ddQhXdB85D0kDb}O+f!0gZ%-qBu#D#{g3N}EQ4;Zmy8i$NsD>&SE!bM ze=Z)8gJLAcs{07nt@}4l=zSme&$uzZLb@bvBd){LTO5#X@=8vQu4y`N~8H5Vhd zlQp2PgqyVr+V}%%OwVU?iY|34S82>IKh5+ZQlIG5`WlChlxU;@wM&!Le-eVphDg^v zHz^wq7|pEQ40^8U<`BPrvhlo~Ux|VgG`b(9Xr8*ox#pg@C=@X5vRJpO_|n{or%jol z)Oh5lH@pxhgu@+vOVGHQ|5w#Nx9p1W?4-Cw!35txrz2?*ovBL3WPjz^X)ylHh-9^J zr0ZF;j$flHj)fmEDtPap_fNP8K916%c0LJTD=0sLcj)Ky9Nc9;F111gp&aef;;h-b z9>X^B8m**MA4qyP`H5NO9rn2nV1^KTp3x~DP7|NWHk^JamLo{>Uv-9Jxg7InW>m>y z)L*blV#dsF>MB|)daC%wkN@NB9N5DC8ZdmamzIrX+qT_u%Wm0TwQRR+w`?tAdAZgy zmaTX1SNMO1-}znFbFT9|_dVG5VG{$WYDB2Q{z6AO3yEEo2Nc-jpL}cRyrNoWKiR`^ z?(Bf3SmOiRqB}L6QZ)ZNOScH^A{=wu*w;Fa`}N5_l`McK^uDH+nc*WIp1o*TIq$YP zr-09RaA;+}8FVk+~X=e@V?W#`A!2BwD&$B{Z#Mk=767&8Ua zrLUj^zooK#XO6mqGbfX|13ARM9BO`2jSGem zBE;Q_;(=|R+3npoyjBXqiG@P)1qAW}^Hw=w6M~-+$&Ex5mwmer0^#w@*qOk`>EZ%?@SLTtDjoo}#0FQ~wnkqm8U=gdy3*30p9l+| zkk+d47F$FBLKG~bLd$&S-|iaLCMIi9Y@)31movZ5WKTl0WbD0x$mS5lvZC_5+`=ZC z#_u)*eOgT6*IAu$btU}Z3$YqtuIGS8*!BamrNySn*`cUCLu=>6pAWa;7c;Vsh^9W! zlcX?<7*e&GQFZ02)o1BGg)VzRUwkv?1UVGHaa4H`B=H^seL24 zI01;?YG2ls_E5o)56Kter^)f(oMLreSA49(GiF<>JzOoHnKntV=rusdsrj}7JLrMK zoSo7;+zS6(g}pc+<+L#!_8vLxlPXAm$y4$cjajp!`YZzq~tjcVsU)O@a#mnoG0c$KUpkA&+m&zeDHQIIW8f|Xh@ zWx{)Bt*&x^PwkyFuWp1%<+ucJ`J@}oK<~>Z)Hb9;H_U)7)F|TBomRUQ9C(^3lE>yE zyTC#!AfHR-))#*~Skh=dNs%9fOIO!{$2GqqJ~vijpjz^e8?A;nXW)MQe0GP>NCabs zS&JK<4OftCn%YmL?dC}c5@OGiiO2|s#Dq8`e^GBkheSG>xu}bne|7NUhY2_YpHu~A zZ_T#JKknnap;nl=53jP}YIDflfj2zG!M*t`lc4eE}f?DAjFZC#r67wRY*>l&}%(>=}82`S@7~3Md6sogIQ`Fd}NO~#@xJQ z2RK#=un-7^|Gei33++7$ALu#Ij6w|yt`EJ#pcV~QURuf9z8s|&1u%l`0(`A`QM!-? z3bMSijlODOYz!=H>X?R_o5ngX*%P2x&H8gMg_l$GfllFB_|nN=(dl=c#hT!0OCsay z5b_*0SrqE;6 zb&8vmb)Spk`)u>wth^GsApki!=5nHqdHBO!5BfBcBy zWn@Z+5Cu*3xL3IZ^*Fn`q-)lP6cF>u#>qY})veq!!%>KaX<+H4@7o^4M8G|ffBH@a zZ7c#~N6`mX=(bjM!)CK^4B!uc?zb3UcK z&>fWOZna6zU~Dlb+y{MSn?2$;>cC(Bb+;d+4MK183pQ{rj7JatBYd_wqs`u{Jjn2P+q>X@nc&K)Pr)WyfW(I3v_5 ze@kG1%jkExb;Z255i=2N#(o)Mfb5h}{aHVC``^NMhxf=dQ)&G?lD>{+mDxa(Krl zYPGE79gA85EebaPm?mD}(YY42&vp6B!lF0hdtcw zE$&<)29Iu3qIelfk5P3dOBJPpj zQuO_xL&Sp7zf+H=Gw_l}f?B%@PjpHE5&92ctei~#_M3Xkd@54e_boNA)9QFIxt^xK z7aC?b!ul9|`9|gW_2^V!w`n)EI@osm$-KL%?%VH^%EciRsoElNTwb{lZ2zQVVa_&b zO`{DH#lOHc!mt_9GqtDDk7NQ~&^!B-AW3)iV%%)M@kt(-n_FMXtn#v7aA1k<;rm8e z1l1DJs!j+MCw$U{g!*OYFd>KNvFwWcmE~@PwrerXFY#UfsGQ@2Mfk6Kx;Ian|C%#O zm-SqVbQ3CPe)n{uN)d#Kxn$__ik4fsFJ#<`&Fz6mTEvY6D>7H?B;IIZR7K*CUv?O# zttusBO?gwi7*c=*+}?F1!(1Dp=xM5!l5g@tKr>@N>N6*W@V}w887p}2e<-?QblhQM zu~ELen`+J1+p)gv47Hr)=vd134@02&?r#d3Rp@U^JKg#pqaD9!NBun(qS)gvfm^LRv-b zo>TNI_#%pK(|YXfPyk#pEY(A3vu7B`L?gW)0wHfmmSFp@ZXE2rrA17a6F|Ou*KL3a z{;|a}TvGG6qhzi6=dnkk=pY4s$+~k{F%Ww1+a-!?n8=|T7PtQZWKJ4~IMN{e2tBCQ z1qiCLfoMcBKZ=D-Slts_5z_OSYE$Y%rCc}0EwUpVi6yiMU`I4HW|Yk&Q=U@qdnBxS zO4uLK*jI3Pp@y%`K1~V()L#=@Dwtj~@BI*Cv)%`4knX;p*YaP*`2*6hS%Y+J+rJ(Mu#&&_gO3w`kg6&Kb{}`Cd zGvAxg9wVvHIZZa(($!LtO$gP@QhYX&V(}Jy#{=Yr;(zDG-FLtxYTJVpBcr6KD4j1Vbo8?I+WMO;IqojmufVGg6Zph0h9*2R9jbaccOAd4Mho~ z*+v%o6%g&8^t^x!jM>a;4y86ifu+>k+z+jWcr=?J!*08h{@E5-{Z~wIX0}ekQT&2b zaxG7ZXMF7Z)5C83C)=i-#Bh9F#^NfFRMh7$Si8oWct_|!owf+cTx;C-{zp&(b^2+I zC#DWqn`e~{?Vm!anG}%t1Myp}j1h zrvzcG^#N+GjRuthZ9DW*Ap00w$x+|nr3rW#6~7l4O~kg)0$9&*&q>W6;6(3HE5p=n zt>`Y_rlQ_3YRH-S1-TWpK#USoM;Q)7rz>St24UAb(K9AIBM$ zh8xNrXOfHrP=)Rm*jz#)^!_QXA6ytLV|ej}l|{m}Hf5CYT1sUClE*?tuKp6Q7=EPc zg;%K@5zm5`XGU67XqXNZ4xG8hHj*|FGsd{?FCoVun`VGCH>Po%v}RE+>i7xIR&=H##PH`50I*s@js^Q-=jyXS(&temOLlH-OD)YR_o>{pgYX z%@yFao(es3nMkR{+$-TTb2t_%F9Kvsm{L(F0**PMOIJw8==~8P&w8rlHNz z*U#e^kbLxgE>d{6f5ArdW+yjPBSiDyWkYn@iL4*7f+Q`q<4^3EWzP4h&cGQPwgL5* zXhGTrPwkOSx~DMjQEwGl1A*7U>;j&0HpnK~_b-3z>=+>*hm>pYf}KXrH{DDzZRzDP z?sFo02FC-lj$=bq^_1O-E55GUcNqPY5zu3;G)xGV-Obc}U}K$^3nf7Kk3DttF=fty z(`Td7JF&I{v{A?J!U3}KAT@B@6H{p#4S}+qB0BT8*OUDD$~LpPzrE*bIT@ZPP^4l~ zDAS1d%XbDaQVQYDe0xZfRkXCX_tJ6%pfeH_)ujX}H8YYV4+tqkeDo5!EVjjUEwL^( zV9!Xm_aO(m^>@6dI9JWmBpGQ0h0(@b=R@6+qb6CEx}9f~WRTkRKN<_CT-x50_HQWA zveVGG7yK^N>Hxe#0n$97avRoRL-xSiR0IB~J75Ze8ukxct(gAG2pD_c!1FH0>`t}j zAN|Jx+FW10eiQ;D7oKP`{KKg3d*Ejn_v@fXoC1-XklM;2ttW)yy61}X4Hg>z0l9Rq zD=2dKvu;Bm#Y;DhtG0W*;z2dzT_NQbZEiCKhdcfgOA+5#3&nsyvygGLo`;=T80~+} z86NUd)^lvj_^;)<>AIiM=FYJU_bnkGZ$?Y`~=HkYaZQ464FGszU1Xs-DMCIt7nsqXww_!tHO88!r%X+mrm?>k<@ z*MN%tZ59h({Sxm9+Oe@FYU*(}9VzH|%pX}bAu2vD+?t765s&rC7$zwUb`mvn)zzT|SZd=Z{>=y!cKc(pTe} zMv^Egm=!$8VzGF#2(%(#dei6piCW@Rs~5q$sXB=HK(OyE0=4wL4CC8Lum3Ah5fs`e zg|sNJ?h(beYzjbB{=&|jZt8y17|Mq+`+f=Rwzxmtp=m&7SsOWy$jEhNz`yf|XA|sH z7bRvmN7V#B^ELzw%6oSxjky10Kt#x1@S&mE%SPTb=bb8>UORxS0#Q%*jl#!ZPS3R> zq}U2~jI%s`ZUj@z zA}l|=JxV1D5vqmVpBeAXneU7vLqEM!P~+({$phF$c^Fn(V%KE(O)o8<-iaHVE`=sA z>-~&L?6@$%|HqpR$FB_0se?c%g6d^6(S2{}7~iro^wDx{ae;)jN&)1I(VruPZTnV_ zEM&YFt|-bn!klmzpl)VHL!HoeR0mT9OugmWwypG1lKPeqBx)f|XNy7Qnlzb2KM2n+ z-oO&1#fR_ycA7JkoZt6zvODK+H|)v?(f<)f$i2?~c>{(x+!5Ae8bf9WJo2u4vO1q5 z%vTij4$Cx-g&V|P3@{(E!#mV1q`am>qEy&g9?=N?P9$H?iZh*eDzW_?S(ocapO;V=9)Sy8 zUv;kzMP`{Oo5aWa2Ys)1iR4nO8~s9u*2#OdqaY4vqc4a?Q^~z@W7U<-x9o-DE9cvY zX9zR)V%chtC1^dopomRoLFv;jc1j_OmEFrUu5+ZcvSU0L@f5{OaesA)PoGQ*&*ZW>98Jbf&VkfZG&kL)h2> zqEAVU*OoE|4??F1-W&8L_HpP)?tJKGE}LG!W8b<4zg*3nduG^(6iE(u(g~v2rxv`s zKkf1R*}(mjG=+Qwo-@7el{BS?G)TmMc&vRYp9%PjEunmWuig z5!E`3C}2VU4l%>IPOV~26Z}@5;d>!d8+*``qxWj>W-e-ND4j}OX(S7)?o3cG+1NRM25-h$;q#5FKDps4>ZP| z=fDGmlgcEr;hT^06<*WUs)USz1K+ADxb;!tPcE&cBVxgUTh+H8@VPTtD~r1|q(|3v z5+s_;m5t`!t7iw&P|*P0>%(BPTb+W{xO44glPP3U%gdJ)pDaw*lMioWBk}>XMQ{ATtsG>?&IZ%WJWLvI5_xN z>-s!X_oNincn^{1)M0|QjL=kRHJt=c`#!S`R0(bwVXYxwA0MbRUQod9ui&z0%um5)+MXd7)Sv4_4Dxzl7)eo$?c%*n{tGty zrjNY|ELWU51ShQ-OmJmXg_)O`ZEhV039^<05kQb1(w9 zHK@~RsU{FK5D$e>D+i=9;}YXm9``GLbJ)tgHPis|Mvwhxfx1X)R83^oCfS%7KXc;5 za*Xce|GGzSR5*bX2-$&_CIa&t7;%+yE?)z&U*dt*9jKu>%)(s>WpP z8{ePKr5$B>Nm=S?p&!VS^y2dVISD{J?OPZA#|c)2?`;r=*c@pbzUIy*X`YixZMk^( zBt2+Ze0E^(9GZVBGKxf*i^)5Cwz z8BZ4JyHVNWnd?t-bl(O2!dc~LX(ZGH;jxS$_^0x?_|d@U2(G~_veNS}AD$@h;0|2A z(0r4vf>4?UMyq2Gcm>i!I6pdG6qf(i{*Jiy{TXqB{%#KKDM_{yz#N*UR;=FBWJ6F9 zC!Bf|P3(M_TH2oe(g10y^UawW)CbM0XYtUaF=VUQc#hRlU>-3h3a!B4jFFC!}CoMN~JM|XF?5F)lLmr1ioMtQzYe~udaAB*Fd z-Mly28VqQ=EAM6??UdaayK1f2da5rAds7hh`F_(mb4v;J2kYl*|xOa__RPAsW2C5@Tw3xMZD7fo2NJ-G&TTBrWG z-3g)hcMI*VZQnE!OW8!1>A&53G|{T+I4rj>-&~E zAdzbM-#D)nf?qy{P5LZfcX>qyjuU6kmLNmEu1ONT;zQBf6N{peejBJA8AdU2E6}1#vU(Fksq_l&CWo|jrO;Qf%s0Nm z&n=qLcsg0Eh37Nrk|^xNae6I2&oJ`@sTD=V07_IEw1ta1^bp8NA@ z;m%ia(hOb1S3rTpgM4zvpBC`a%R((o^+;JM$H&p0!~4u!&0q#Hcs;hr<;CQmZh=`z zV_Cd8){2ITcbWA<0u{ok&`n$NX6cAB!xd`;OTcI^$<1mb#cJ_B(pnifYx&1ra$&_C0n)4 zR>H+Zf8AryTid-YHt4Rj_BUft!MW~krcUN4xhAJEv2|*Nzr&Dv^Elp6rTM3T@kyCL zFv{Jnxs8}l@#RK@>eG?Y$<`tpXP&4cT2u=u%}-fkaAMm=nzm+JiH#oqBGLtqSAx}O z%VwtEgkRQXq+%EgNJj@uIc_Ydr@RQo7z@}isY|!4h-JCgy1Yd;s{ZL9*Ps#j?OAch zlY@P30`~Jp4F$vP*ipd9PlKd)$ah9ykfah;Rd)+gOAjLYa4!s>7?tMuI3Pi*k^hh6hd00(b`8aI-OxSxwDzpPW@Mpj$0$uWKk0#mKM8Gfi zWBDQA*(FdVT(9(s83s+Q6mu_?(e)VB-0tQ2C@#H~=?%J#F z(Cf=6vUviU9(wj{FVMKO)2$LjAag>?Kwd3fPJcY{toqqZ?k5@=^mzcAlcD9fr44Qc zprVCfIFEMkW^jTuB;7IT*Q0&38bRsvV|`fdc(1DobQH4G7zK45%hkhFO`Gb!Uy>b~ zO)%G^C)p>UqAJ;gpx4fxt$1R`LCy~Qh&JEJ4`?I$;UnH08e35_OU%RIo$kOdX(>5v zdsy0$mp@O#U)wJVgL%fZDt7yDao^s7O(>(p5Z5%{Ev_yM3l>O45n;p)FL~@FRmWw> z>bylzhK3tB+YttLXVF6U3$D-WEtlk1xNUBu$T=+LNFq1Dieh7bsXwu(LbTwEmzyVV zCMsRRiN=+7Vv%!$QNILz%&-%m=^VD!B5ydDoO-Ih4_d{UAx3)oIGev1yFq|HamDt+ zG=eMKQMr}xOoIdWF;%UFOLM=U7!bjG9BlA&ws3OvrCf0(SDxsj+Siggncs`D$0+S*$-b=0{_lL65fzV_ZL1juZR^I) z5JOey8BLIOox!Q1YyLi<+!jqed*jUKY>ZjU8|(#npKO%=ozFa# zgD$IBS}Qf|=D*zW?+W&Ro_o7M|(WTj4^uac4DLBqnmuw zxi&-`LU;i;7(nF|k(nbl>#8)@$|3^|?)Tl;--&1c0!X;}`fx+zS+Mg;POPdlD=Q9o z7$W)&&hzVMOyF@JcC#)$dF0x2zz4)z1(w#MKm0$?Om2rn6>!{bi?;CkPAdEH3Vi?d z3j*oq?96*XdfyK#ICor+CK@_7xlf0G^FO_3_tr$w0wszO>Phf28eP@fouM1ivk1|B zRXG%!I8R3vwY{*lV26TPof7Ze7a29-wiMcnyYD7mvkjWM%tYGx`=-jt0E1%hu$S)Z z9&h?OojW64eDH&p$5-st-+TwMuzKPYU^uQlpUq_sdiX&1%{9H5I}g%Q+mDE>vJJ+{{h(^AT` zzCKUl+TD<;;Z92aP&u*Ss`ug!%Ew3AdCj&V8)E7^8a6fm@jLewT+8*KP0LAu2c>31 zgkWKIAH0zh>x60z`4gk7@RfvUe^o?~teGs1tfHO>b30tAlVZm201Y|xw+iTOl045`9*8d)w)9K}Cyzixqc^U> z_ZGOb?v-B9N&$ai&w|Q^bN_quuIDe98C1?j?{kWNIDS%PLy(JkNU1%K6|};PV>Hsvncl>t0FPyVq}6mKNpM{qz~@M zymjz}i6JP9#@0Au?tu;SeGLCKXIPD(8hYKZ62ETFW47aE@Fn(NH$Sb^2Y<{?ODgi} z1gsSkkmV&G^6dz=b}%CtK4@*pm{xTM<=X0=*}!UqGEgXu~1We z8wWZ1lns`0*H_RPxZv7PM+7{wO(dYcPO_9yTHc%|AKvw>6*0}^z~kxyH9_M6-cGX! z+e3o5Y|JkVYW$XTJWzL283)>9N^QMBm$;N?QTMi!J?i(t?nrli#H0q%kJ!tKVCF?C zXE6jo7Fb=+@;oYqz2OBCeB^?yHQFwPE~O|r3j23`;Fp2by}X>g|02)qE^XBD)ap-I zhF$NzzJA9a?~hNGBXdFZ5p~brTa=nSqE?2cNDLlHb5dZE(}S}Zkz>7O+bvL2Cp7sd_#K*3gCMMS;|(ZiciA1__&Er z8(1U3Lncay?9GSlX-CqJKtdXwckSSZaOKY?5?6BckTM#!l<@jS55qh2_dTpWKtnlE zc?`w$fIt%K%317o>Kf%c^Kz{FZA3c_j-60Hpfjc&R+)^vjG|DUz=>By{cXSSjmGIZ zQjuF9>vdHL*sh)tWz{4m9*nCc5S+MekYGL07wM&W5Wg6B67{G6&o19G6IWcgl`3NA z)!_u2OVzzkM=?fKJ2H^u^qHyP?uz@TZnc|aXSkKP7q2tzy;`ULFPVs6wv3xO?BU1& z<(s%RIvSL=W?S1|&kvL}$uIoIM#^a)_~eP>Lv$|ytmJR2SZm^kYdbBUBOzaoq9|oX zQRN@VIdBB46DA%2Hw7~MfOcQK!#NTAJ$%^6hu;IzK{D0i%LuAdj<0rrE-WoNg@T}B zZ7J}P=LdDZf4QKB3B+i{$?HW@!JY;UgWYI>S80`p!~ z(+vS^Q;KKFZ-dOZdXqe*_Zrk;E&IP;joO_G{oGL6Sk!>c2lhzP6#lO_#-b%RYw{vW z4otSIZ4I_X=(9=qNC+T)14Fy^CtNlv!NYdlt{8n2&VC~wbGqUi8#Ww&whPGiO@nR@ z{weu8Aq=tv3mwCAgW01=Nx->Y#+#O&pAf9S3P>W}sE6+J2;oi7PsM(c4XQ1uQ-xXj zaCt@j)e@}1`{}E~^U`~7W)0U8HVJ6oJVeoCmpSI2dVV{Zd;)(i&pxIaLl8T+gRLKC zS<8=za)!TH%v{q3h#Q&6ivbT!F`;HkTJ<&sRpg{y^4BWnf&$n=rwdB=9bUpOe1K_P zkeRgTPDY@wZ;Wzm0_UatIDpgIPT{j7nrjkLKbX@b5O;B`w5JtLT=_dnx(7l0vO6O_ ziW-HG|M9%?3rO);mx<2ZPqg(4lv=KiZ&?k^|Kj$g61EMW(n)KS10{#+5c04AX?hOpA%% zfk>Pc_1B|wtJ)Cpe3D*_LSWaa%~7~-TGjfeJc#5T48a-a--hZrO~?~BOB&g`@(KH*9cCAIlqX_EZhRbSG}N zX+w{Y^cft`=}eksWBgmJD_4&vSYiFo3vjc-l#o!k8b5t?@gkA$w3+?gJ>_qa(cd@T zXu}Q=D^%^3rxHIG^CNvDIyeC@kXQEIX5s2R4|_uVBNGEy_#W{7F?E0P#I7{y@*t3> zo|kl$!PDfH^BsGBPk{l%hL2_zLtc%BLb#$^&%JH3L_B>-rh2KrYid!QleFC9BAk%{KI-?XImtgdKzRq zz3S3OnGcvZ_-SZS9pO!)L!3Z}o|DmZWH?0L5k?Fl zEyuAKLKs!nxdlFfd(uGZocyy5QHPN;Dh7DL^41FY?;D99U`8y@B=6{fG)Mfq+4#c{ zxWa$kzjU@OzKbz~@_#%9jV5A)(U=ppYe@YzL-HHd_hPntj z80J2a7>LCFxg>^hmysEIm8c(^jhns4!H=(n_{&-N-KB*3U8qKSijlF=HT7X0e;mE%2gmR!5$tHmEYHjb4aPkS$r zz6LZg&NG0`C;D&;=UR{_KdY?TWT%_+bFBExD@mFbER<1bdmXUWgbN}GU+17!_cHP9 zHjND+GyLq+i2UJWp!kPS+9TjR_uzC};;vt9DaHPw(A(rR;4cch=LzmEwwJD>;tE)} z{bGWo<~dZV5Ye!yjh3T0b)J*wTq{^y%|iFr(`9&5MjsdApGOx-=tT+ z>~N?%*ji$I85_K{srVo2m>G20M$(bmT@~CwT6;xW_kWj=Ll8aW9S1v0j!&=-yj36b z`aM<`P?4xn)=z1EN8^%x`?(DtX$BHZPUreGO!5i5;vf0X#)~l)e7~#lFLOIY3RS?M zu7UAX2|gPnPt|wiKy?f%BY~`rVG<`?F79S2yVWsMpo(zBc0S;M&4VR?fnY)eR>LT8zebG48`v>*c`7N1Y5jW+!3?{P^$ts+t z)!~Fbe<|swLS-dP!9CPSl>YHU(;=xY20Yj`$x8A3tbJBpVE&qCu=Q0d>)>D$L-VPB zrgy~a9?(B`qv1B5FWMf>mr@1s923zK^C|BNs%9tZ%!9YT05%La6eIAq93#>u!_@1S zE^G-TQ7GHXk(fB1)d0R5@XYi4Y+cx-Hcci9Fg8TmNVaGRXF{ zRTl^QaMb)9@*&Fop5rkedgnLZ@OfajM(>yaE%iv4kY6*Oz=lC^*rL6voehm0QGD~w z#Y%uqt;Qsn^wWt|P`(K+W;hiOm01~>N!~|*NYW+V#&XTB=Y|A#Jz$!3M)XhZZ}W4Yiq0|L;Afg(&O-CA#dHvc_W{y~K4#EfihwxDoS)JToYjv&Z`lVU1kqyTwEzr$pm)qBCf(CufDpV)mZ)(LV zPIRLjKBky7|DCmq=p@QDCZ|MLh)DU)vm)An^W)E?ke?e!tFv@3kRiB`hxS+IqR_x*(LVqRJqU;CcAolP%1iUZk$EoZ1O-MoL2v zHP+p%4$u{jVW;aVstDW`^g$;`Er@hU`8RNL*4}kY4q#m3fJdHx1+3;WQ?rje2DjBd zP*XT(4N;=TNB1L=IhhAq0EJ{}l8^q-7Uvu}q-GQ*GUPHQ6{&IFLOocmLxp!5&{|9hp09PnMy^ih zXdXV+nbHw}T!+>*t3x(_QSgSiv*6m2O$1+T@rWKdOJo=+%-}r;Zc`KI){~XZG<(`r zXo7=0#6Y+m+fCPFdYnR6YwQ7ZI-l7}JLR)k-PS~@c{Noz2W!`wsnjvQW)c0ix%5>Vlf|kMfPj!v#2<_Ivlxr-Uvab%CgzrFihi!z+k`k6!6z=$cK7Lzd^O8AD4SZH`c~SRlk9D<%Di8`4Ey9lYz7nUpiL z#T3z1F`SU%m&%=0Q0^z&Pg{szB6+7i0yqp1-LyM8Sq}-4nP^T`mwlCN^xN6Ll$J@v zzjx_}1lO95xvZ_93VeAP6({pK8@c=!++^yiJtu2876-ms0}GVciIOXMoa&QaHk{dw z?TUk2NA+ywl9)MHO-(lLa_G3 zeKzPJs%;TeN$niID>5p0|2o8|_ZQI}*FopgDOu&6Euo1rvYj3<#?m<#Rux`HWj|sq zktXgObuB%3le6dIc^l?c+;9WQKA+>DKRqG|Vz@mQMCq2k64X@%@E+{5!C-$t6=(yU zfTP~|XyqS~RK%L+suI7%)!qWB)ca4_w8AZ8z9yhaWg_=;#^9yn-1ABP*_@MHMvFC_GMXu7m9tJ6x z8i~*Z&#@IZ-dG}r8F*C5r#Mrf44m``ZZC5*)(G*6RYPaEe1s}U*if+GbZ2LZN)y<_Q_eqVsDbAyBnw_00(uL z@=;lwm{8K`ER@>^>fHB7C{(*LMy7DGo;Iy1pcHb@EK^3mJ`Bad$v3v$I3%&HMHp55 z=;2OS`62ESxOcLRyxs{ep^O}Ntr(E+ug*yw$JzAaX(UOwsI*F$S5Gg4Q;$tql!*9E=R4$jiD3M+}MKn-qMsmb0F{)GRN{$6W_R zHz=@%1y^0pH`EFIr3z9(doco%--$=7n!?!`9$tXuswNB1opooh=neUEwOwT|f-lRu zbi3uk!$?XiDpN*`#}CmqIjNU{gq!2 zRkb0KYM1@Toa67f>zG>H9vTG9l6 z35Mnz<5Zb_L3wXbjZJK&c^_i@Omt>FJIhFLLP&leV3|+1@rccPsNhVbdQdyHX9<@w zHzE<59OAL zP$!K=wbIn=Ux^fN$8IIuZ}bc%<|wBA>50O69*Tl0LfP zK+!*gY;8=q2CRC+-7EcCb#YnQF7h}50xni&Mpdil-EcdQj`IDs)gRC5$wfe_{L3+y zB*oB$j}A#8O!aoS;qnk*q4|dMxDn0cWL;3gGeUu}FJ?3?+gHn=ew!{oA-xBj`mrZK zZ*6Q$<22K=oXdqAs=xxCuixM7&c)X2(JYW`rQR`={s${b%U;Os499HeK9_3mJR8tN)+;mcm zBS$aG*z_2ro4rUE3^AJf>%azc>x|?=iRNnlpD=m^i;lHojIxUM4>$c(%{rqfWq|HE zxdXPV*yqRJm^k!#$_7+6yB4x~yBuuT-Kk&%Par&@vtst;zK)4^d9$&916}w#|JvI< zKXlIuyjK~74X96M^3~`GK+PyvdBBnUQs4K@S?zuO6WVpUi9cS?EiH`^2|0E9wMUh*n(O?A#m)&RwboTBqOA~AkM+7)h5 zO>XozV3mHaj(RY-a8mgoY7J4o>t)o|=1)9-7>|qH%floIa$e$PI-+Z-vTWZER1^zEYfsTl4zAc80KJnq{b9%u|tjNFKmBmgu*kDL&N5~tHSSYDaRxxG_e_XpOXziU5u{D*Q4S^`W`k9)(jY?L~2-X*Jj~w zN4!HN-eM!*H8)-;5#hC4_xGtEg&Z?+Ea=#++l;K?n%=R8OVa|kQ;=U1ujv&~zcksk zP=qr5uva*%&ZcJLJAbghdw~J;Ry&fX!5Mf#%ilzgW+~9G>AIS5pfH{ip{M)l)Bb=< zdK7nR#;IV3MgDMy=$81lXv#+@ENDaqE@Yuc2ob!w61!YxJ^!u}P8@*Bf1;O;7iq>7ve>mGR06YeNgsy_% z$Dl(?evV6LsaJ?h)Cd5hx@H%J?fpuX;HQi0bT&Ota3}UViWW-K?Tb^x;4o`$)kjGQ zs#upJ5Y@@Zwzsa74F|V^vPAc2M*CJSR)AB!Y={hHt3mFsmrUrg+d{9Hq6MH*GYv#n>Rf?6cMOr|FE<$XVP7 z^F$xvvR$x1y{u)-Gk|DfJOvsm;7gzNGXI2YC;d5){!xx?>wkH>`(Z{#Ahtp-X#%hu zRo$s85bQt&I^XdqPVVC_jmWf_8tyPrta&FfmjaIrrx-Hz(AL6CB6Kzf1HvGm62a~F z!UE?MnKs{!J_F}pnju8>S`IeX9oIQOMbiIbv5(SU$iD0UMKd-U+X-fqsX}mtnd`s! z?F>92$rn=7OVd(Q(X2gKm`VJ zcM5Qa`{h-lLXmXFWoRku?hX&rv6!u$7zn|h>crKE{Qw7o+p%Z#-EuWFGWqT>PUqW! zgx$jsyjR2WJM1Od9iTC@%XK5&*_$42oLa zw^pEBtP5Vz<`aa~yi$Do))~Q0Of$?Zm?^+rarEfsW|bLaGOQK&&Pz@ymt5<`OZXFw z)>8ZJPPSUV=6wq!g#RC(>f&FyUWq%Wck?XQx=Kb@1nhr|LB1~*5?hskGs7s!g6qoD zJIHZExTO6tR5Cu#pOf=Uz)V1bdPsJy+^>niAVx&;_Z%4(y&LK8he9#*hHRxL;2N)+ zikpKIxAgwr%HaOb)!ivG;;!g$z~$e7?gF<|&}RtQE04$ar68NjhB;1}`FDw^1pCE$ z&%~W3&QgC6z>fb5=fBT2Gx7|DbWhlFl@h-2RB->;Ne5v@BT$zw+F8Bde*dWLXg?22Wqme{!DX1cK=omi< z2;<8O5;eo9POi06I$E#qawHvwwbAhV9!T=4gSlblGxa?X$Je#)D6qMUB5QqkRKMAvZkwgurD1BH)=aBvhD!!o66_{IU)k3C zFcp26`c$S*=B6@n5zR`T)qDlJeyzy&{ z5q9B#7>Fao4^H>5hq16^&Ch-!o~L6-zYKj)t~)O z;!=2gj8MNaS#r_9_j)J5sQ@`Y*?EGWb8+a63FGT)U~;2Lg^M|4f}0K8B023)9;_WT zCz@zyn*5uhy_Td96FKx_Pg7fILl40g^l2-x%${nvvqzrp^3il191BNUl!-o)I$_8+ z7C3OwJBmUvND~jegUU~;#Z5kB+|VDH2|wR6@J!ScJP!g`qBxpCu0p+v8gi!B-Yph z%#RNwz>-wSP}l);kNpb8`GhbIyVTP=v=oNou8V=&UkhzgfT4K4*}8h&?cV+jaXekD zwslg4FG6g(e2~v|rRo3+(0nVIa3tV|?;I#oSf=JP$0%aW7PyLbI)-YjAoofEXKQ*G zN{r?TsoP@e?#pw61mV*<9OfjVEamB#w2_ejIV7Bhh@vXe3vmv1?TYdW(r9NnM|7RK zi{gFHFGv{BO8?G)jW*SVF7bgmC+mV*7S&X>JsEQ*cGGRgDpJwPk10S1&JtA7n=F7LK@=A2lXULeL_*ag-TI1XW zC*e8!Qh>7CO9aZe>^g@6(m(HRUCRV)XryU2ox2pBYa4(169vDi;M9hEe8i*9G7TSD z-Xwh2)wb)U`e6=%IM|?{ios^+sp#&+CG>HsshIwduI^_%^bqTR%^CS`*i~OC=~6i7 zrBRg~(%53g#B#lsIqiR*JWwJ_yW*qCaBGXuNMATvHLKiCsjE*R zTgRZ&_Dec7jMS0|b2#w_fy)Ne=ow2MVbr;L4GGCRr(G|1;g>Sd1bq&hxF8nv9v0nW zbo%q{-SLHT3oF`xNehb*(Py9*uIR;5Ar}E~cxrA5u5!2EefRdq;X64Wjii*nyF(bEXfwmsS?5=>|1o2 zwv%v|o|FOpaE5x;ic)E`MJHghGROUJuV9XV8}<{O%Dcp>Klr~KL+^5#>XXe9@0i~> zSOf(MY)Kix9J_Z*-RIFymV|5(Sd}7)YrY|TLcdVka$K_opCwra=Kwwdyfd7eB^op z$;kVI^g)w-1~qrP53oHRKO6xwqT<8HA9*-Gl+f{%;!Yhr8}oDHE-KQ^g7Tm+2OE)a zh%W(UF6*hnje|u(CwO&hCb(1z zK#hY9n5kql&Vg(=bC$mxuKAucucF(RQ2CKvVBv~jfca<-*js;Hn+6dt=JPl>st4La zu&k}s7t!;PkAcA!R-JJmYxfTTN-y{i_{rdFHyduPi{I-GjH@wfo6_81ct zb~vq3}ubSJxSKC>%Mq|j2~W?i$12a2eiH#7g*MeFNy zW%qt9VxW&6>zZ&^@ooK8X1`!42C)&G`@E{rg3JbTd#apFn)_VJ%BP|60$%@e0@$*D zfJ0&r^NJ2_T?1xl79!1F`wC6QL^@*uH#VYy#js50`_dpw|(7EkGbEMhbWI`aoc@@#%M zBZ=U@_z3NJn;CdiBpKu)qJGp(wHYM>fn-bJ&64ii{h9b~p5nzWvY+OjrJP0eVKVDH z$^~;lrhkqkLK8D_<^`|M_Gys7$>D{%a!PKUjNd7%wai~YK%Led)!qBC)de5f6wX+< zG;QIP7>dF{ZH9{dI6nc9>D5H`&c#QgI*e?gK2C{j=e0Ah(1%HWeS8@_HfRF_bcid| z=Am{EE%^%+9!5(LTKT&iOI`o5F4Ca%Ize9dOHtayffs1@ZMXe)X&r zh&6sTYws1FisOVw|ssagzKeM|4X&s86rA11f*-p zjCabqYZaaOlirZ3x~DJh#qWY44r&c?zT%++z(Mi7M4?PBzxZ>sb^c;Y1pW5sfTV0; zyl$ z)bU9->f%^`HY)4d&Gtwp4Iv{9kmT&K8<+j9E-SSjClinIYdmGF-*`&vE8un!B}cXd z>N38M(7NrA`N%n=&BA+*?1Tl~Rs+7BuxwwZbnW{9`ywT?@1LjmV6N-@&rpObw`N56 z<>?66tI6_KqMr=`%POIWxwJ2V1x6l~1Y5flE2&>;z6oOP62U?=jD`d_*secZC!2}3 zLg435LSfH7UXT8;JTAqBr*qePqxR;>Q+?5y*2eYQJNK?cYecw_~M5qE|27>+RrAWS?X z@q)jwi2d+MSzPy!@Y4ZvO&(`&KFXEGzn%)sJZpUPR%A=fQKxMzvG`oq8-;~yh5+vh z9$}JJ9yaxafuR0YC+3KaQkC&oWq?e54H>qzUNluuzEXo^tV*rSB9ZOC<_y}F{Bzr9 ziTApE1vQA0kO-yN^+I(WhAG)!QSM(_LjXzQ3v+@hl~O)hs|BgyBwW}vz zYO11%AK)@lg4*H7YTJDG4=r*Z*3f@kc`M4TpIM~@G;?L!^}rSX>?hVhIw4533#*J$ zaizj7CJK4OoyE02dOC!=VX)GiyOzyvD1;r*^_^=~AzTIx57&A?g#{iZ7LTIr4us*N z_i)%O#xfcFUB=K&=_nHkH_9u4DtlPQP&Z;71$ndt6uWgV*7CAlh`g;;0k~Anu~qB2 z+gm8Tq8HB9peO5BRRFcp`o~qz3q7{C;_8bg*&n~fGK4N~@$-6ZuxmQu9Q~OB>tR$TPIz_XSKRP zkWo$}tu{)coc2n;7^SKq=!T>dX_yI+=_l&=$j>^i1ZMI|yCxXXXu`wLeWD?3++mO& z9R38n&qY`HYrjZL&+8K6D-RE$Q12?C**?1DAq#lBBmD(;T?yEoHd^ZbcCWMMNg?z@ z{RcI%-i`yho?^sMs0cuj{~DuttAj-$pEiM(DJj^K-B5&?ow*a-YxG#T$G~pUJ|t??mKA@Cu}&mi*rChK zUL34zOaLmsZg>8n#%Oz`-Nr+Ll=<%0Y^br|H0w*lwI(k7odKXSAZ%R(o6_UrVi_0r zJzv^eW|w#O#mZH5i);QVBL+x~KUHJt^!Y+OaZC_6si^Lyuk04R#8~>&yET{9Ti%A8 zN4Y0Oip{dw9~@G+9kfOt9VPjWYh=~iL+VbWus{gyct&13lf#^gkW&x?X7cY`QEs-y z9S=$K*N8VCI!H{fEQSA!QMGIGJ~iA+U{W#v;&f|Nu)1nCJ`)R&qt$6woL z$joqH6x|NGVU+BSkGU*sA1RPnfVb+@%-*z&J|_#xuY}$e)G(C&N)wEx(}*IyA|W0Z zfPKk#W3}UCEph8eFh}C#k1{OpCaE5_62#%wH|?(sAlNDT)d)JCPNdMSm{k}DnhZ(7A%Tg@-m__K15!2#b&P2(8=eQn~9bG3-{ z{wzDrU}52rZ(T5oi&GX;LVN$96zm6J!tmF#N^3EFLM|e&ROfrfbXuEUu)CMrt9Zkl zT{awG$q%3m^cNssm9v(!({Rq1*cb1)P!XUT{eC%|x1eN@&FH(?Ln?6JZ@Tr`G|d-5>2Eb{#e!C-o*;X&w>23>r;0~KBr%CwiE}Y4*{fNvqXq&+=i~=r~+tI z9Nn|1lcaM~PO8;?&lpu-ropOkW@j+&2k7c899%lFtB>;Ni(w@Na$hc-uC_iQ(txN^ zPjfT<=3Lvsp#FUqQU11Rl$Ryl_fbwU)o}m3p8-uGl_zUKgGRo>N5w<7m%u*D z$DyVDSx!gGFZ7cr!&$=pJSl>UEeJG7W^@=nO}L^7uoaP03pwKOq@aAm`6D{3#k8L98gOPk`6LDong*<*<8NV9TNYTgq zuDmd~xIa(}_Jq)Kz;RLBJS?AIQCySwy&(3%6M0G>q!u8U4#1pCFD;cbjvXV2jCh}ZQObi)BD@DRXRPlN_zr}V zU+u6T+~Qz=K(hH`gqQR3TvCH{IHyqLv=2|S`}jJ9fwKlqRgsWihfuq)C#kP2G-82Z z=Y)s|af8`cAL_ZL0bc4f6PK{`^|wXcw0%=aoFaHti9sKXD>CNq8a)&04tTO<7Ywi< zs)6`*>`;*$F#=TP{i5ofpjN*I|E3d`@ssQS>l|3+P9Oi8I+u1s>J3nhZ46`&&#S~d zUPl*^^E>al*$Af~%&c0l0(9v#jscMsyJ~stSTH~Apt9jx(Za>a+jX zoN#kp*3nwf6m{#KnIJgOJPKFQ~-d%G`eCkTMXDV{qC3 zIYA~qR4fb_@ZZMxk{z)U(!5yAcZ?pVQQ3E^+h&2LH9tx91*UtZWnkD2 zNoWBnH1V)OM9afFy|SJU@5bLeaH3Do_!U%~d)8FL+(TpTjYKV{-tGTrH!7p+sF}dR z2Q0mOw|KmnL)E+%Q4y(AIIN{|WtWMbArtuWbT8oTKu$-Jj<7Wl--bCZQk0?x9W@zV zkcW9P{29tlWE#}h`}@3@t-{eXlyELS^YUXlGvlU_Fe(Z@loa&@>H@_p{u~yuJ*_5^ zn5%vTO)VsO^O@6PZT*QB-dW1TG{EY2_x(P8t>jj?EYr5SKs*{EV2)?KCgM-mnlK@J zI%v@SQ=-w%Y%TH0p3nbZGE&#>eT0kjq92kk{0TdvHoy;$9%6H3dK8 zFRT*M898Tb=w5K@&EGJzPu_)7{t_nq;BN08c6Z=>iD0Kv6%TJ^C2_5XMoZr`?LAw` zf=~DeV3wyAVwxYiySKijY%g5U2tn7gXh!{h2t74;`2qbSC@67^i(p$8C``L? zAf#NAkSS>EJeki^$>})*27@S&>J9U z0o*@jb`=^yqu;p3&072ufvAi3y}{!{({J;T=G`Seo%v4>hT6vDZm~Y0!F;Bf>U(*1 zX9~MFJ>@RovaD<=(pQ-H~9fpZ%^@ygEG48ge$s!!jL!?vBVuHvG^4GHXMR zaDJU^za^A`laPn--@QJCZT_~P{YMHW4b;k!^7m?=YDfcB^6&AhkjFweAMdq<65KcPejtw}Rxbs&;UEQzOwraZ{%Jd#6AvncUN&-&a;h^Tj={eqSsvqA-=`I3l{EScL*zu#~a#~)%RvPVv`$tm%DE@|P)|jSrTn zr)R(X21w}#D+}={-&;Vr!1Y*P zt`hCS-sNKyoA3LosK3EZXl1uzh(N9cQ+4kdykh7G`-i3U8pWd(G9wyAhoWc~VUTUO zeWA#WihEB43=%6iUjmF@n$L3%<9i*eYY$g-_|q>!2Zv9ioa``xOyu|9FkOu{rn28T zPq1>V4qNlco$>HlDpekKzn#;9-!QN(T8Yp<7yLRPU1U1$b>tLXW0bp_jc^y_Z{t%y z4#oW9UIC8rbrp&SoU0*a*tnNL><{t}mO-SB+dWu7EUdkvR$?wFmfw;2ykx5@4;sZK z?KY+o5l=tcJnSDZ_?(M0(isf*ZqPqAcOKf zlia=(k2l^fWl@?P=Nwqf_PoO}NgP;&Hqk97_}4UXWM#d(LtJPS#7;znM-TWzeif0% zv`uM11`91%zJgw>rnZGlmY!3Er@|WOA7B>KQ>~ZM9MNgX9}6Gam9Q{1Pct+#S+y>_ z<(p@(EO@0L39?jmILV{*K@>rAsH?z3OBMe;gU}itcK$8j5C|S6bgPg`uY39y(iTa>i2mf!f$i;h~dTl51+lc8x4L_%@jfW>~P=jS<&F- z=98Xo>2G9GW2`w3(y1ac0 zBC=k+Bq1`LTs9*jiLwvcgq|&rU><{Za7G`;6u!n!Y&7VV42Lo)*0hgVtilHMHbRI3 zxKntQn_#seX?YilFLW^CjLygFAC!opS@4VTgjTWu;arlpBYwvhzPk$o6|-}z5AumT zSz;72tG>>)f2rUBPh69|v=Kz%#U5>ki!PmENPK02*9mN-tM zL}nQ{4tR%OlZcQj_UG~Ty-)-|^}sV(H?vnGO2ECevnK-v^3N7~Q^=`o3x7WF_n9tk zMH<%W2NZ}1&V9ZSvvdSQ3!Pk>B`>SS|4_Nui!kloyahk5b9ztAl@C744rPMNkiMAq z4)ZvH%8?H_HnL@QoL;e>`(X7W8g8A$)eL|%JE5#fuO*b>Rl`7GIL93fk-g7IE|zL_vqFnTWb^f znvk8JSpU>-+4n1;jzNP?L>W{=1+$)t-X553&P&^b73pD!l#zV04t`c?Udh1{gar}} z5c}umvgLYT=bg42l~35_cm8HODPV1GXum;}x&i*oGGF5F+PfzA!U0wLziIyZdhfW8b) zNBUpdho~-#noBR#k-DEYi*Ig1X%_-Otwvy;~q!%gYYkqeC&I>+9jGV`h$(G7KxRp z^g+n<`OUz?oujM3uAK&cowr&`vgYoozWTj+@Dl(1uqf5E<}iV;A~PIgM*u)tIGnFFZ}JqIB#rtE3j)aF#t6J7~gCtKlwXu4hlq1lQg1*J^AmFt4cC{ zygluu!2=$&a0;JBvt`l0{B37$Z*NP$Xkr?|Qp^f*rk?RQoCdr7{e`D-_yLW~U77oo zFl2|V%c_wXQUhV8D2ZPmlmX@M&1~k^O1P2l51Zq(jKaQzWKBQZ#P-5zwCnbsnm~yW zm!tWCH|K=jc$dM4O9%H6V0++Y($$f=fcme zAg{J^VT&WY1Z>ij5qW1RR(Ky*5n0_fFlFOyhDWW3Cgld}=0XL(0ZdrVB^hIy2J+#( zN&X0f@}5b@hZT!2flQ)8dEMSPKqKr(mzg(fw+?BacBe!n^gG$|-kuFh<|sySMNt_@SsqjR@K3T#H=zJ*;`udz**w&luY$QFZu>$C zxx>lR^fO5{iA{Toh+9Bc!lR3zLx^=m)d&LCbNEo%IWNj>3S+!Sy3+GOUQiOea*txlSjXczisw1= zJzz3sB5au|AX5bUNA98E6@cxW(~`M~&Am}&a2~A?ADo^D=(^%0lCzzW0|fmnKwiJr z>pfkG!Zh!fj4u{u$t2f!5_Qz7WI@VIFc452?ejAx$Ug;R6PCIBU z;cw)m_4_d%Jm7l{)n)(Fw^x?}%YxIHR`kw=U6YXW90IgF-7T|4qQW%MggS9SUSX&4>VTwvAQ@_=H92AO>-t+SHGT)edpQ3`Bs-LVW`q6(b;)?~@7Q9EMZ5^N4DQd;jx zcoTr>|2jmaXp#nHNH(*0&*9K`Na`#rQ&q2&?1_uH0tpJ9%UcB2`eB?C%o&8sAG?ME z|DJ9PLSv>V4r~5lJotvIG6cbTJpYy750Zw>^k*-vLG+ylG}BD5Pzq8!^upg1;+Ua! zi(t-XnsC)-Qf7iir6{tuG zdd$?%woU0ULR1R%#Jj7( ztN65MMU>ob-*O>cx?xeLyv0{KkIFKV#Mr@5saW?#G%rX7AuZ#aJ=yE?8qNDI|Uxc%Qenh z<~X@!Lh1cJHWX-&3j1sl{p+8^jzBViWtejQYyG9X8o5+^4Eby9TpaC}&uKWX!U%%U zYScU+4Z7)H#z#0dxy8PvoAGIEgCMfHDzzel`8t7>1FdwxW{JM3>DtQj_V1Du?knVa zY>Zu#tk$-x&Xe1c1HNFc7UP!_M^|ut-O?uG74F}eD_h$X_v%ot;^@ZEW+N~#$wpYT zi$Wu$7M7WLW4S~nO_end2XbY`Zv7O6D}aq=8}(E2CGwYl1o2;T-HO{7`!LoLk{X`e zCuUuHJwW&^0%`W_2Ypy}m=;)lgCN#bBRnoYgXO=I?QpE~@!(180d!Cv$P=}CddOw(R6;|yNkBQ* z_tM(BeLQa%>9Lt`69L5&W=ra&uUmT*b@Za^pi& zuBH42t%WNb$Y~u4VPRTg^HwMI4Wc3Yhlxp;WBDkH6dd6^?+_Gaw zqOzHkUpv8?2#=MHIFzkOq!k7wxutj7dsDO4J*oP!Ot!Dx{X?LR(xWBEP?tGx1Zy@O zVKRXp=2I$+Ip&MbxnH{B`J2w+SLFNY4+UZ8DUQ-1TxMNa4L|l^R(ufxzD}ra#{32f zCIjbBy5!j@ILoJuS6b{O(#n34gzL;?UnOg;4fcR6jc~)tJbtiysu65mOvR$>(q%Hk zhXgBT$|Gl6%}wz6C*}kh?k~H}&Q7hsjpop*wT1~CK2z1eU^n&Q$aR3en+y?WThp8L z9=&i3U-IPY;Z%C_Dtx~;&mvI_wHF|(l*zmGuCP(><|o}%#nHDa`pCf*^iK|K70ADh zWdXn8F{6}!H)a;XfLN59BcZ%E&jBq8*$+apd974yMX*aUNGNj~gbH&5HH*n}A#MmV zMaGX@Vt$z%FaQ0Y)N5q-t*w|S2y6qW4Q%>!|McE zUJCyAOd5e&6)7wPXehM4W52icKJZ(*3fusLHe1UnEzyNo#G{|Q0VM`lU$!{qp_rPS zB{^P7#pl6;!%rb&h}v%g;z3A%jd`+47q68o#!}2wZ~s8ttVMy3_HuVbO-ceb%mLaA zf~`nI6v6k@Xhr;A+8dF1Vl+YgcibS(IJP!o2|FVGSjYvf>U z=}FRf2ffAF$U?EJYFztbZrd=rIZn;wiAxD;<; z!Ca?H-9sfkw29TecSIrf)8+cFIiu?yIwY$jPF#r-EoRW~nXnzxZJ*66u08NeV~pua z5}1_eWFIr4_fWpTODKL$vki_8wl1;tjXx-eaLQe028wK#e+B3vig;Ej`BeX*u%zFh z*UF&8Zj)%N4XS`kF!>|JUh2{zR@#7kYWsnv@0TR@kNYh?=^4cnS;vw@Fc49#`8THI z>Q=kieA>pmpd6$o$xd+A4Cf)j-Y=;MApL|^ew zb@t|Og!S7HGFCQ`4$k+0^CD1E#pe%*x!t;4-^_5%rvi6q)wcw$C=9TKs2l)>Y@j@x zKKdwph+J7fkfrKwP3hCz;|`H2ee8g)A1TnopO)WVRo-uuJm4|+uCXd<+kEVx?k)aT z{m3uNOb;qMwT*X5ss3gKxGpU247Z3IyY`YN;O0+KJbil*^9R0p9GgwbuBF(GP0{6n zPf3sFayKL=q~YPRJCtG~3m`C_v{o7GRsB4_Rs3}D*K8pVjRj<5%jpFX-N0iX9ca+* z;~M%|9=`PM)ebdd!_*N;;dLH!_wd;f#q+q|_f4d#!OZWp`HTNBtlPC-?q$s7KC}a! zhf2LAlX#E04fK;eo#2t!OcgdK7M&xj&h!694Q^|9Ga;_#^VZ;{25j<$CmU3fQIdD^ zw1`8u#jG-zaf0qpq)g@CPnk%t0ilVdm~Z)B!mW4s?w_IctcR;st7MY_=}*16>B@C4 zp!s+vtMO9kN199@gLe@J3FOcqCoX-E^~c&i?SnmNU_zh=zoGc2urLuhyl2SGv~WG7 zmzL`Rz9(y<6;X2$gd${YbPjIiCB)>t^@Ztn_PRH@w4jNNnNIDq35+kA%NabxDG6>ne6Kduar-Oe^;Z} z!KMGR#>k1t>TZc4pp#K7vjBu#%2Hj(w~~zg2D+z0j<0c+A~c-TpAa}ggH_z?-uhA% z<*bHNrTCkd7&;6@KPs-s6D7yDwIwoe=lBhgxj~;;9Zm%GCQk;&ww|Mx`cU49;Ef;- zY{cj57by;CI3VI5@mv2kT&+)B^rag2LMLZBHuX}1{X`AeRR#Zw1vU`R3)r9DxmwCi z4CnceLH7w+B&1tce*8VpSDXi11N4tFf=x>bp%HZ5`*|YgN)EK8_lo7^9Zh0Y#85xc z0V6@vkf#x4caGuZLftNmpdPbIp(7Kc>}zgfp=R_+Fy(b?^lnZqM>g{~?%1@55({JZ z<+$uDTxTtS-j*x@gx$e`jK_%0 zUuzn*R*D}#t6zmn@F>Ua_dHF8NbnJW1_eo#S=nW>PR=IrR`#P+Z_R$mfH3~BaV=iX z)c6p{*!LxToxG;SbX3IM%E=5-_{*Zw)D>W3@iQSNB-jIBac@YFZm35d;~=xVbN6rA zU3fh3;B0)yJ5u``ZG<^npismt8 z|AfcLbxI+0h~b7yzvfF3ec6EsC{1pno2vjlh||oG7_~P!UxMrbh-9#8oVe5V|ySe zDUwUV+HUjh^lAzx?Q;I7A|%euixOWl1aU2L{0f*CqQUM*$vZJlAvmqI9 zA%{ajaqM=SF%29^bC?E8?towGZ|w$$#h}7FCb?|Z?NN4%e0XFcJg`g5MkV`&>{i_s z%pALG+CQD{PrD2E58|)$&0TH@fCDkV9*fdV^;Adt=n#fz|1#fCxOmPy1EJsP7LD=t zfsxLshIW70S5fQxzNjcN7(A9p^YZ26kMmj|%rRF)fLPjB+Gds8jdFhuKCO462Bp?$ zMrMaFR%|q_9Hay}V39BbzNe_!<{d7?0HuJmJFUcyt{TQAbn%-~>;SJw@Teo=n!Yn)R znCNPPWzFCREp@pM9C!w=^#sf=2WyzgX0#S z$3+-P>t9;ejr!M*3bT@6eX8tBZs6b$vG_twqVGMv0}M;Sj}byXS#{1@mkb&pRH`PE z%5q?3=Y~x1O~?26%ca;DZ@k$@Jb?I{bD=M$?srF+9+c6;ANQ&)RL1y}_EezIQ_I`eUq-1p8%#+O? z(l$QsgohgFR}IncL&zn5^BgHlaPy8!brzdmxf{Qc0X|g?-TTuYTU&54I`bZG?f`2NAD z0;4sy_#s%5R+UACvSYSgC)oFF+J_q)y-9{yo&4mkAX-!@1|HMf%G{;UJ-)-n?}&jv z4vj1=Q2J|u_f=aL;7p&x{);LkW*5CklBm6KG0oLBxt`c^Aga}ZonmwivimYe1Oo0< z9yHgbPTZ})P7SXsw|b<&VA8H=`DcD0sFGub%BGVVxcdEUKnTZW&X(YK;6eqC;rbL+ z+eaM8C70Beh!IA(a)*BVfOy~+V!ga;oWL++=a95LA4LydrNk8{`k+Tiv2o=Lg*;ZD z74u>Z@VcKE*41iLjPwDKjX7@EYi5H`jT^dfE(}c_sfRO4^gQ54nZHg_ zDe$8J75xP|+a;U?%BZNSX6GZGqDli|U^-yvpG*yF5L>;%o!>80U}?2xC62AF%n=r* zZmI1JIReE&%GEPU95tnI%snAlb8O;fg#MBr>Q?quOdmwD|A4cdc`l@dK?54y+k=vk z7snvxjK7I}SRS_vsILl@hTw*`qb$i~2kW-J)0`t+P#_!$6TUol#3;f`{i3Zn0}xf{ zz%N!Lb*)W^w39?vgyz%@l&aw9;d<@4=pzeFfXR^9&EQX7{Ziu&+_fFoWl@-C(w1`g zwv8+mDpAtXpa+8lhT?I>I&J4r4=a%3{kO3ij{ya4nz}R|j^sTQz(Ps_p(!pUAfYxV zvw@zrf($QQgZwSb_a{dbT%NHDs8``?OAYPHL~YMPIx|Y@hwmq{Sw<%Fe%kl79LoxSj3RNm!Q;+z%=TCY{mHl#b_$r(|}EWrQLp z_9-bbew8hS&sgXJx8RVr>YXq?{{_QUZtLRiXP>2;vJLZjZOZJMTi#4ithHlGK%_a& zLT!`04JSxLDP$~rahm;@1hG(oAkG2C_Yow=7-%_P4`G_+$Zm$47@v$H zL6!oanj`DxVs&S1u?__L_@_)q%X76#930CH{1g-CteQ>nGj<0<_;X|xx zXkFl|0k+<&smaWZqWHKZb$zvxW4RP9cR_hk890N`+!yru^_oZtxq+AxDVinR*$P;mZ z>0?wvv&py?{2yoM+!uz|hVc`&Y}c~gvTZM{md!2vGL~)ETDEQ5Ubby5_v|%1FX8-g z&V8NFeO=${@TRu6LKl71N;6G2j|42VKYxf^p5WpAy#hb4kIJcfljJchmF6Z7o zSL)+vPmDb|zsKgcVF3loKU0l-qU@T*Q@TGIZKh;?NB5WCu=8v=bw*Z<|9TF|%yG-- zHLe4=1VSL@lyDq^?S)HO$5wA&&?T~W%H5; zJG)kPC78oSixE7;nz>A}JtFDE$DH=Zrmjf%UM;n%btrvV079_7xe+2$thHE1B=i|A z3Bree*Kep7$l^sMxa#fQ^6{;H2Tc*cL`b!unD6x;}I`=^Hfd z=z%JsTY|m-mWb375HgqEJf^raT&*dq1-ayKlxeT#U$hetI(no))mWqDhMB0;KYYy7 zri8jg>%zlxe!{6S|I+wPcmgl5M;uf^dAntPrhkBD?QZwLHeEPoKpzk{+-s`iZ|nsF zf3MsWZhp?SY~PGx^+kFitFy4bz(iBv)9O~Qi#3JJ6a>lF*|};{L9^~(Q#bz9wV`fqdnzd9p@^oU4>16kU#LNR1z!>ws}lp~%Q zSutM9>-l2csR=ZEv39iI6|2C7rT!9pF8v3FE?bg`W+6~DyqpcfDHXILRgWh65AKi` zr^TLyby-eM8ziE6o-ps5fuvA@L%<2mb`ufg(ftXj5$cnAVDOu&9(M!|3oF`0*I-G6 zD`?h1)1@+WxmEkOxWwCbh2-{){T;@waq#$+Hm&J{8mK=IRsH&e>SeJAoeYJh?dg`M zYjgjqQr;-XW3YxM0z7|Fc9i|3k$)p2&7jrz6JQt5TGXtz)wzSUrr*{`2FAl$tMM2A z6*Wy+WL|84v0-_6fg~`(r)5$Mw7Rhp1kvkh#xfsw$acRnVkTTYbVzS8lUAdMB&X8t zDgTGt2yms@F`}U{Y;HfGO9CtN_a<@OGD6~xlf?F#`d`@DKnYKH!F|I*es;~R zn)hw&gAdZ8A6D3DVxl}Ykb4%?lxvh}N5dk?GJjc%>q1g0KVngUwQ%|wgET+}%A zZ|MFLlF?rB-TMNH3)$Qatpat`k7zwGLh46gIrS>wuj&3(4yno9Z&`2HMWDcRIGo?j zZ@(_k{}FC7intJ*Qy)1-E~5vQ#ed8g@$TBZlzZ(Kiqp-y%!_8JHO1<6s8p>kT9 zC=$;KU{LCH9^uQK-+k8m^eEeMJKzQWW0kNQ^+ht`%g;sUi`V;Wuz<$$b4g5IA=bf_ z>BJi#|MWy+KUov$qQt_z$u%VYQddR(CJ5X#SArwkI`3DvHC}~yN~tNuv}Kk3BIaNN9Rd@d3|Mpe z4BbCw5xRAZ>k-aMIYm?d5WwdzO8x4amj3On8e|YXs&oX$ZShE)N;kuxj-Dz!ON|a# zk}`-aWg1oM10P4P__J9BB*DLpcuqtC9c%g4Jvm8B5~Nyt7r$>gfcSi^JD!kM1os^q z>xu*4gzL@we%Os&gzD}RGnKk6kb}JGyu45h$*3pd)G>+MUvs1sb67$-dV#0Iq3ai!H6I4M2==TnsARw-64XF}aP$cN$TpAwD#kPih2Y5Tg~xZXC9 ze_gMdD6z&uTJ-s}Y&tcXd?1oIp)~_)Iff^0ElP+-r=ujVrMcN}2-B>y-wYtm@hI-A zjM72)`UglE%Zikab+4;$qU6X-;bTvzjNB9u)Q6?Ks7eIqKWNd=UQc z1MH-dk&tR_vQpY^9)F=7kYpogfgd}vw@0GjBkVtaC(rJ>eR)U(Gq zpHgCLCckTUb!>_;w2@=!f_ri)5_Rgb#kV=E`edPXG9@*S&BlDNF}4)Haz8H*lKp#- z6p?iZPpkM^vLMVvpGvu8r;7KTe9zY6hA%4rY}#9teC!D|%TT(~$qf%SA??}o0OzJ%ixqXNI7 zjDdyp31*SD2U0K1M!CG}UD`4CD#v*vIads#;m(mDM zu(bCnH}mG9wGtIm6(ALdpbX`*`B#bIWJac z$INoEwiWp$uNA;QS0_#!2;UWE-xycT%F^y5XP-DyR_*zWw`1QG zXz((r86G>;^ze07hs0Noo^f&lsx==OD@qoAoXin>1XP804UatdVQAET%PJ723qlQS z?SRZ-a;(5*uQRyh0#^uKpY;r;Uym?6lbqSe9mxpB8^sjOlWn=CjRarlKrYgYM_7if zSgp@5mD~$m6;dUipCutDkP@nuN?GD$;99fe$06=)i?2z=y~FjMJg=HbnNC83LC05! zQbPw8(4?M$Tz@xX7nCqr!-!NzZT}mO^$zZphWzvsf!0G(MfCMhgB53wVS^1Tq-eq> z^9zaK1{G2#kgAd(YURC@vaDW(7aEtDMPy9-#Zjoj{r~C=E-E6XuZx@!U8wwdp%-Pv z)t{U&_3V!EQnk=OrCOkafbY)iV&gyU-tbReeLokon2Qa$It-*~P0&a#*$F0tmMVX{ zqLtNU!%t`V?w|YYoQNRMb)Y)S(w@=2`9T;0WnN9Fd7f5vc|)T4$pG&pvE!LlGn?pv z0uFCtm*`lnNMSH>3K#JR?By zYmGaLc4#P5QJmkcKYf8n*na8*!!KNPbIDCL7PeJ>B#DBp z3E3uy>vhYPT}WV=W3P*h$ui)L2X?JU_B1W&7Zyl{FMKFOQSQ%{*T7biDpE0zE)TEh zJM%7W*uVtsob z*AgN%5|)zd3IF8;V2;@F|6DE5)|R4#x1-h2kI~Q=nc}woINH5u%=E+yJ`tVL#2Hc} zQM&k!IP~71w8r>G`}gh|XHVjYHynBZy_@bp)6dN!W!RT)x)&~pB7x5$h%atBeY=s% zRgYF+8dBZ&Z8MZuypx-4g%IvgB!dh67@sZMMPt;X$9M{aTN<47+V7?-`IETlduCIa zFI8WKbYxz=R6yWrs>T5`fuLY67AWsWw?JuIBL)qBk#e1<;8Vh4M#z=F;%R{7ne~=~ z+;B1MpSkX@*M}CXwZ^FmobEJbJ#U&I*=C@XX(`Kt5pr1qQPC)kXNdmU7v-exE(H~DFIMWN?Hq4x9@?B2d@dYyCRS@X> z)%1yQ2qNdqL?5nqi+v0h$GJLQyX035GI})|4{+SGYI5>Wl~dNp^-7UrO3-K;Ncwtd z^CH^w@4i^K1Jmd}5QrV8S3`QkDa48Ttly%S)X8$rcs#jp0UVGAp2!;-Q$BTN6czc_gY zmWjn>WD)bnr4LE7X%Y9Dxgt;@SGaR)1Z!R2#R9j0R^b6wZb3}$JR0`i@E5t4=_{zq zygCGwyfYKS-<_2}PQk>I@dRsQxAs|w?>SWVlDC2WD+aOK{JqPfRnQ_>s49L7w_gr( zk&TXyC^#8R6K68&D-Gj!ZuHKcuOMGt{!OWU$cbj{NE@`M`(RSHx zJ1LRMwZQa1`{Pg5(-^%Nl2%{ZVQ4sfoD{Jz2G8+Nw|ie5&nhfXm}b;8x9_2}C|_13Hh$S%f-a6+HqJ5emMADLWAL@&)d zE4XNg#Mbe})v&7s=)?^+;_S09ewYeyMmky^a7SF*8X?O{mq|w?NDyAYj6N_cxZju0 zWb-E7l59zr902MAa63%{dcRN-Jek%ynAfPW2Q*ZDukmHU=(g)Ru#> z`6mt17U}yx=vlHb>>f#}l^PZUu6@nUzkse=b_w!Ii7Rpz%^yGTOoFWZJU9*(20*E& z&v$g*Jn*WH5xuTpz&uVGvH^AYYv^$6bI9rL0)`Eaxxd(=J-|u#HH~B|N0#J3o1_lm zq`MT-r(NxE5Be4~{b)ja11MPOJ_$Z4zE586inh!V`5|L|wK@;=3%RiS;lq|q1*Aw^ z7t$?XPgBIJaLO^Ymz57iNf5i5r4lY7xahiGffR;8BDQxDp9t(rhuNCx{eOCB(0^6h z!#gY|l_NZx=;q7(~&rG#90t~<(ti$GzU$-CMdH!FWaa1Fb<7{8c{An^a zaO<97Isu*t<*XfBJK2Jq&QYrr+(x?Z71yZ_qjfNf^gpTy)Vt*n@+F; z2!M`dB?(eGa^e8DB+;DELR6IkO0|Db>spSz9W??#{So8JF+10Yn;M`=ad_o{bTzFV z-_MOVZyH&AwebN1CFhTcDCkei>2XL>IZ96%B#aA+Oq9Z~G$H}z9)n#)#IhzSU)}ii{wqjdJ~O15$_{h$Fx2RpQAjf@ zwf)Dx5K%b!{ww+j5)wF>&L!5=9Nk*@oMc+{-CWc*RDr_Y@Bg?MVb_{z<6l2|z{AvK z2>FYO@0@xjY`w=8hk=jbHJbS!VO6~=yT=aNzR7*OBV;A{ue)$RuJqEF*@K4B@l@{Abjki4+4VCCFB10xOxLO4Hxvamx92p6_cK;sB8z z@0l1(y=#eRs|ZC`7;sUA_dKNg44p$oP;?IyK4;Cy>Yjp1;ntRmX? zA49oCTh!uz!RMQn&b=2TFcMq<4C6={5gJiD@D95+%F)Q5t@!5Uc%0wlXU@JK$S9eE zyPKGx3K!Rb9KV*xzg&wwvHxkEK;u$3Y(zW!)Kp;sGO11qAHLJYSFpmnImjcbao@i# zd9xp@(@zWID}QnVPOH|i2-%Q*B7g0N%Fxq3M7^6p^iM(R(=T2ey^$nWiwHnmCr_8(@S# zju~!%D#vnJSn(T-tp`BFqOAP)DUF2sN^C8!z5G-I&)W z@Telr4~Cm$P@$V%-_-)L+*mI(n8Q(DIpnvDp4-Fn%mP6qXN7#3k2{uHP0TkPB@8hYN2ByEE(A;>Bh~UeE05A1p;~9W$%+ zozx{)j{~&od}iAQ>)9yIM!!M5iW_=(;l27!MNV`?=1bW5nd;kay_};;{$oD1)3i zzF3pFM-QI`=$*miEW+okmo60r^xk+k;89Bp6oElW&uEbJEn9^`Rb!V=$MjzTW-BG`iMtEYd3k2AZuzpJ8=)Wu(c zyCwOqua`a67w%eHLfu3o&Jrs)q=e5=;|q!Y3?-}J&7NM8awEZ#;<4Nmm5Q*9)HR{* z*Xj0uX%ekk_3tOZM}>?dY(U6E*r~5!uC3!hdh0cOln`H#AKin6JKP+s5U4g*3C&h3 z1oz@MShA!gHRE$Hs&Vil<@@$K$R2~ATlv~5coHlPk)mNVN9&4Mmq-qqcf=jZxf_LkKd=F2XsfCOG zr*dy+RmqtUBf+E=DQzIS5ZWdK`^0V1U=m6tX#9$4j>YklzC6o-0=nKBO8~G}V*NHb z^Z4_ZQ#*0qQFN%b{i3aSx&Of^;F>!7=QuDcD%mgmI_^)myv`Qd%5s4qCi(HhU9HZ> zYR}mx6-cmLiHE2r@N>HB({Mh;u~8SNRB8SCH|&jlU27^nTW+vi&ya#XHDN$V8AC)g zd{s5(8a-G0u=X4uUfG_lmIa;?BlpK*zJJLKn*ETko!(84`3GUoxjov}Sj5w&P70W( zGEr1N8Ro=Ect6FfxqC*6v|mb@af^ClA&UEC>H!k7>C;;qw|oQ(b~SmYyfRgT0m6W$ z`r5*b1^)K3Q4r55ON9|kqTfShvosxcv*MSPArMTPTC@{dkHwVX2WD`s@FNyaF&9$L zm2-q$54>EA{bP33cKVY10+MQ!0SC*hi?UBhQa|@4XR@YnJ4`?`KPk?+B4(u2Qy+TCMML#Z{_vRO>;oGg#Ss5F6g22`sb*eyhplx_m8Jx{WvR;=3Gg5e~q8OdF zcqjYrIncFwgKB_g;$t2n_}FVolQRTe;SZ$*SE`bwzN$N_4cdDPZ$-<46IOrBVrKUL zwh&~Sb@@xj>NN5-kLs6BFThp{x>*o;hZF8S>9)-ptxCk{n$Biw7R~6-t8qL001}tZ zwGnip6n$h@vSrtIt_G&+5#go{X-|#FyISy*z&5i}(wy8;i?r9#b9cdD>KPzvp& zkVcO3yrp73psoAPWArxjC!ZOrZU>P^&au}^Nfr>*vQOV&oRjG08?-6?9FS*c)M3>gsEgAq>VKxd> z{JHR>o35tME|~45XW#|&9{!8Mwa-#|d$vvS8eae=6L>uiQ3btfSZq$Gq0pg9ZMwQo z?qMD0A>9Hoj$zt!Ciu>ZYod^81g)f?h1___KrUtz!&Urs`s<)=^U z*kb70P6f`ae^)ZD1@jPvkP?Js3WPyJDQk}1(0oK)-@A2B! zN(}2&s-eyMGXl}jH0B3RFtfK-na=G42Norc_q(%iI<#sjX-`lXFxY2$IbHh%3~3Sa zywciXz)3H{hPXaL4~?C*ou}u{0A-|g@<=0 z`WHs&o_mfglMOvE)3_9Y!A$0cF~@D|GNT87yy@R{ZPwP6smMy2og$><$392wt1YtV zF^h7#2-5?ZSzOzkRJ4yMj#Z)vlQA%rN0B)q9|zs8@0}KI8=e3*3FV28d%8BG)*j6@ zRYIuQuJCw!a9Dx^$FFJko9|%mIx!S0BZ8W9U9PT(R{9$Wf?>=Fn-?PPb(GwBlp8?( zDM93?p}{-N7*!V_?#lh!NQu0MnX28W;-{LYiWZo}cPKrAXUQwg4VHR_s)f8e8xiVr zIK;itDCa@xm;=*zY}rg5HPY9~nQSZ6Wh-p>^YYf;=7&Gn4PPYY1_Du;loEG=l?7-) z7FXRrr9|3_5@jv$PT1iMQ;whhwgcglZ!wj>MU3N%aHah8c1l+j>N$lX!?Y5I=-p>s z5rFiV$Jdw#^CHVKQa=~6?JQaXVn0`pP*>sD12W|?3#_Ujm(d~} zd()7|dRpW?!*SkEG)Y}Q1rV*s!4s^E-2(Qi-`BnB#IcF2-;m0L^~6n$6IU;8zUNkv zKtzf!4MpMJ4SBEV8{CGO?e9@%)EbPc=)({~Q8c)9Fx6sIDm1WvfUN$Rf);H?_x|3jY+h6_c&=O*X6C2El$_T;kmkZ`_!{zz6pBW>kpfMn$>N2rCaqS)XqeSDPr@@ZJ?c_qtG9lgp9@9K-;nQR8jt98|@d}Q`2FG zCil$DJ9uBd8OkYRVdB6pbjXM>9ZS*%gs=yv+!ML-eP@(OjlT~yO0ULW!7^%=KFtj2IhM2 z0Q%7Aqw(pZ(it^8nKG;ex)X+?*!x;6&u1-jP#ZDI5X<(lMQqu`2$^F)|0G}dT(;=F zcG0J17W!K;;Gh@HhqS(jddGE=N+BoCztbMU&>dJL+`H?gGyD<)aHa>l{U15wYM1lg zoMC4o>|O5II{$SmRK$4v)W<*tSo(uniFK%RFzHRiQy4B=z6tA&6_D7o8p6y*&RGoL zUZSs^4wKN$(zMV>ec)drgi1jCyAxl}u0rG2HuWFipbAGj{z6VLd7_^F15z4fo5NO~ zS}krxBV?wWf8ja6tEQ~;i|oFnjqKYAv(bpmo2(_g)Kv)YADAC7Ew%uD0>9`!q>q$i z$(czfCCbMqRy`+16)?2>=JA{Ny+45P7w@oAwmt;W)~Eja&E{Z&hP^SUda=#c_VG4X zD;j`I%b~-EZiyd&CtDqz4GHi5vGiyZwdhWZ)cUM4um?cVYPuwfBhv=J?h&vJPqul7 zGl*pWa2<=x$Nw+r+cCHbp3@VQm&FTCuC8gknsuyrWLmpuf$>bixb1BnlLhFo*EI;s zS@h3~0z=nXI{KSFNF$S1SSS;TN>+D)Az&hp)rIyyL~4G@W*Uo<-b;gi31#0RyG!WT z_58?YZP3gjD#aGcBYQBSUTD)@E>0?BfY3^eA@Ouon^uVy2tE&Vllai`0s1v@j8yx?qjL5#OY0Zx}Pws{6wojlsyHQ;p>h{JY|5LC?9>zY90ygg-B^5JSLPeQwCv3!JyKGd-cR{3lfxHMz zUtAn4mEzWTcc^jMmWxV3@@pULTNvQ7_YG`1C$ir z(G?EN%bN)Mrv12-e;gyG;j?myOU){kSMvqv!9$qfs<-1`5%Sj$wI7p83&h$q_qHYr zq)qCxab#4OfmC62=Qi)5tkBm~3aDD*#@)pq`_fgKtdo(@8F0h00HePsOc}nWHJLF? zhL_Fy*kb&k-Z`3@3D^Hym#A?%Kr0bl$od@!doH+}a>adJZyD7#E`xY6bQJcnj^SUd zRo&unO0ZbL>s_tq;oEq}0eGxmCv&~ap6^XN#u|bJM>z(AY>LJ45RugJstY9sPoWM! z>lAw`>&ki&bUgLJ=4xp8pcZXTPTIMaHY*xx5X2I-^!Cy&9@c*|(u@K??s()N#f%=A z*Gq3!Wh>Q$=}Zu%pfiO6w}Fntdu0*iizv$2#iinKdcIGv+zTYQ4UJn z>G<$ln=IcD2eE^q&hx|57Xp>}j99Iht`YzfYc6pOuc07Eo*u=#H3v!L_8kH_Vn)%q|Lq`HuBnH7G>5lnyS>TxErrDN0~4?v@Z zK==wx)9f_Ue`fC^91g+ilr`tu&^`k9p1nk~q|5$Wx00U_hA5oD?rldv>{q zui6NmX@~`^8m+cxN4b3$epHG9{!Z34O_i6-dh3L(V@7TTA_^aHBXu(nS8Mv zILvXvb%s2}GjR~JC;Ru;VvBm{RUD11E?*0t_g@EAhl1qJ>8ESr(FLGflh7LEb(a{S zM)JqT?;RGENn3!lcxIkQYk!d%p%c*8CWy;B*ty|}wnPMa)RThLiiD1nD^ZtyJ^azo zjNr&m!CHq1ADuc-@KB8inj_q`Zs%>-db;7hE6a7r>Sod%t|1_d7%)UCr^V?T@K=0Sl=Q>w0)}Vmf&9RzEuET+ORy(BQ;(TLrM_2jwJouWOaT3qjm6{4b5g}QpE z{l#(c2L;gnc6sQ{reR-CL}I48&PAv8!+%tU5PEA()=0Q_Cl_2WeGDWfQ)q^HRepOe zMCthAj*oU_>Q>>FM>dnGd=2c=J7is#tqm_e!mU0D`Y%VS@sH^c$i-I{m z$3`MN`5@)=-`MN+biqE}iFO~k#*`6P(^6|ES%Jf?WYTBF$)GFmSo)iWbA!F;Lzh~j zdHr8~KV-IRd;n&KABFJgP}mP2M_8wQLxeM%;phrgl?9uo!z%gNpI{h8h2ECi!l@v~ z%eZC@rezAP-kb?jr8&`SjFWgR1x!{9OkY|*&LXM^N1E198m&ea;|}3LyVzZLh~%yr zff0_NwIU>iZcbii4451o|K?k_zdNO-OFkCUUcH(GKwdOshE`7+gsa#~Z)N@)jm}$9 zpOKQ>n1uBmV|B7XkgZc13?UIb%L}UBFAu}&V3_iw{AkIu{m#;zPGqA9Qj^tn&>?p@|c<+Wu{Qh8u7!s@D2Mp4XPIumTX7% zQr1C*`GE3|)s!(Jio7M0H>;yC9*}?jIaeZl!fL9z?K-)M2Ot+)6|w+Gc)h!*G5^J6E%8d0{LWs#bvC94ytPtWv*=63Q@0{+Gx!razp&Pzc04T= z*DZ`r%^|IT>0^vPKT{<8Ppe2c)?<@aso~A?B%Q)yLu&M_)g%H*R(W^xA>P+q^cLQzY_v{ z+xttprP~}eK9?9J@Q7U87CG5jAT(2V-W#=GN}>Yh@z|+!a+GWbU5ha@nYKEGv7VVi24mp1ev(iQ5cgd{L340HvU3Ttsg?& z!dYv=UBaMx5c7a_XnrZy($?jd*gOVx-V!Kp3XR^OsL0+hd}fExF+Mckl87Wmi|YEJ z*=_-QZ;ghY;yUhFffQcV4hred9j1@XJHO!(*KA0zr$&G#;_4xbh{O=pkDs?Ww(D(K zOm#Wg6@GgU^Qj>Z#T(#=UpIZqqaS;W_VgZwTT$br%|_)DTQY9wKmRy`G74b&#A?U; z^RbK;6Df$a6|n`)u)Ofy$c5V@V$^&RF$753i*2h`+@aJw{5kqM*I(A?59Ji@n4DyL z+MV`^ZU*4PFG=fNM10X{BhO{NVb6E7HIc9)J+wS5?=Fz*4F;csm0oH(n8l;_r;6&G znhYiCpdL{RGzuupn~^}92Vn6L`)%{6Ik{ZBfI*#K@$wbRpQ=cFyZP@tj>dA?G*G=9 zc9!^R8@YMBfcbLC2iHKbdlu4*BW+0bh!p_+1_p4y*z2vQ{LP28wXPW4$G%-cuK{+kT=W!g_n>SzeC%jdS&WkK7LH z%dVe#nPF*d`*i(5@44J`4Pj#%XWYZ_T9mL-UmO{h1XU+J6kG|xl~+6HSNx5o8s-73 z-0aO&gP!pTVSBPuoa7S^T;H7l+Vwh^C?AJ5A+Y>L>W1wE`S%f~xX|olew)%}@Hd|j z5v()78V0-D>H+&5j)ThnkY;&_9bU$qy~+*e3DfG<8N393t|@yx}P!FrY!Zz z+j<%AINXV#%|YyhKenCzkr$Ri8MMr4iKGNV{MNn@q8W`xkTdoEJV?l=3Ap?qKqBat z!}}1X4NaP$!1>4I{0F(%nbt@P9_aHxzHq%SRN4!u665&nq`zENVzgt!Hi0q|o)v|5 z3`8VVgwKCJ8s&O5nQ|iwzV(SUr4VO3FY(BuuVG__0*@DkPQ`*7rVCsNOhxTw6}ri! zaep+yy$^hoFXS+^fY;j-YN1LwFr)UIwCL@f)~uvwfe^+jUT3I2LZ>(me4>-M>G+KR zOr_E)2H4jop{#KVd2$d^^eVy{{s!d&Z&7?DTlAjs@1#WbH-9eRYgUFoY`0aHsKWIP z67mTHkwvq^q~9UDyS6=gd66L&k7=rD`Xf<3*4r!pu^DUwH2*jH}A~!N<;xADtaWcGQ1@co`^o*5}iY@F_nODbLk$7GxWW>OW9v;h1-aWp7h8dVDg1}uHQNVF=prb zUxVZvg!J(GzpU)lc$pwKasH2wU^M4oA;&6H9MX-KWVnjxm&yYMw=(?Fn(7YJO4|7+ zfFW>JqxEAVFu>1-Br$%H#5Y#Ccz$Yc_z!9$kG5727-HVZDmhyarbxBBOYb_?<9k=y zhVkonsjdIR6Qt$^dh=C~_ntZDsbLOlGmW*4*Ro){5YO%oXIo!G4&QCSMW1%WSk%Wh z4E4Yg&tjD>F(dDB!xw?J{UoQyzbpTG(T#FDrO57Tsj56UH+foqlOmwmUR?b+W08@q zyg|GLvVU4RlD`uW3ZWI=#0U``R6lS$(v3y@JoknU;a58O*Z0}sQv5S^64kDA{fp{N zgevzetSdsx&2x=BNu9$#?sSQo^c#E(O>ntlvUx%uHK*!RIT7a(ny1w9<5f=}K&<#I zLvgm!UBhg72Z@v@zH5_&+#+y|!|Ku3P81gj;`}Sj>LR@&wDTN8~zpgZH zc(UnO;coGP*x1}W;(qKw7R`6GoXFLqsIrt?0#VFaXO!GM*+N1<_g4&$kq4HHhWziO zNek-I)Y@|6&!B5B=PSwwr{9r4{MEXVmzM)#mqiH14l%=8r|(x+2o6!NiB7k0H9Hf? zDi?Lv*i8Bvoy8d@SY`}NAbAS|@W=hQp$^HdGG)PsXsbWEbslBJ>w`0Se>roNJ0X14 zRBm@9Dj5D7_%aUkzoS{+lc@-_+yq z?Eg@$q0A~mxxUJPCqn7vO*p7-emDm<#bCpTtXpco*)XtN-`(Uv$E2L3K0`W%A0gNt zvH#->>3bt*$E{u2%XIku`0v+1RS(X)ONn78V_l&D`_mIRo)|@Oo*?o6h)JQ?@U4!6 zplOhjyVIgz&xcN8&V2-klQ}mNKSMP@emRF#r~8(vh~7QcK^4J{KqmXH61)$-QT#!g zdnu9Y@1mxAB`RP#JUB?17z!qgPuaBnMPUb00;>a9x(eXo`TvY}l5f~U=J4zSw+=*N_ns_)x%b=y;H6^L~B_8c

FFabWqYOtQDbaZ*~rpZ>|m;`$3Oe(3yU2DI3_Z6X+(hdCmp*^TOiHf!za* zo0flO{|$pPVzQHPx9C@N71fIJ`pH4g0W#usNCnE zPsaBZCa3XU@uau6m3cJwo?KOb>r*n!z(@ic!O29~x4HAzni*g1Z+TL)2u9yi;WXU; zIvB0a{qtFx@up6nWu|I`Uz+D4Dod@WdBSuithl<+%9*p&f%4BQ0%AUYnlAKhpSw$q zPE_W_QMq}9C>%Wv7xm9DK=_Wqq4(;C0mK-}dKb9qL59tv_BpRUeE5Q%NCHPi(AQ9h z*)m>-b-;7e&}v&5nC2!n68wc^{SJ#;JR{5oNcU-HSfQW(Zhy`k`7mcy;U^y19QnV3PRA=8jg*oc_)BifrXv? zdE8Rt54M5Zdgqb}+_p`-(g)vgcl}{cQX=5J%5NE}AK0dEQq?$QzePA1`*{#kK~;Ht z+Z&&B;RR%MjP4`4-7ei_$#O?#c)C!`7253Is2GfzEUg}`rGp&Aoef{6(ckPzub9I@ z#D5ch`L4YGN`(b9+z8yNiGaI%(^Lp;V8G%DUz2tkVh)k@zg|Uwj1G!ZjA&LV9{{f_ zZ=6=33->j6O~8|O>XIr_M}R8Fa$fS-2;ulr7O=HGj4q^>h1Yqx!1jqT_zVn>r=?o4 zm@an?EE?1i0Zpc8YjSy>#v)dRKLbe1oy^1qW3D2Wd*RE%H|0r2v&1ro*gc3!a?@#Hi4uzZH6%U-70Y_RErZ)j1j6MI_jtCo|myB;;8@ zv%J!%Z|TOtY9{n?Y9-GdqcWm!g-70jf>eb4V4pnrzOHODh$U_J=dsO$kv@a3{vU0LV|3$0Bd@ zWZ9C(BT_>hNH--*=635ZKHK+~az2Dk0H26n#I|slf8D&>x)w@eMbPY9rcAi$#hcs; z0@j{9021bZoSlPWC0Y^yZ*1GPZ6_1kwr$&)*tTukwr$&-?E8lOi#n&ft7x?MaH(Qy zM5^kcuI-^>B*z~K^-uR|g{NU_7c>C*v*WzKqyjx9RM}-8`NkG0{ z2)JRGs+|?{rZavv>Fq21g?Naw#@M!7WLyInQX6T%QV%RSW0@e`i?Fs}dyI9NpO7kz0IyiM0VI=z`Bd(;3IQrc)(G&YH#Y!X1Q zkcqSaLV;g#sY)PqiGVQuHO| zE=o%!_4@9V?VZm5;RJV_jIu;jcm<%>WK`5LN;$h)2>#+D6t!*SdtWsZ1qUc4;OKY4 z?E~D?6)h|su&px*k~q1xAe2w_!c#oXqAn>5@VP8p3;<+PI@XRM%`>js8sWiwalIG4 z1K|N9M}-~hb?Xh@y#T=lKPfcLSDUktxR){Gf4YT{EMdM<#jM7;88U`ndH^M*J3C~z zk&pnlwR5Ir-3~n{V4QX#mEib~vl*Bp3_yRPEiU7`i@gCC>#Cfsg#tp~$Z85?C-ja% zZLJYwHh@=7o{mw<1%@l+5ye$N|IiF)qT}9QJi6XfJ^ZR08bFY(t*sY4YlGy{{XMWu zUiHRD$ss6J?hk^$yD{6u2Vi7H`}ZNG3oo;VEx{&OLJ3SLR(GQRxADp37CTRl@E7p! zCRl}a%-xk(MIN#7Y|mLgQ3xA*u$e6-M%EQ&8Nayh|40r>if&0G^jPZtixrK{4zjgK zTBB}-2>idC(SCMBk(;uwWq_Z2LpM>B@t?GehApKsmRwjts(lpuvpNHI!? zP<4+10E)0FQ{UA9#;ajTbuRvZRZqMP)Gr!vY)BmWbFvgBeRK%=j+TliGP9F&?r+>RU|mqUjqT>oCj&ifUGD(3VOT@s#R}iEKnI6 z+(nj)>l3AJEpfo4j?*BD4G^3mN?0v^Lc>`Yk$~j6wh8S$pG+YFDJ8%iJ%s(_p{@%{ z4_F%WR~rmhyz7pgijrE8PY*s==Mb=>fUJmjMUnReTvJsajmQ>H+@Px;Z3s-G|EQO%9>oF&U0fYtS0`~yEvCjnbUy0mx) z3)`FWzGI~}VR7d3I@Rj2KuY2D5z+LYAyS@Hcz^h|MWP2;QtOE`avGoLLDDcohw>;dKc?}y-k3wTL zV98ZS8C2gp*fCQ9i=;VybqoZ9|HRyxup+o~Rq@~f;6~9X2xr+x1oUV^_7E$-dqS{AV8pl!e!toxyS`_XMUUdF2OOw0~rJV%iBGXR^Bb zx?nZ{U`0I+lDe1VYE%Si5H_EjNorrNObA?uN8L3EVf{Y;%dpEdQzYO21A0r$#^8ld8+!;AR{{$ZLoA5rNui6Z&^|%K5{pJQ*MH| z;80EwNMZQl(_h=50t0&+7_IUNy8rTb_`(nX)46Rf7Hmkd?>;*7?~apA_?X<8G@#ih zk^?&hqB>mwOSt_b^~8@b^OVK3@~jMgRs$!3^*K4FeNrB1t9uWC{V%qWpFLmDe4?b< z^_B{VY_1S%xDG^Tv5jKx zmie6l!|o|Fyr&6vc>gX@^IChd8Q29NXsASfnj=ZxKP+v&QFjFKHoI7;?)DF4t#H)oY}3Es2VnpB^mkv zGM9)7VW6?0PvIrSX2<}O3APLw{<~SGt0Om^2gk-*c?;X@hKHB0iSK&h$vQwh1*oWa zLer&@$DZ~ADSt_sz?PeL1w9#a-8%%a+X&#P4BQ7Tv`m2EUO=Hq#ejIdaCov7J7^`n zIo;w9?I>U>7gL?M9@s4^(z1%GLg{;ADrzdYNY(x6PhD-S6EENiY;S|6(IVTINvt$s zI~dX%s$9(6#N{RWex=u7R}H{2ZiGANoBpw_`LU4&i>u!mhH2j*W2u4TpdFNNXaEql zdBkIYbe4%^Q}lL%M%c5?9506?Aixk)L$Khgivwgec*`QhE5;I1x@lHFuAvNJJfE5i zp~v)F)D-rA)BtK43E37)bJ;X(Zh80-Tk_?l{73SOX!m(Ppb*MR+5uYpR;m{HQae86 zW<7sDk43hTJd;2Ff@vOI!CWyQA{J~D&nJrX?>^4e-A{MHUfjKBq1_K3P+eYi2vu-K zl-QgTM>UIXgAtv2@mMJra}3`|y2PupHJVNjhA1ftEjR}6Sx6+o~3hq z7e!#T#ac?9p2HlopmR-#eFopB4H)xFCr7u;-*y;l6-Qm81b;&G!lnp8Bgo+ zW({zhIZ$9GJ6tq7Mcx+;zY}0lo$At&RUmm+?`|m8*kU$B9DFcg%}NZgV-MvWiE;#~ zDkG+I{~eo>y44u^DJ1^q5SJ=JIhUo^Q4x1~$JyznxsXjOeC>Z`itu@TQG=4~W=lyn z$8CuT%<@H>U|6XYmJ|A_rM@WliU}h)Q{#hjI`T;CkX1l{Y~m_Ye8DFtLH87jN&r~c z={}2Q>=&+e?}I7Mdn3_4dAXQdPq16(|;DL z^{=Vb&thDoM>ayy%vl&R!=oPYt3KkZcNKWJW1k(4q`t(aCrc*hZzfN1YQ8nb8K{4K z1~SaeEYfozPKkOBc8xgE5F~;taO$^cNfP#`eUs#myUtr-sKoXtw4^D3E8AhqPMN+3 z7h~7iXHh$g#O{a* zjB9EhvFH$Uwm4->EiJeC9{2GRpX>)XYj&e~6}#xjcPItL;Z6uQ4!-yt7VC=>Y~8?T z+H+BG5}&7JZe8&uR&IJSln|1tg%tjEVgY-()F=NT!p(N_g!e7nKoHEeXGV`skR*gF z_)@^NGY!J{Lvah^a{I+NTxlHp@&KPZUlP4r22v0DlU5BB!8W|o5z(~--VEZ&W7W~L za!r{pXAwJwl52s1m*u6f$s;}!U+s}AE?k8Ob93rIG*d`r?2+i`?tF!?npRnkgz(>; z3CQEGE3qn-TVLe9F71R_Vgcv1Iv+3>Ied>G!`N2g&7DyitvLY}&pN%08UJbrgufE4P*!!3*o*9*=zN6!vBS!WZv#pw{$3k1C;TOH!w+pW; zPeTA8EQ77?zZJ3xf>ib_F~% zd#o)O6ctdiL7KhlwMbZqg>Jh@m^%8St&@*J3Gf@XGun_qNFq8U{Axx&Je!$ z(xCon+#|?kIHRXVs_ev=&~7WhriB(3OCu?q7fwfsj!HNl+(EC9`Vy>X;_)EvVR4WP zwznQ_`0M;;dQ-8flC?Cc7@nq6Ic5y!+r@A4AWNL1$4P`^D2~T(He{kGeXRmTv<>q5 z(W@4a#D+@KA5SKdP=ExgwXwMrHU80I00Jc!5A7>Ot~6)e*c+$98-GTNkxvvY+%Y%l zD_kE>PE%d0)t;AHP#&(+3BE3HAc?&`wslt->T?qCw8!bNnEZki`ad}XecRnT#(k`w zhm?rDu>X%E$s5L*0l$E~;|^%_HLFP$LssIDhX;F^*3x@8W^21ag8~paHA}%Jf6qdS zs;IOTBWI^+h&cR3MS9Ps2Krh)o?yGorr`8zR#gNTaT{=l6GC2?v)7+(-ys1ynNXn7 zwJ=N4t7hZgd`g7<4A`kv!mXJ-Rkl__^uslnS}iJyEp`FNpe$Pc>?19REG=wJ4uhKT zfOHKBT5(=u%L?h>v5n3h^Ys@N7su{t*h{*@u-OP3^VWE zA6Ce}5AVASr=3t5>pn{aLuA^vn4=c`)xpWo(vjqS+7Pg(RGYz)YA3Vy1VM{WR1_E$ z#vFPHIzJUu%K~0LzIR$mKfkrYVn{*B#LE%!e4WqYe9hK)_24~GHnMdbWV_{v^e^?J z7U@7cExUQh!E)32YOryA@$uHjkp=Fsjh^gkRO5TABz;-Y7?9_Q<79DVwC1$CZ8k}5?dADE zwAj}ly2iN0j4V;`W+9=ShO9N2Vr=D5dVJQXEiV5Ux1s49EWe%AIq&neSZ1c)1-Ifk zG<7I8?KfguO5Q6UdZ}K^BnO$biv%Jr81-dxj8cQ{MIDqZh)C(J(RlOP8ru0ZoyRWG zVRP-XGtcCZ!U)1*LqXdA1QlH!z8rBrH?|S)lETrkgV63~&OQZd{N-lfG<>V)ifgOj z$DjDjTS7$LLPBiRQI$F7P^;sygN2baNbH6`)X>IjQS3sn&(2d-am>pqYgT|2!X(_7 zdU#L!80UKR2l4A{$(;P_|4R^YPlZ~oil}kh$sHQTc8(UttcYnuKJ8v*n;EPTfrC;_ zV{PNIMB00&+-}jP8MM75;f+SB|YX?;iSLX7*+-?Sf4(8J9Kr*Yh&c5Bk6NZu^pcl@U(VpX8 zZzad60Vk_mdwjpYqc}2GPP!%POjVWzH>$0KNrJ|Dw%y`d7&-EY{=eI6KTF~iQZC}H zwm?mF1bqkfUElw9balb1U58zwR~u2NLypP@4Ng41d7sE9s!@bzRjp zHpcl(EK_@{`;bKTVDZZ0vl9Gcr>;!WPv8Qmcmm2$CYt$G$zKUuzSVh7%6JiO*wdT; zjTj_(J|aqzI@~1#(M<@^6Cs;P?NP*TH27`*+l2a9-g+k3uDovGe27(Kh}qCF7AI&x z7NC)s71q(sR5jfr!Oh@P7t5UzcdZvshC%jd}!&|A^F+3@7@@p!JRYcb+Ji@_G@Ng_`A%uC`o&+{m zSYzL%_g?tp$9TNCca5a#fu+TR^j7KBhmQ6)8kpPbW9z4}qm{(d<9j&3j@0^h*>MeUnDhbJe|(id|IX)L6Bhf55Bxes{lsmd&j00 zp0y*4oB9$eq9i_C+;KAt-l%ouRMTdeilQxx>>8y)rwerO)zIep#|ZAVl|V#Ym<##3 zN0hIRY09mzB?|5vkC6f3&N{F^h5Btp2}%L-ofH0&PTSt+2ppAA!eMwF@NsZ4GwSU2%gY1nEgXvz-z1~#%!7ZcZ}NN~9=o==3%nhnnL zwIk0VHKuW#il23t8Qmx*szq8E;+(ecqGPBmg7y>sMxib0dEEF-`m*g>s>)#T>}ugU z2$J{t4P1#Ju_9@T_J+=hl>J|X-=(e6GTWhY`K=2GPA9??&f-8K9QyJ3gDxI*B;KXU zadFyjM=4n4;mWyIFZ~%ka6|WRXUO~@JYT&t+V5%|3|4C^0k$i18G2}u?Fq1OQF z5=Kq-fk=R7Ghes zw9Wx^lKOK@Q}&&LR#pTIDe8lb`O?#R+u6zGj9qjtbvu|u>>q^}S2W?rGPdKBMVwJA z)D8%(3El@}#N47=96oudhjFLc7NT;73SG~br;=+6CV%LNs>OL%jqguLX|(Q@GI;mR z4|X~GS?})zKjhVk?%~DY?1g@YtEO&1T3`uH@lA&g?4d~#tfv6OHD{{CAUg2hs)m;& zy}CBrrC~F30*ZzIPtK6foHsyO!}LK#v8LbRkMu?y~P&e1#1aYt=A9a zjCXSVtp8=qpDkiwlD8ZzPFS_oO?h*zJ5ELJVd_`><;UV_NS2Y}|F6ISQj`$l z_$XJIf6eRJx89mP$KM9GKJP$FCfk(4lkm&_^aq;55Hey4enNGF`N^G<@TQ46U_yA+uN*~Iz7-Fp!Vnd`21E%Px#Wkt>s!>}`^ zSNZFQq|_$qcW&&@s{}C64$jJ8WbmJSnG$%5k&<3v=6S_fE+4jDvg3_^E8Vg4YA4I27f9$%Y3-RS9U9 z5NK$>MAGlVo;w+4ymeMrL+vU!J$m>CPvTFD;4twmNNwH{2TcVNZI#iKyAQ#rR__s?oOA>krsHL7cQUY`A6zqQWhA&zw(XH8nvA2CH7LLFqlA_=-m_)@~t#8t=9g ziJaG`utUQ4Iro%?68<_aj?I8Nx#0PJ7$=!JsYCa?QE(fvuj^9twj)ZD%SEM|`!rcb zw6lFG>Ig0~&sQW;A#8JA=UnFkU!^Oxl}2jTW9i@KwMu9@W#)n-?@5zY1i8PyB7T5y zk|W1M#oD3ThcTVPm%EH#xn0eko8x^CWbj)as!VX=$_w&u(5?sNNpPUg-KBXB6Joo$ zyC^)P&9aJI!IZ!-AD8k9+_m9^6Z{y$>#6x^ttJ&rw}{uJD6Cez?h;U(4W<`EmZcGe zIELCMrw=zHOkr*+66DFz>0*VJR&=U*0`=CHfcKWWgG5a%3YDiV+jJ_QX;L`&7_VJP zuBEAv^z?YDQk@j3ELUpQuNd!D7IjkF3QQtc(Z5uCby0Ey+f<`H_st=B&oiEE`Y4xC z%%<5bZO-5kpzBEoxsgoz~kYH9=1H%K~%}n3K%83ptC*O4=8?*F% zLM_!N4OEGc0cD3AoR%?aSj+TD^^D};Yhds;V?QK9TTyNRm0}%yk&e^er{^cLpZ5slQX3SBC zSXI5_i*hGa`X;^`^r%;N%KDrL+yyEhntNt&AR1bJ}s=|2`5(25@Y>dW=eP zvDbxwlPnEQTZ%Kz%7_tbts9MBJeIlJ;u3M zX#l-T^|nIBUolo~jSV(vY~;_L`;Fp62W>eIdKN+Lt9KIp)L71$@bQ?t*$E3cW;J96B$*L-13pTgn2K5#JJCj#vuJu


j5uNBv0uPE^;9b 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 + if !spec.is_gloas_scheduled() { + return; + } + + let harness = get_harness(VALIDATOR_COUNT, spec.clone(), NodeCustodyType::Supernode); + harness.execution_block_generator().set_min_blob_count(1); + + // Build some chain depth. + let num_blocks = E::slots_per_epoch() as usize; + harness + .extend_chain( + num_blocks, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + harness.advance_slot(); + let slot = harness.get_current_slot(); + + // Produce a Gloas block via the harness. This caches envelope + blobs. + let state = harness.get_current_state(); + let (block_contents, opt_envelope, _post_state) = + harness.make_block_with_envelope(state, slot).await; + let signed_block = &block_contents.0; + + assert!( + opt_envelope.is_some(), + "Gloas block production should produce an envelope" + ); + + // Verify the block has blob commitments in the bid. + let bid = signed_block + .message() + .body() + .signed_execution_payload_bid() + .expect("Gloas block should have a payload bid"); + assert!( + !bid.message.blob_kzg_commitments.is_empty(), + "Block should have blob KZG commitments" + ); + + // Generate data columns from the block (using test fixtures, same as the harness does). + let data_column_sidecars = + generate_data_column_sidecars_from_block(signed_block, &harness.chain.spec); + assert_eq!( + data_column_sidecars.len(), + E::number_of_columns(), + "Should produce the correct number of data columns" + ); + + // Verify all columns are Gloas-format. + for col in &data_column_sidecars { + assert!( + col.as_gloas().is_ok(), + "Data column sidecar should be Gloas variant" + ); + let gloas_col = col.as_gloas().expect("should be Gloas sidecar"); + assert_eq!(gloas_col.beacon_block_root, signed_block.canonical_root()); + assert_eq!(gloas_col.slot, slot); + } + + // End-to-end DA flow (process_block → process_envelope → process_rpc_custody_columns) + // is not exercised here: Gloas blocks are not gated on columns at block-import time + // and the envelope/column gating belongs in a dedicated test once the DA path matures. +} + // Regression test for verify_header_signature bug: it uses head_fork() which is wrong for fork blocks #[tokio::test] async fn verify_header_signature_fork_block_bug() { diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index dc4f999eb2..1d23990b80 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -573,3 +573,121 @@ async fn prepare_payload_on_fork_boundary( advanced state" ); } + +#[tokio::test] +async fn gloas_block_production_caches_blobs_for_column_publishing() { + use beacon_chain::ProduceBlockVerification; + use beacon_chain::graffiti_calculator::GraffitiSettings; + use eth2::types::GraffitiPolicy; + + let spec = Arc::new(test_spec::()); + if !spec.fork_name_at_slot::(Slot::new(0)).gloas_enabled() { + return; + } + + let db_path = tempdir().unwrap(); + let store = get_store(&db_path, spec.clone()); + let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); + + // Configure the mock EL to produce at least 1 blob per block. + harness.execution_block_generator().set_min_blob_count(1); + + // Extend the chain a few slots to get past genesis. + harness + .extend_chain( + (E::slots_per_epoch() as usize) + 1, + BlockStrategy::OnCanonicalHead, + AttestationStrategy::AllValidators, + ) + .await; + + harness.advance_slot(); + let slot = harness.get_current_slot(); + + // Produce a Gloas block directly via produce_block_on_state_gloas so we can + // inspect the pending cache before it's consumed. + let mut state = harness.get_current_state(); + complete_state_advance(&mut state, None, slot, &spec).unwrap(); + state.build_caches(&spec).unwrap(); + + let proposer_index = state.get_beacon_proposer_index(slot, &spec).unwrap(); + let randao_reveal = harness.sign_randao_reveal(&state, proposer_index, slot); + + let (parent_payload_status, parent_envelope) = { + let head = harness.chain.canonical_head.cached_head(); + ( + head.head_payload_status(), + head.snapshot.execution_envelope.clone(), + ) + }; + + let graffiti_settings = GraffitiSettings::new( + Some(Graffiti::default()), + Some(GraffitiPolicy::PreserveUserGraffiti), + ); + + let (_block, _post_state, _value) = harness + .chain + .produce_block_on_state_gloas( + state, + None, + parent_payload_status, + parent_envelope, + slot, + randao_reveal, + graffiti_settings, + ProduceBlockVerification::VerifyRandao, + ) + .await + .unwrap(); + + // The envelope + blobs should now be in the pending cache. + assert!( + harness + .chain + .pending_payload_envelopes + .read() + .contains(slot), + "Pending cache should contain an envelope for the produced slot" + ); + + // Take the blobs from the cache — this is what publish_execution_payload_envelope does. + let blobs = harness + .chain + .pending_payload_envelopes + .write() + .take_blobs(slot); + + assert!( + blobs.is_some(), + "Blobs should be cached alongside the envelope" + ); + + let blobs = blobs.unwrap(); + assert!( + !blobs.is_empty(), + "Blobs should be non-empty when min_blob_count >= 1" + ); + + // Verify take_blobs is consume-once. + let second_take = harness + .chain + .pending_payload_envelopes + .write() + .take_blobs(slot); + assert!( + second_take.is_none(), + "Blobs should only be consumable once" + ); + + // The envelope should still be in the cache after taking blobs. + assert!( + harness + .chain + .pending_payload_envelopes + .read() + .get(slot) + .is_some(), + "Envelope should remain in cache after taking blobs" + ); +} diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs index 382b967b43..06a5915c08 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs @@ -1,10 +1,12 @@ use crate::block_id::BlockId; +use crate::publish_blocks::publish_column_sidecars; use crate::task_spawner::{Priority, TaskSpawner}; use crate::utils::{ChainFilter, EthV1Filter, NetworkTxFilter, ResponseFilter, TaskSpawnerFilter}; use crate::version::{ ResponseIncludesVersion, add_consensus_version_header, add_ssz_content_type_header, execution_optimistic_finalized_beacon_response, }; +use beacon_chain::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; use beacon_chain::{BeaconChain, BeaconChainTypes}; use bytes::Bytes; use eth2::types as api_types; @@ -12,10 +14,11 @@ use eth2::{CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; use lighthouse_network::PubsubMessage; use network::NetworkMessage; use ssz::{Decode, Encode}; +use std::future::Future; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; -use tracing::{info, warn}; -use types::SignedExecutionPayloadEnvelope; +use tracing::{debug, error, info, warn}; +use types::{EthSpec, SignedExecutionPayloadEnvelope}; use warp::{ Filter, Rejection, Reply, hyper::{Body, Response}, @@ -85,7 +88,9 @@ pub(crate) fn post_beacon_execution_payload_envelope( ) .boxed() } -/// Publishes a signed execution payload envelope to the network. +/// Publishes a signed execution payload envelope to the network. Implements +/// `POST /eth/v1/beacon/execution_payload_envelope` per the in-flight beacon-APIs PR +/// . pub async fn publish_execution_payload_envelope( envelope: SignedExecutionPayloadEnvelope, chain: Arc>, @@ -109,7 +114,24 @@ pub async fn publish_execution_payload_envelope( "Publishing signed execution payload envelope to network" ); - // Publish to the network + let blobs_and_proofs = chain.pending_payload_envelopes.write().take_blobs(slot); + + // Spawn the column-build task (CPU-bound KZG cell-and-proof computation) before + // publishing the envelope so it runs in parallel with envelope gossip, narrowing + // the window in which peers see envelope-without-columns. If envelope publication + // fails below, dropping this future drops the spawned `JoinHandle` (the running + // closure on the blocking pool finishes and is then discarded — no work cancellation). + let column_build_future = match blobs_and_proofs { + Some(blobs) if !blobs.is_empty() => Some(spawn_build_gloas_data_columns_task( + &chain, + beacon_block_root, + slot, + blobs, + )?), + _ => None, + }; + + // Publish the envelope to the network. crate::utils::publish_pubsub_message( network_tx, PubsubMessage::ExecutionPayload(Box::new(envelope)), @@ -121,9 +143,130 @@ pub async fn publish_execution_payload_envelope( ) })?; + // From here on the envelope is on the wire. `take_blobs` already consumed the cache + // entry, so a retry would not republish columns; returning Err would mislead the + // caller. Log column-build/publish failures and fall through to `Ok`. + if let Some(column_build_future) = column_build_future { + let gossip_verified_columns = match column_build_future.await { + Ok(columns) => columns, + Err(e) => { + error!( + %slot, + error = ?e, + "Failed to build data columns after envelope publication" + ); + return Ok(warp::reply().into_response()); + } + }; + + if !gossip_verified_columns.is_empty() { + if let Err(e) = publish_column_sidecars(network_tx, &gossip_verified_columns, &chain) { + error!( + %slot, + error = ?e, + "Failed to publish data column sidecars after envelope publication" + ); + return Ok(warp::reply().into_response()); + } + + let epoch = slot.epoch(T::EthSpec::slots_per_epoch()); + let sampling_column_indices = chain.sampling_columns_for_epoch(epoch); + let sampling_columns = gossip_verified_columns + .into_iter() + .filter(|col| sampling_column_indices.contains(&col.index())) + .collect::>(); + + // Local processing only — envelope already broadcast, so log and fall through. + if !sampling_columns.is_empty() + && let Err(e) = + Box::pin(chain.process_gossip_data_columns(sampling_columns, || Ok(()))).await + { + error!( + %slot, + error = ?e, + "Failed to process sampling data columns during envelope publication" + ); + } + } + } + Ok(warp::reply().into_response()) } +fn spawn_build_gloas_data_columns_task( + chain: &Arc>, + beacon_block_root: types::Hash256, + slot: types::Slot, + blobs: types::BlobsList, +) -> Result>, Rejection>>, Rejection> { + let chain_for_build = chain.clone(); + let handle = chain + .task_executor + .spawn_blocking_handle( + move || build_gloas_data_columns(&chain_for_build, beacon_block_root, slot, &blobs), + "build_gloas_data_columns", + ) + .ok_or_else(|| warp_utils::reject::custom_server_error("runtime shutdown".to_string()))?; + + Ok(async move { + handle + .await + .map_err(|_| warp_utils::reject::custom_server_error("join error".to_string()))? + }) +} + +fn build_gloas_data_columns( + chain: &BeaconChain, + beacon_block_root: types::Hash256, + slot: types::Slot, + blobs: &types::BlobsList, +) -> Result>, Rejection> { + let blob_refs: Vec<_> = blobs.iter().collect(); + let data_column_sidecars = beacon_chain::kzg_utils::blobs_to_data_column_sidecars_gloas( + &blob_refs, + beacon_block_root, + slot, + &chain.kzg, + &chain.spec, + ) + .map_err(|e| { + error!( + error = ?e, + %slot, + "Failed to build data column sidecars for envelope" + ); + warp_utils::reject::custom_server_error(format!("{e:?}")) + })?; + + let gossip_verified_columns = data_column_sidecars + .into_iter() + .filter_map(|col| { + let index = *col.index(); + match GossipVerifiedDataColumn::new_for_block_publishing(col, chain) { + Ok(verified) => Some(verified), + Err(GossipDataColumnError::PriorKnownUnpublished) => None, + Err(e) => { + warn!( + %slot, + column_index = index, + error = ?e, + "Locally-built data column failed gossip verification" + ); + None + } + } + }) + .collect::>(); + + debug!( + %slot, + column_count = gossip_verified_columns.len(), + "Built data columns for envelope publication" + ); + + Ok(gossip_verified_columns) +} + // TODO(gloas): add tests for this endpoint once we support importing payloads into the db // GET beacon/execution_payload_envelope/{block_id} pub(crate) fn get_beacon_execution_payload_envelope( diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index 6b65995a73..644ade956a 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -494,7 +494,7 @@ fn publish_blob_sidecars( .map_err(|_| BlockError::BeaconChainError(Box::new(BeaconChainError::UnableToPublish))) } -fn publish_column_sidecars( +pub(crate) fn publish_column_sidecars( sender_clone: &UnboundedSender>, data_column_sidecars: &[GossipVerifiedDataColumn], chain: &BeaconChain, From e8c865dcc6332c5b0b0f52ee5b1587b184c608a7 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Tue, 28 Apr 2026 23:50:07 +0200 Subject: [PATCH 149/189] Gossip reprocessed payload envelopes that are timely (#9210) Payloads from the reprocess queue should be gossiped after import if they are still timely. In devnets this happens frequently since there are many cases where the envelope arrives before the block Co-Authored-By: Eitan Seri-Levi --- .../gossip_methods.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 29306c198d..0135d7f5dd 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -3627,6 +3627,23 @@ impl NetworkBeaconProcessor { self.propagate_if_timely(is_timely, message_id, peer_id) } + /// If a payload envelope is still valid with respect to the current time (i.e., its slot + /// matches the current slot), propagate it on gossip. Otherwise, ignore it. + fn propagate_envelope_if_timely( + &self, + envelope_slot: Slot, + message_id: MessageId, + peer_id: PeerId, + ) { + let is_timely = self + .chain + .slot_clock + .now() + .is_some_and(|current_slot| envelope_slot == current_slot); + + self.propagate_if_timely(is_timely, message_id, peer_id) + } + /// If a sync committee signature or sync committee contribution is still valid with respect to /// the current time (i.e., timely), propagate it on gossip. Otherwise, ignore it. fn propagate_sync_message_if_timely( @@ -3831,6 +3848,12 @@ impl NetworkBeaconProcessor { let process_fn = Box::pin(async move { match chain.verify_envelope_for_gossip(envelope).await { Ok(verified_envelope) => { + let envelope_slot = verified_envelope.signed_envelope.slot(); + inner_self.propagate_envelope_if_timely( + envelope_slot, + message_id, + peer_id, + ); inner_self .process_gossip_verified_execution_payload_envelope( peer_id, From 16132a369417f6ee35eb7fb7ae6c5e714dc8cad4 Mon Sep 17 00:00:00 2001 From: jking-aus <72330194+jking-aus@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:23:24 +0200 Subject: [PATCH 150/189] Spec v1.7.0-alpha.6 and Gloas genesis (#9190) Co-Authored-By: Josh King Co-Authored-By: Jimmy Chen Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/beacon_chain.rs | 3 - .../src/block_production/gloas.rs | 3 +- .../src/payload_bid_verification/tests.rs | 1 + .../src/payload_envelope_streamer/tests.rs | 1 + .../gossip_verified_envelope.rs | 1 + .../payload_notifier.rs | 2 +- .../src/pending_payload_envelopes.rs | 1 + .../gossip_verified_proposer_preferences.rs | 4 +- .../proposer_preference_cache.rs | 21 +++-- .../tests.rs | 10 ++- .../beacon_chain/tests/prepare_payload.rs | 5 -- .../src/network_beacon_processor/tests.rs | 1 + consensus/proto_array/src/proto_array.rs | 28 +------ .../src/envelope_processing.rs | 13 +++ consensus/state_processing/src/genesis.rs | 35 ++++---- .../src/per_block_processing.rs | 11 +-- .../src/per_block_processing/withdrawals.rs | 9 +-- .../src/per_epoch_processing/single_pass.rs | 20 ++++- consensus/types/configs/mainnet.yaml | 14 ++-- consensus/types/configs/minimal.yaml | 14 ++-- consensus/types/presets/minimal/gloas.yaml | 4 +- .../types/src/block/signed_beacon_block.rs | 6 +- .../types/src/builder/proposer_preferences.rs | 3 +- consensus/types/src/core/chain_spec.rs | 65 ++++++++++++++- consensus/types/src/core/eth_spec.rs | 2 +- .../execution/execution_payload_envelope.rs | 2 + consensus/types/src/state/beacon_state.rs | 80 +++++++++++++++++-- testing/ef_tests/Makefile | 2 +- testing/ef_tests/check_all_files_accessed.py | 1 + testing/ef_tests/download_test_vectors.sh | 2 +- .../ef_tests/src/cases/epoch_processing.rs | 21 ++++- testing/ef_tests/src/cases/operations.rs | 43 ++++++++++ testing/ef_tests/src/handler.rs | 4 + testing/ef_tests/src/lib.rs | 7 +- testing/ef_tests/tests/tests.rs | 27 ++++++- 35 files changed, 349 insertions(+), 117 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 9da64888c2..ccb12a353d 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -5023,11 +5023,8 @@ impl BeaconChain { } .ok_or(Error::MissingExecutionPayloadEnvelope(parent_block_root))?; - let parent_bid = advanced_state.latest_execution_payload_bid()?.clone(); - apply_parent_execution_payload( &mut advanced_state, - &parent_bid, &envelope.message.execution_requests, &self.spec, ) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 79ea78ce4a..a02963c358 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -623,11 +623,13 @@ impl BeaconChain { // For trustless building, the builder will provide the envelope separately. if let Some(payload_data) = payload_data { let beacon_block_root = block.tree_hash_root(); + let parent_beacon_block_root = block.parent_root(); let execution_payload_envelope = ExecutionPayloadEnvelope { payload: payload_data.payload, execution_requests: payload_data.execution_requests, builder_index: payload_data.builder_index, beacon_block_root, + parent_beacon_block_root, }; let signed_envelope = SignedExecutionPayloadEnvelope { @@ -854,7 +856,6 @@ fn get_execution_payload_gloas( let mut withdrawals_state = state.clone(); apply_parent_execution_payload( &mut withdrawals_state, - parent_bid, &envelope.message.execution_requests, spec, )?; diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index 98863a49d5..b7b77d5d2a 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -256,6 +256,7 @@ fn make_signed_preferences( validator_index, fee_recipient, gas_limit, + ..ProposerPreferences::default() }, signature: Signature::empty(), }) diff --git a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs index be3dbf33ce..be763b4ee2 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_streamer/tests.rs @@ -72,6 +72,7 @@ fn build_chain( execution_requests: Default::default(), builder_index: 0, beacon_block_root: block_root, + parent_beacon_block_root: Hash256::ZERO, }, signature: Signature::empty(), }) diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs index 80724e2b00..a20963302b 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/gossip_verified_envelope.rs @@ -339,6 +339,7 @@ mod tests { execution_requests: ExecutionRequests::default(), builder_index, beacon_block_root: Hash256::ZERO, + parent_beacon_block_root: Hash256::ZERO, } } diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs index df21d33493..eb5e13b0cc 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs @@ -87,7 +87,7 @@ impl PayloadNotifier { Ok(NewPayloadRequest::Gloas(NewPayloadRequestGloas { execution_payload: &envelope.message.payload, versioned_hashes, - parent_beacon_block_root: block.message().parent_root(), + parent_beacon_block_root: envelope.message.parent_beacon_block_root, execution_requests: &envelope.message.execution_requests, })) } diff --git a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs index 293553ef54..8f7568d017 100644 --- a/beacon_node/beacon_chain/src/pending_payload_envelopes.rs +++ b/beacon_node/beacon_chain/src/pending_payload_envelopes.rs @@ -105,6 +105,7 @@ mod tests { execution_requests: ExecutionRequests::default(), builder_index: 0, beacon_block_root: Hash256::ZERO, + parent_beacon_block_root: Hash256::ZERO, }, blobs: None, } diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs index 8ea095743f..e97dab56d7 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs @@ -64,6 +64,7 @@ impl GossipVerifiedProposerPreferences { ctx: &GossipVerificationContext<'_, T>, ) -> Result { let proposal_slot = signed_preferences.message.proposal_slot; + let checkpoint_root = signed_preferences.message.checkpoint_root; let validator_index = signed_preferences.message.validator_index; let cached_head = ctx.canonical_head.cached_head(); let current_slot = ctx @@ -74,7 +75,7 @@ impl GossipVerifiedProposerPreferences { if ctx .gossip_verified_proposer_preferences_cache - .get_seen_validator(&proposal_slot, validator_index) + .get_seen_validator(&proposal_slot, checkpoint_root, validator_index) { return Err(ProposerPreferencesError::AlreadySeen { validator_index, @@ -162,6 +163,7 @@ mod tests { fn make_preferences(proposal_slot: Slot, validator_index: u64) -> ProposerPreferences { ProposerPreferences { + checkpoint_root: types::Hash256::ZERO, proposal_slot, validator_index, fee_recipient: Address::ZERO, diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs index 69337f2a83..e2b0c40fb5 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs @@ -5,11 +5,11 @@ use std::{ use crate::proposer_preferences_verification::gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences; use parking_lot::RwLock; -use types::{SignedProposerPreferences, Slot}; +use types::{Hash256, SignedProposerPreferences, Slot}; pub struct GossipVerifiedProposerPreferenceCache { preferences: RwLock>, - seen: RwLock>>, + seen: RwLock>>, } impl Default for GossipVerifiedProposerPreferenceCache { @@ -34,21 +34,27 @@ impl GossipVerifiedProposerPreferenceCache { self.preferences.write().insert(slot, preferences); } - pub fn get_seen_validator(&self, slot: &Slot, validator_index: u64) -> bool { + pub fn get_seen_validator( + &self, + slot: &Slot, + checkpoint_root: Hash256, + validator_index: u64, + ) -> bool { self.seen .read() .get(slot) - .is_some_and(|seen| seen.contains(&validator_index)) + .is_some_and(|seen| seen.contains(&(checkpoint_root, validator_index))) } pub fn insert_seen_validator(&self, preferences: &GossipVerifiedProposerPreferences) { let slot = preferences.signed_preferences.message.proposal_slot; + let checkpoint_root = preferences.signed_preferences.message.checkpoint_root; let validator_index = preferences.signed_preferences.message.validator_index; self.seen .write() .entry(slot) .or_default() - .insert(validator_index); + .insert((checkpoint_root, validator_index)); } pub fn prune(&self, current_slot: Slot) { @@ -77,6 +83,7 @@ mod tests { validator_index, fee_recipient: Address::ZERO, gas_limit: 30_000_000, + ..ProposerPreferences::default() }, signature: Signature::empty(), }), @@ -97,11 +104,11 @@ mod tests { for slot in [1, 2, 3, 7] { assert!(cache.get_preferences(&Slot::new(slot)).is_none()); - assert!(!cache.get_seen_validator(&Slot::new(slot), slot)); + assert!(!cache.get_seen_validator(&Slot::new(slot), types::Hash256::ZERO, slot)); } for slot in [8, 9, 10] { assert!(cache.get_preferences(&Slot::new(slot)).is_some()); - assert!(cache.get_seen_validator(&Slot::new(slot), slot)); + assert!(cache.get_seen_validator(&Slot::new(slot), types::Hash256::ZERO, slot)); } } } diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs index 2f1b24fcbb..d3974baa8b 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs @@ -131,6 +131,7 @@ fn make_signed_preferences( validator_index, fee_recipient: Address::ZERO, gas_limit: 30_000_000, + ..ProposerPreferences::default() }, signature: Signature::empty(), }) @@ -230,10 +231,11 @@ fn correct_proposer_bad_signature() { result, Err(ProposerPreferencesError::BadSignature) )); - assert!( - !ctx.preferences_cache - .get_seen_validator(&slot, actual_proposer) - ); + assert!(!ctx.preferences_cache.get_seen_validator( + &slot, + types::Hash256::ZERO, + actual_proposer + )); assert!(ctx.preferences_cache.get_preferences(&slot).is_none()); } diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 1d23990b80..549f15a13f 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -229,9 +229,6 @@ async fn prepare_payload_generic( // `apply_parent_execution_payload`. let cached_head = harness.chain.canonical_head.cached_head(); let unadvanced_empty_state = &cached_head.snapshot.beacon_state; - let parent_bid = unadvanced_empty_state - .latest_execution_payload_bid() - .unwrap(); let mut advanced_empty_state = unadvanced_empty_state.clone(); complete_state_advance(&mut advanced_empty_state, None, prepare_slot, &spec).unwrap(); @@ -239,7 +236,6 @@ async fn prepare_payload_generic( let mut unadvanced_full_state = unadvanced_empty_state.clone(); apply_parent_execution_payload( &mut unadvanced_full_state, - parent_bid, &envelope.message.execution_requests, &spec, ) @@ -248,7 +244,6 @@ async fn prepare_payload_generic( let mut advanced_full_state = advanced_empty_state.clone(); apply_parent_execution_payload( &mut advanced_full_state, - parent_bid, &envelope.message.execution_requests, &spec, ) diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index 76c6ba812d..c4e7f8f8d1 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -2131,6 +2131,7 @@ fn make_test_payload_envelope( execution_requests: ExecutionRequests::default(), builder_index: 0, beacon_block_root, + parent_beacon_block_root: Hash256::ZERO, }, signature: Signature::empty(), } diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 8548974054..78f5026689 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -23,14 +23,6 @@ use types::{ four_byte_option_impl!(four_byte_option_usize, usize); four_byte_option_impl!(four_byte_option_checkpoint, Checkpoint); -fn all_true_bitvector() -> BitVector { - let mut bv = BitVector::new(); - for i in 0..bv.len() { - let _ = bv.set(i, true); - } - bv -} - /// Defines an operation which may invalidate the `execution_status` of some nodes. #[derive(Clone, Debug)] pub enum InvalidationOperation { @@ -568,10 +560,8 @@ impl ProtoArray { ProtoNode::V29(v29) => { // Both parent and child are Gloas blocks. The parent is full if the // block hash in the parent node matches the parent block hash in the - // child bid and the parent block isn't the genesis block. - if v29.execution_payload_block_hash != ExecutionBlockHash::zero() - && execution_payload_parent_hash == v29.execution_payload_block_hash - { + // child bid. + if execution_payload_parent_hash == v29.execution_payload_block_hash { PayloadStatus::Full } else { PayloadStatus::Empty @@ -613,18 +603,8 @@ impl ProtoArray { full_payload_weight: 0, execution_payload_block_hash, execution_payload_parent_hash, - // Per spec `get_forkchoice_store`: the anchor block's PTC votes are - // initialized to all-True. - payload_timeliness_votes: if is_anchor { - all_true_bitvector() - } else { - BitVector::default() - }, - payload_data_availability_votes: if is_anchor { - all_true_bitvector() - } else { - BitVector::default() - }, + payload_timeliness_votes: BitVector::default(), + payload_data_availability_votes: BitVector::default(), payload_received: false, proposer_index, // Spec: `record_block_timeliness` + `get_forkchoice_store`. diff --git a/consensus/state_processing/src/envelope_processing.rs b/consensus/state_processing/src/envelope_processing.rs index 8ea96390e3..3da4d1e9d6 100644 --- a/consensus/state_processing/src/envelope_processing.rs +++ b/consensus/state_processing/src/envelope_processing.rs @@ -26,6 +26,12 @@ pub enum EnvelopeProcessingError { envelope_root: Hash256, block_header_root: Hash256, }, + /// Envelope's `parent_beacon_block_root` doesn't match the parent root of the latest + /// block header. + ParentBeaconBlockRootMismatch { + envelope: Hash256, + state: Hash256, + }, /// Envelope doesn't match latest beacon block slot SlotMismatch { envelope_slot: Slot, @@ -126,6 +132,13 @@ pub fn verify_execution_payload_envelope( block_header_root: latest_block_header_root, } ); + envelope_verify!( + envelope.parent_beacon_block_root == state.latest_block_header().parent_root, + EnvelopeProcessingError::ParentBeaconBlockRootMismatch { + envelope: envelope.parent_beacon_block_root, + state: state.latest_block_header().parent_root, + } + ); envelope_verify!( envelope.slot() == state.slot(), EnvelopeProcessingError::SlotMismatch { diff --git a/consensus/state_processing/src/genesis.rs b/consensus/state_processing/src/genesis.rs index 9dfbc87b48..c643ad56e3 100644 --- a/consensus/state_processing/src/genesis.rs +++ b/consensus/state_processing/src/genesis.rs @@ -175,13 +175,11 @@ pub fn initialize_beacon_state_from_eth1( bid.parent_block_hash = el_genesis_hash; bid.block_hash = ExecutionBlockHash::default(); - // Update latest_block_header to reflect the Gloas genesis block body which contains - // the EL genesis hash in the signed_execution_payload_bid. This is needed because - // BeaconState::new() created the header from BeaconBlock::empty() which has zero bid - // fields, but the spec requires the genesis block's bid to contain the EL block hash - // and the tree hash root of empty ExecutionRequests. - let block = genesis_block(&state, spec)?; - state.latest_block_header_mut().body_root = block.body_root(); + // Update the `latest_block_header.body_root` so that it matches the body of the + // Gloas genesis block, which embeds `state.latest_execution_payload_bid` in its + // `signed_execution_payload_bid` field (see `genesis_block`). + let genesis_body_root = genesis_block(&state, spec)?.body_root(); + state.latest_block_header_mut().body_root = genesis_body_root; } // Now that we have our validators, initialize the caches (including the committees) @@ -193,24 +191,23 @@ pub fn initialize_beacon_state_from_eth1( Ok(state) } -/// Create an unsigned genesis `BeaconBlock` whose body matches the genesis state. +/// Create an unsigned genesis `BeaconBlock`. /// -/// For Gloas, the block's `signed_execution_payload_bid` is populated from the state's -/// `latest_execution_payload_bid` so that the body root is consistent with -/// `state.latest_block_header.body_root`. +/// Per spec, the genesis block body is empty (all default fields) except for Gloas, +/// where `body.signed_execution_payload_bid.message` is initialised from +/// `state.latest_execution_payload_bid` so that the first post-genesis proposer can +/// build on the correct execution layer head. /// -/// The returned block has `state_root == Hash256::ZERO`; callers that need the real -/// state root should set it themselves. +/// `state.latest_block_header.body_root` is set from this same block's body, so the +/// two must stay in sync. pub fn genesis_block( - genesis_state: &BeaconState, + state: &BeaconState, spec: &ChainSpec, ) -> Result, BeaconStateError> { let mut block = BeaconBlock::empty(spec); - if let Ok(block) = block.as_gloas_mut() { - let state_bid = genesis_state.latest_execution_payload_bid()?; - let bid = &mut block.body.signed_execution_payload_bid.message; - bid.block_hash = state_bid.block_hash; - bid.execution_requests_root = state_bid.execution_requests_root; + if let BeaconBlock::Gloas(ref mut gloas_block) = block { + let bid = state.latest_execution_payload_bid()?.clone(); + gloas_block.body.signed_execution_payload_bid.message = bid; } Ok(block) } diff --git a/consensus/state_processing/src/per_block_processing.rs b/consensus/state_processing/src/per_block_processing.rs index 71ad394ee6..f13f2a339b 100644 --- a/consensus/state_processing/src/per_block_processing.rs +++ b/consensus/state_processing/src/per_block_processing.rs @@ -555,13 +555,10 @@ pub fn process_parent_execution_payload( state: &mut BeaconState, - parent_bid: &ExecutionPayloadBid, requests: &ExecutionRequests, spec: &ChainSpec, ) -> Result<(), BlockProcessingError> { + let parent_bid = state.latest_execution_payload_bid()?.clone(); let parent_slot = parent_bid.slot; let parent_epoch = parent_slot.epoch(E::slots_per_epoch()); diff --git a/consensus/state_processing/src/per_block_processing/withdrawals.rs b/consensus/state_processing/src/per_block_processing/withdrawals.rs index 3b14e904c4..8a09e35cdf 100644 --- a/consensus/state_processing/src/per_block_processing/withdrawals.rs +++ b/consensus/state_processing/src/per_block_processing/withdrawals.rs @@ -9,8 +9,8 @@ use safe_arith::{SafeArith, SafeArithIter}; use tree_hash::TreeHash; use types::{ AbstractExecPayload, BeaconState, BeaconStateError, ChainSpec, EthSpec, ExecPayload, - ExecutionBlockHash, ExpectedWithdrawals, ExpectedWithdrawalsCapella, - ExpectedWithdrawalsElectra, ExpectedWithdrawalsGloas, Validator, Withdrawal, Withdrawals, + ExpectedWithdrawals, ExpectedWithdrawalsCapella, ExpectedWithdrawalsElectra, + ExpectedWithdrawalsGloas, Validator, Withdrawal, Withdrawals, }; /// Compute the next batch of withdrawals which should be included in a block. @@ -495,10 +495,7 @@ pub mod gloas { spec: &ChainSpec, ) -> Result<(), BlockProcessingError> { // Return early if the parent block is empty. - let is_genesis_block = *state.latest_block_hash()? == ExecutionBlockHash::default(); - let is_parent_block_empty = - *state.latest_block_hash()? != state.latest_execution_payload_bid()?.block_hash; - if is_genesis_block || is_parent_block_empty { + if *state.latest_block_hash()? != state.latest_execution_payload_bid()?.block_hash { return Ok(()); } diff --git a/consensus/state_processing/src/per_epoch_processing/single_pass.rs b/consensus/state_processing/src/per_epoch_processing/single_pass.rs index 976607aa76..881e6bb16c 100644 --- a/consensus/state_processing/src/per_epoch_processing/single_pass.rs +++ b/consensus/state_processing/src/per_epoch_processing/single_pass.rs @@ -962,7 +962,11 @@ fn compute_exit_epoch_and_update_churn( spec.compute_activation_exit_epoch(state_ctxt.current_epoch)?, ); - let per_epoch_churn = get_activation_exit_churn_limit(state_ctxt, spec)?; + let per_epoch_churn = if state_ctxt.fork_name.gloas_enabled() { + get_balance_churn_limit(state_ctxt, spec)? + } else { + get_activation_exit_churn_limit(state_ctxt, spec)? + }; // New epoch for exits let mut exit_balance_to_consume = if *earliest_exit_epoch_state < earliest_exit_epoch { per_epoch_churn @@ -991,17 +995,27 @@ fn get_activation_exit_churn_limit( state_ctxt: &StateContext, spec: &ChainSpec, ) -> Result { + let max_limit = if state_ctxt.fork_name.gloas_enabled() { + spec.max_per_epoch_activation_churn_limit_gloas + } else { + spec.max_per_epoch_activation_exit_churn_limit + }; Ok(std::cmp::min( - spec.max_per_epoch_activation_exit_churn_limit, + max_limit, get_balance_churn_limit(state_ctxt, spec)?, )) } fn get_balance_churn_limit(state_ctxt: &StateContext, spec: &ChainSpec) -> Result { let total_active_balance = state_ctxt.total_active_balance; + let quotient = if state_ctxt.fork_name.gloas_enabled() { + spec.churn_limit_quotient_gloas + } else { + spec.churn_limit_quotient + }; let churn = std::cmp::max( spec.min_per_epoch_churn_limit_electra, - total_active_balance.safe_div(spec.churn_limit_quotient)?, + total_active_balance.safe_div(quotient)?, ); Ok(churn.safe_sub(churn.safe_rem(spec.effective_balance_increment)?)?) diff --git a/consensus/types/configs/mainnet.yaml b/consensus/types/configs/mainnet.yaml index ab85bd9e71..25bf872a7a 100644 --- a/consensus/types/configs/mainnet.yaml +++ b/consensus/types/configs/mainnet.yaml @@ -105,12 +105,8 @@ CONTRIBUTION_DUE_BPS_GLOAS: 5000 PAYLOAD_ATTESTATION_DUE_BPS: 7500 # Heze -# 7500 basis points, 75% of SLOT_DURATION_MS -VIEW_FREEZE_CUTOFF_BPS: 7500 # 6667 basis points, ~67% of SLOT_DURATION_MS -INCLUSION_LIST_SUBMISSION_DUE_BPS: 6667 -# 9167 basis points, ~92% of SLOT_DURATION_MS -PROPOSER_INCLUSION_LIST_CUTOFF_BPS: 9167 +INCLUSION_LIST_DUE_BPS: 6667 # Validator cycle # --------------------------------------------------------------- @@ -135,6 +131,14 @@ MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 128000000000 # 2**8 * 10**9 (= 256,000,000,000) Gwei MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 256000000000 +# Gloas +# 2**15 (= 32,768) +CHURN_LIMIT_QUOTIENT_GLOAS: 32768 +# 2**16 (= 65,536) +CONSOLIDATION_CHURN_LIMIT_QUOTIENT: 65536 +# 2**8 * 10**9 (= 256,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS: 256000000000 + # Fork choice # --------------------------------------------------------------- # 40% diff --git a/consensus/types/configs/minimal.yaml b/consensus/types/configs/minimal.yaml index 8c0d7254fe..7251efc762 100644 --- a/consensus/types/configs/minimal.yaml +++ b/consensus/types/configs/minimal.yaml @@ -101,12 +101,8 @@ CONTRIBUTION_DUE_BPS_GLOAS: 5000 PAYLOAD_ATTESTATION_DUE_BPS: 7500 # Heze -# 7500 basis points, 75% of SLOT_DURATION_MS -VIEW_FREEZE_CUTOFF_BPS: 7500 # 6667 basis points, ~67% of SLOT_DURATION_MS -INCLUSION_LIST_SUBMISSION_DUE_BPS: 6667 -# 9167 basis points, ~92% of SLOT_DURATION_MS -PROPOSER_INCLUSION_LIST_CUTOFF_BPS: 9167 +INCLUSION_LIST_DUE_BPS: 6667 # Validator cycle # --------------------------------------------------------------- @@ -131,6 +127,14 @@ MIN_PER_EPOCH_CHURN_LIMIT_ELECTRA: 64000000000 # [customized] 2**7 * 10**9 (= 128,000,000,000) Gwei MAX_PER_EPOCH_ACTIVATION_EXIT_CHURN_LIMIT: 128000000000 +# Gloas +# [customized] 2**4 (= 16) +CHURN_LIMIT_QUOTIENT_GLOAS: 16 +# [customized] 2**5 (= 32) +CONSOLIDATION_CHURN_LIMIT_QUOTIENT: 32 +# [customized] 2**7 * 10**9 (= 128,000,000,000) Gwei +MAX_PER_EPOCH_ACTIVATION_CHURN_LIMIT_GLOAS: 128000000000 + # Fork choice # --------------------------------------------------------------- # 40% diff --git a/consensus/types/presets/minimal/gloas.yaml b/consensus/types/presets/minimal/gloas.yaml index 7ae61ddf97..559c2d46df 100644 --- a/consensus/types/presets/minimal/gloas.yaml +++ b/consensus/types/presets/minimal/gloas.yaml @@ -2,8 +2,8 @@ # Misc # --------------------------------------------------------------- -# [customized] 2**1 (= 2) validators -PTC_SIZE: 2 +# [customized] 2**4 (= 16) validators +PTC_SIZE: 16 # Max operations per block # --------------------------------------------------------------- diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index 23b01415c8..dd6f52426a 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -394,15 +394,13 @@ impl> SignedBeaconBlock /// `block_hash` from the parent beacon block's bid. If the parent beacon state is available /// this can alternatively be fetched from `state.latest_payload_bid`. /// - /// This function returns `false` for all blocks prior to Gloas and for the zero - /// `parent_block_hash`. + /// This function returns `false` for all blocks prior to Gloas. pub fn is_parent_block_full(&self, parent_block_hash: ExecutionBlockHash) -> bool { let Ok(signed_payload_bid) = self.message().body().signed_execution_payload_bid() else { // Prior to Gloas. return false; }; - parent_block_hash != ExecutionBlockHash::zero() - && signed_payload_bid.message.parent_block_hash == parent_block_hash + signed_payload_bid.message.parent_block_hash == parent_block_hash } } diff --git a/consensus/types/src/builder/proposer_preferences.rs b/consensus/types/src/builder/proposer_preferences.rs index 46dffdf3b7..0d2ba760d4 100644 --- a/consensus/types/src/builder/proposer_preferences.rs +++ b/consensus/types/src/builder/proposer_preferences.rs @@ -1,5 +1,5 @@ use crate::test_utils::TestRandom; -use crate::{Address, ForkName, SignedRoot, Slot}; +use crate::{Address, ForkName, Hash256, SignedRoot, Slot}; use bls::Signature; use context_deserialize::context_deserialize; use educe::Educe; @@ -16,6 +16,7 @@ use tree_hash_derive::TreeHash; #[context_deserialize(ForkName)] // https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/p2p-interface.md#new-proposerpreferences pub struct ProposerPreferences { + pub checkpoint_root: Hash256, pub proposal_slot: Slot, pub validator_index: u64, pub fee_recipient: Address, diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index 516ca2288e..c54d032891 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -251,6 +251,9 @@ pub struct ChainSpec { pub builder_payment_threshold_numerator: u64, pub builder_payment_threshold_denominator: u64, pub min_builder_withdrawability_delay: Epoch, + pub churn_limit_quotient_gloas: u64, + pub consolidation_churn_limit_quotient: u64, + pub max_per_epoch_activation_churn_limit_gloas: u64, /* * Networking @@ -1268,6 +1271,14 @@ impl ChainSpec { builder_payment_threshold_numerator: 6, builder_payment_threshold_denominator: 10, min_builder_withdrawability_delay: Epoch::new(64), + churn_limit_quotient_gloas: option_wrapper(|| u64::checked_pow(2, 15)) + .expect("calculation does not overflow"), + consolidation_churn_limit_quotient: option_wrapper(|| u64::checked_pow(2, 16)) + .expect("calculation does not overflow"), + max_per_epoch_activation_churn_limit_gloas: option_wrapper(|| { + u64::checked_pow(2, 8)?.checked_mul(u64::checked_pow(10, 9)?) + }) + .expect("calculation does not overflow"), max_request_payloads: 128, /* @@ -1414,6 +1425,14 @@ impl ChainSpec { gloas_fork_version: [0x07, 0x00, 0x00, 0x01], gloas_fork_epoch: None, min_builder_withdrawability_delay: Epoch::new(2), + churn_limit_quotient_gloas: option_wrapper(|| u64::checked_pow(2, 4)) + .expect("calculation does not overflow"), + consolidation_churn_limit_quotient: option_wrapper(|| u64::checked_pow(2, 5)) + .expect("calculation does not overflow"), + max_per_epoch_activation_churn_limit_gloas: option_wrapper(|| { + u64::checked_pow(2, 7)?.checked_mul(u64::checked_pow(10, 9)?) + }) + .expect("calculation does not overflow"), /* * Derived time values (set by `compute_derived_values()`) @@ -1675,6 +1694,14 @@ impl ChainSpec { builder_payment_threshold_numerator: 6, builder_payment_threshold_denominator: 10, min_builder_withdrawability_delay: Epoch::new(64), + churn_limit_quotient_gloas: option_wrapper(|| u64::checked_pow(2, 15)) + .expect("calculation does not overflow"), + consolidation_churn_limit_quotient: option_wrapper(|| u64::checked_pow(2, 16)) + .expect("calculation does not overflow"), + max_per_epoch_activation_churn_limit_gloas: option_wrapper(|| { + u64::checked_pow(2, 8)?.checked_mul(u64::checked_pow(10, 9)?) + }) + .expect("calculation does not overflow"), max_request_payloads: 128, /* @@ -2125,6 +2152,16 @@ pub struct Config { #[serde(default = "default_min_builder_withdrawability_delay")] #[serde(with = "serde_utils::quoted_u64")] min_builder_withdrawability_delay: u64, + + #[serde(default = "default_churn_limit_quotient_gloas")] + #[serde(with = "serde_utils::quoted_u64")] + churn_limit_quotient_gloas: u64, + #[serde(default = "default_consolidation_churn_limit_quotient")] + #[serde(with = "serde_utils::quoted_u64")] + consolidation_churn_limit_quotient: u64, + #[serde(default = "default_max_per_epoch_activation_churn_limit_gloas")] + #[serde(with = "serde_utils::quoted_u64")] + max_per_epoch_activation_churn_limit_gloas: u64, } fn default_bellatrix_fork_version() -> [u8; 4] { @@ -2362,6 +2399,18 @@ const fn default_min_builder_withdrawability_delay() -> u64 { 64 } +const fn default_churn_limit_quotient_gloas() -> u64 { + 32_768 +} + +const fn default_consolidation_churn_limit_quotient() -> u64 { + 65_536 +} + +const fn default_max_per_epoch_activation_churn_limit_gloas() -> u64 { + 256_000_000_000 +} + fn max_blocks_by_root_request_common(max_request_blocks: u64) -> usize { let max_request_blocks = max_request_blocks as usize; RuntimeVariableList::::new( @@ -2613,6 +2662,11 @@ impl Config { contribution_due_bps: spec.contribution_due_bps, min_builder_withdrawability_delay: spec.min_builder_withdrawability_delay.as_u64(), + + churn_limit_quotient_gloas: spec.churn_limit_quotient_gloas, + consolidation_churn_limit_quotient: spec.consolidation_churn_limit_quotient, + max_per_epoch_activation_churn_limit_gloas: spec + .max_per_epoch_activation_churn_limit_gloas, } } @@ -2710,6 +2764,9 @@ impl Config { sync_message_due_bps, contribution_due_bps, min_builder_withdrawability_delay, + churn_limit_quotient_gloas, + consolidation_churn_limit_quotient, + max_per_epoch_activation_churn_limit_gloas, } = self; if preset_base != E::spec_name().to_string().as_str() { @@ -2817,6 +2874,10 @@ impl Config { min_builder_withdrawability_delay: Epoch::new(min_builder_withdrawability_delay), + churn_limit_quotient_gloas, + consolidation_churn_limit_quotient, + max_per_epoch_activation_churn_limit_gloas, + ..chain_spec.clone() }; Some(spec.compute_derived_values::()) @@ -3719,9 +3780,7 @@ mod yaml_tests { "CONTRIBUTION_DUE_BPS_GLOAS", "MAX_REQUEST_PAYLOADS", // Heze networking - "VIEW_FREEZE_CUTOFF_BPS", - "INCLUSION_LIST_SUBMISSION_DUE_BPS", - "PROPOSER_INCLUSION_LIST_CUTOFF_BPS", + "INCLUSION_LIST_DUE_BPS", "MAX_REQUEST_INCLUSION_LIST", "MAX_BYTES_PER_INCLUSION_LIST", ]; diff --git a/consensus/types/src/core/eth_spec.rs b/consensus/types/src/core/eth_spec.rs index 4159091f5d..5f296afb44 100644 --- a/consensus/types/src/core/eth_spec.rs +++ b/consensus/types/src/core/eth_spec.rs @@ -572,7 +572,7 @@ impl EthSpec for MinimalEthSpec { type NumberOfColumns = U128; type ProposerLookaheadSlots = U16; // Derived from (MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH type BuilderPendingPaymentsLimit = U16; // 2 * SLOTS_PER_EPOCH = 2 * 8 = 16 - type PTCSize = U2; + type PTCSize = U16; type PtcWindowLength = U24; // (2 + MIN_SEED_LOOKAHEAD) * SLOTS_PER_EPOCH type MaxBuildersPerWithdrawalsSweep = U16; diff --git a/consensus/types/src/execution/execution_payload_envelope.rs b/consensus/types/src/execution/execution_payload_envelope.rs index 028423d681..a6d123bd21 100644 --- a/consensus/types/src/execution/execution_payload_envelope.rs +++ b/consensus/types/src/execution/execution_payload_envelope.rs @@ -20,6 +20,7 @@ pub struct ExecutionPayloadEnvelope { #[serde(with = "serde_utils::quoted_u64")] pub builder_index: u64, pub beacon_block_root: Hash256, + pub parent_beacon_block_root: Hash256, } impl ExecutionPayloadEnvelope { @@ -30,6 +31,7 @@ impl ExecutionPayloadEnvelope { execution_requests: ExecutionRequests::default(), builder_index: 0, beacon_block_root: Hash256::zero(), + parent_beacon_block_root: Hash256::zero(), } } diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 7ed3121d6e..e821ca922b 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -2762,29 +2762,55 @@ impl BeaconState { /// Return the churn limit for the current epoch. pub fn get_balance_churn_limit(&self, spec: &ChainSpec) -> Result { let total_active_balance = self.get_total_active_balance()?; + let quotient = if self.fork_name_unchecked().gloas_enabled() { + spec.churn_limit_quotient_gloas + } else { + spec.churn_limit_quotient + }; let churn = std::cmp::max( spec.min_per_epoch_churn_limit_electra, - total_active_balance.safe_div(spec.churn_limit_quotient)?, + total_active_balance.safe_div(quotient)?, ); Ok(churn.safe_sub(churn.safe_rem(spec.effective_balance_increment)?)?) } /// Return the churn limit for the current epoch dedicated to activations and exits. + /// + /// From Gloas onwards this is the activation-only churn limit (EIP-8061); exits use + /// [`Self::get_exit_churn_limit`]. pub fn get_activation_exit_churn_limit( &self, spec: &ChainSpec, ) -> Result { + let max_limit = if self.fork_name_unchecked().gloas_enabled() { + spec.max_per_epoch_activation_churn_limit_gloas + } else { + spec.max_per_epoch_activation_exit_churn_limit + }; Ok(std::cmp::min( - spec.max_per_epoch_activation_exit_churn_limit, + max_limit, self.get_balance_churn_limit(spec)?, )) } + /// Return the Gloas (EIP-8061) exit churn limit for the current epoch. + /// + /// Unlike [`Self::get_activation_exit_churn_limit`], this is uncapped. + pub fn get_exit_churn_limit(&self, spec: &ChainSpec) -> Result { + self.get_balance_churn_limit(spec) + } + pub fn get_consolidation_churn_limit(&self, spec: &ChainSpec) -> Result { - self.get_balance_churn_limit(spec)? - .safe_sub(self.get_activation_exit_churn_limit(spec)?) - .map_err(Into::into) + if self.fork_name_unchecked().gloas_enabled() { + let total_active_balance = self.get_total_active_balance()?; + let churn = total_active_balance.safe_div(spec.consolidation_churn_limit_quotient)?; + Ok(churn.safe_sub(churn.safe_rem(spec.effective_balance_increment)?)?) + } else { + self.get_balance_churn_limit(spec)? + .safe_sub(self.get_activation_exit_churn_limit(spec)?) + .map_err(Into::into) + } } pub fn get_pending_balance_to_withdraw( @@ -2879,7 +2905,11 @@ impl BeaconState { self.compute_activation_exit_epoch(self.current_epoch(), spec)?, ); - let per_epoch_churn = self.get_activation_exit_churn_limit(spec)?; + let per_epoch_churn = if self.fork_name_unchecked().gloas_enabled() { + self.get_exit_churn_limit(spec)? + } else { + self.get_activation_exit_churn_limit(spec)? + }; // New epoch for exits let mut exit_balance_to_consume = if self.earliest_exit_epoch()? < earliest_exit_epoch { per_epoch_churn @@ -3103,7 +3133,19 @@ impl BeaconState { let total_active_balance = self.get_total_active_balance()?; let fork_name = self.fork_name_unchecked(); - if fork_name.electra_enabled() { + if fork_name.gloas_enabled() { + // [Modified in Gloas:EIP8061] + let exit_churn = self.get_exit_churn_limit(spec)?; + let activation_churn = self.get_activation_exit_churn_limit(spec)?; + let consolidation_churn = self.get_consolidation_churn_limit(spec)?; + compute_weak_subjectivity_period_gloas( + total_active_balance, + exit_churn, + activation_churn, + consolidation_churn, + spec, + ) + } else if fork_name.electra_enabled() { let balance_churn_limit = self.get_balance_churn_limit(spec)?; compute_weak_subjectivity_period_electra( total_active_balance, @@ -3601,6 +3643,30 @@ pub fn compute_weak_subjectivity_period_electra( Ok(ws_period) } +/// Spec: https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.6/specs/gloas/weak-subjectivity.md +pub fn compute_weak_subjectivity_period_gloas( + total_active_balance: u64, + exit_churn_limit: u64, + activation_churn_limit: u64, + consolidation_churn_limit: u64, + spec: &ChainSpec, +) -> Result { + // delta = 2 * exit_churn // 3 + activation_churn // 3 + consolidation_churn + let delta = exit_churn_limit + .safe_mul(2)? + .safe_div(3)? + .safe_add(activation_churn_limit.safe_div(3)?)? + .safe_add(consolidation_churn_limit)?; + let epochs_for_validator_set_churn = SAFETY_DECAY + .safe_mul(total_active_balance)? + .safe_div(delta.safe_mul(200)?)?; + let ws_period = spec + .min_validator_withdrawability_delay + .safe_add(epochs_for_validator_set_churn)?; + + Ok(ws_period) +} + #[cfg(test)] mod weak_subjectivity_tests { use crate::state::beacon_state::compute_weak_subjectivity_period_electra; diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index facc8208d9..63d1907b96 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.5 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.6 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) diff --git a/testing/ef_tests/check_all_files_accessed.py b/testing/ef_tests/check_all_files_accessed.py index 5a54e150db..53fb626e7e 100755 --- a/testing/ef_tests/check_all_files_accessed.py +++ b/testing/ef_tests/check_all_files_accessed.py @@ -55,6 +55,7 @@ excluded_paths = [ "tests/.*/.*/ssz_static/PartialDataColumn.*/.*", # TODO(gloas): Ignore Gloas light client stuff for now "tests/.*/gloas/ssz_static/LightClient.*/.*", + "tests/.*/gloas/light_client", # Execution payload header is irrelevant after Gloas, this type will probably be deleted. "tests/.*/gloas/ssz_static/ExecutionPayloadHeader/.*", # ForkChoiceNode is internal to fork choice and probably doesn't need SSZ tests. diff --git a/testing/ef_tests/download_test_vectors.sh b/testing/ef_tests/download_test_vectors.sh index f91b2d1c38..cb45aeb922 100755 --- a/testing/ef_tests/download_test_vectors.sh +++ b/testing/ef_tests/download_test_vectors.sh @@ -23,7 +23,7 @@ if [[ "$version" == "nightly" || "$version" =~ ^nightly-[0-9]+$ ]]; then if [[ "$version" == "nightly" ]]; then run_id=$(curl --fail -s -H "${auth_header}" \ - "${api}/repos/${repo}/actions/workflows/nightly-reftests.yml/runs?branch=master&status=success&per_page=1" | + "${api}/repos/${repo}/actions/workflows/tests.yml/runs?branch=master&status=success&per_page=1" | jq -r '.workflow_runs[0].id') else run_id="${version#nightly-}" diff --git a/testing/ef_tests/src/cases/epoch_processing.rs b/testing/ef_tests/src/cases/epoch_processing.rs index a032aa917f..ec243f05cc 100644 --- a/testing/ef_tests/src/cases/epoch_processing.rs +++ b/testing/ef_tests/src/cases/epoch_processing.rs @@ -58,6 +58,8 @@ pub struct Eth1DataReset; #[derive(Debug)] pub struct PendingBalanceDeposits; #[derive(Debug)] +pub struct PendingDepositsChurn; +#[derive(Debug)] pub struct PendingConsolidations; #[derive(Debug)] pub struct EffectiveBalanceUpdates; @@ -93,6 +95,7 @@ type_name!(RegistryUpdates, "registry_updates"); type_name!(Slashings, "slashings"); type_name!(Eth1DataReset, "eth1_data_reset"); type_name!(PendingBalanceDeposits, "pending_deposits"); +type_name!(PendingDepositsChurn, "pending_deposits_churn"); type_name!(PendingConsolidations, "pending_consolidations"); type_name!(EffectiveBalanceUpdates, "effective_balance_updates"); type_name!(SlashingsReset, "slashings_reset"); @@ -191,6 +194,20 @@ impl EpochTransition for PendingBalanceDeposits { } } +impl EpochTransition for PendingDepositsChurn { + fn run(state: &mut BeaconState, spec: &ChainSpec) -> Result<(), EpochProcessingError> { + process_epoch_single_pass( + state, + spec, + SinglePassConfig { + pending_deposits: true, + ..SinglePassConfig::disable_all() + }, + ) + .map(|_| ()) + } +} + impl EpochTransition for PendingConsolidations { fn run(state: &mut BeaconState, spec: &ChainSpec) -> Result<(), EpochProcessingError> { initialize_epoch_cache(state, spec)?; @@ -387,7 +404,9 @@ impl> Case for EpochProcessing { } if !fork_name.gloas_enabled() - && (T::name() == "builder_pending_payments" || T::name() == "ptc_window") + && (T::name() == "builder_pending_payments" + || T::name() == "ptc_window" + || T::name() == "pending_deposits_churn") { return false; } diff --git a/testing/ef_tests/src/cases/operations.rs b/testing/ef_tests/src/cases/operations.rs index f90b6f2a6e..f5c999920d 100644 --- a/testing/ef_tests/src/cases/operations.rs +++ b/testing/ef_tests/src/cases/operations.rs @@ -53,6 +53,15 @@ pub struct WithdrawalsPayload { payload: Option>, } +/// Newtype for testing voluntary exit churn (Gloas+). +/// +/// The test case applies the same `process_voluntary_exit` operation as the regular +/// `voluntary_exit` test, but under the `voluntary_exit_churn` handler directory. +#[derive(Debug, Clone)] +pub struct VoluntaryExitChurn { + exit: SignedVoluntaryExit, +} + /// Newtype for testing execution payload bids. #[derive(Debug, Clone, Deserialize)] pub struct ExecutionPayloadBidBlock { @@ -265,6 +274,40 @@ impl Operation for SignedVoluntaryExit { } } +impl Operation for VoluntaryExitChurn { + type Error = BlockProcessingError; + + fn handler_name() -> String { + "voluntary_exit_churn".into() + } + + fn filename() -> String { + "voluntary_exit.ssz_snappy".into() + } + + fn is_enabled_for_fork(fork_name: ForkName) -> bool { + fork_name.gloas_enabled() + } + + fn decode(path: &Path, _fork_name: ForkName, _spec: &ChainSpec) -> Result { + ssz_decode_file(path).map(|exit| VoluntaryExitChurn { exit }) + } + + fn apply_to( + &self, + state: &mut BeaconState, + spec: &ChainSpec, + _: &Operations, + ) -> Result<(), BlockProcessingError> { + process_exits( + state, + std::slice::from_ref(&self.exit), + VerifySignatures::True, + spec, + ) + } +} + impl Operation for BeaconBlock { type Error = BlockProcessingError; diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index 96798c910c..e380f51c0a 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -340,6 +340,10 @@ impl SszStaticHandler { pub fn pre_electra() -> Self { Self::for_forks(ForkName::list_all()[0..5].to_vec()) } + + pub fn pre_capella() -> Self { + Self::for_forks(ForkName::list_all()[0..3].to_vec()) + } } /// Handler for SSZ types that implement `CachedTreeHash`. diff --git a/testing/ef_tests/src/lib.rs b/testing/ef_tests/src/lib.rs index 0ffedc7eb8..bead5825ed 100644 --- a/testing/ef_tests/src/lib.rs +++ b/testing/ef_tests/src/lib.rs @@ -3,9 +3,10 @@ pub use cases::{ BuilderPendingPayments, Case, EffectiveBalanceUpdates, Eth1DataReset, ExecutionPayloadBidBlock, FeatureName, HistoricalRootsUpdate, HistoricalSummariesUpdate, InactivityUpdates, JustificationAndFinalization, ParentExecutionPayloadBlock, ParticipationFlagUpdates, - ParticipationRecordUpdates, PendingBalanceDeposits, PendingConsolidations, ProposerLookahead, - PtcWindow, RandaoMixesReset, RegistryUpdates, RewardsAndPenalties, Slashings, SlashingsReset, - SyncCommitteeUpdates, WithdrawalsPayload, + ParticipationRecordUpdates, PendingBalanceDeposits, PendingConsolidations, + PendingDepositsChurn, ProposerLookahead, PtcWindow, RandaoMixesReset, RegistryUpdates, + RewardsAndPenalties, Slashings, SlashingsReset, SyncCommitteeUpdates, VoluntaryExitChurn, + WithdrawalsPayload, }; pub use decode::log_file_access; pub use error::Error; diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index 79a02d7e80..ca383efdb0 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -142,6 +142,12 @@ fn operations_bls_to_execution_change() { OperationsHandler::::default().run(); } +#[test] +fn operations_voluntary_exit_churn() { + OperationsHandler::::default().run(); + OperationsHandler::::default().run(); +} + #[test] fn sanity_blocks() { SanityBlocksHandler::::default().run(); @@ -285,8 +291,19 @@ mod ssz_static { ssz_static_test!(eth1_data, Eth1Data); ssz_static_test!(fork, Fork); ssz_static_test!(fork_data, ForkData); - ssz_static_test!(historical_batch, HistoricalBatch<_>); - ssz_static_test!(pending_attestation, PendingAttestation<_>); + // `HistoricalBatch` was removed in Capella, so test vectors only exist for Base, + // Altair and Bellatrix. + #[test] + fn historical_batch() { + SszStaticHandler::, MinimalEthSpec>::pre_capella().run(); + SszStaticHandler::, MainnetEthSpec>::pre_capella().run(); + } + // `PendingAttestation` was removed in Altair, so test vectors only exist for Base. + #[test] + fn pending_attestation() { + SszStaticHandler::, MinimalEthSpec>::base_only().run(); + SszStaticHandler::, MainnetEthSpec>::base_only().run(); + } ssz_static_test!(proposer_slashing, ProposerSlashing); ssz_static_test!( signed_beacon_block, @@ -899,6 +916,12 @@ fn epoch_processing_pending_balance_deposits() { EpochProcessingHandler::::default().run(); } +#[test] +fn epoch_processing_pending_deposits_churn() { + EpochProcessingHandler::::default().run(); + EpochProcessingHandler::::default().run(); +} + #[test] fn epoch_processing_pending_consolidations() { EpochProcessingHandler::::default().run(); From 0e427ab77b74f62f87d50122b259b3893cf31755 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:02:12 +0200 Subject: [PATCH 151/189] Add Gloas bid inclusion (#9221) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- .../src/block_production/gloas.rs | 257 ++++++++++++++++-- beacon_node/beacon_chain/src/test_utils.rs | 1 + .../beacon_chain/tests/prepare_payload.rs | 1 + beacon_node/execution_layer/src/lib.rs | 2 + 4 files changed, 240 insertions(+), 21 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index a02963c358..6510c20ba7 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -78,6 +78,16 @@ pub struct ExecutionPayloadData { pub blobs_and_proofs: (types::BlobsList, types::KzgProofs), } +/// The result of a local payload build, used to decide whether to include a builder bid +/// from the gossip cache or fall back to self-build. +pub struct LocalBuildResult { + pub payload_data: ExecutionPayloadData, + /// EL block value (in wei) of the locally-built payload. + pub payload_value: types::Uint256, + /// `true` if the EL signaled `engine_getPayload`'s `shouldOverrideBuilder` flag. + pub should_override_builder: bool, +} + impl BeaconChain { pub async fn produce_block_with_verification_gloas( self: &Arc, @@ -85,7 +95,7 @@ impl BeaconChain { slot: Slot, graffiti_settings: GraffitiSettings, verification: ProduceBlockVerification, - _builder_boost_factor: Option, + builder_boost_factor: Option, ) -> Result, BlockProductionError> { metrics::inc_counter(&metrics::BLOCK_PRODUCTION_REQUESTS); let _complete_timer = metrics::start_timer(&metrics::BLOCK_PRODUCTION_TIMES); @@ -121,11 +131,11 @@ impl BeaconChain { randao_reveal, graffiti_settings, verification, + builder_boost_factor, ) .await } - // TODO(gloas) need to implement builder boost factor logic #[instrument(level = "debug", skip_all)] #[allow(clippy::too_many_arguments)] pub async fn produce_block_on_state_gloas( @@ -138,6 +148,7 @@ impl BeaconChain { randao_reveal: Signature, graffiti_settings: GraffitiSettings, verification: ProduceBlockVerification, + builder_boost_factor: Option, ) -> Result, BlockProductionError> { // Extract the parent's execution requests from the envelope (if parent was full). let parent_execution_requests = if parent_payload_status == PayloadStatus::Full { @@ -179,10 +190,10 @@ impl BeaconChain { // Part 2/3 (async) // - // Produce the execution payload bid. - // TODO(gloas) this is strictly for building local bids - // We'll need to build out trustless/trusted bid paths. - let (execution_payload_bid, state, payload_data) = self + // Produce a local execution payload bid, then select between it and any cached + // gossip-verified builder bid using `builder_boost_factor`. + // TODO(gloas) build out trustless/trusted bid paths. + let (local_signed_bid, state, local_build) = self .clone() .produce_execution_payload_bid( state, @@ -194,6 +205,9 @@ impl BeaconChain { ) .await?; + let (execution_payload_bid, payload_data) = + self.select_payload_bid(local_signed_bid, local_build, builder_boost_factor); + // Part 3/3 (blocking) // // Complete the block with the execution payload bid. @@ -679,16 +693,13 @@ impl BeaconChain { Ok((block, state, consensus_block_value)) } - // TODO(gloas) introduce `ProposerPreferences` so we can build out trustless - // bid building. Right now this only works for local building. - /// Produce an `ExecutionPayloadBid` for some `slot` upon the given `state`. + /// Produce a self-build `ExecutionPayloadBid` for some `slot` upon the given `state`. /// This function assumes we've already advanced `state`. /// - /// Returns the signed bid, the state, and optionally the payload data needed to construct - /// the `ExecutionPayloadEnvelope` after the beacon block is created. - /// - /// For local building, payload data is always returned (`Some`). - /// For trustless building, the builder provides the envelope separately, so `None` is returned. + /// Returns the signed bid, the state, and a `LocalBuildResult` carrying the payload + /// data needed to construct the `ExecutionPayloadEnvelope` after the beacon block is + /// created, plus the EL block value and `should_override_builder` flag used by the + /// caller to compare against any cached p2p builder bid. #[allow(clippy::type_complexity)] #[instrument(level = "debug", skip_all)] pub async fn produce_execution_payload_bid( @@ -703,7 +714,7 @@ impl BeaconChain { ( SignedExecutionPayloadBid, BeaconState, - Option>, + LocalBuildResult, ), BlockProductionError, > { @@ -775,10 +786,11 @@ impl BeaconChain { let BlockProposalContentsGloas { payload, - payload_value: _, + payload_value, execution_requests, blob_kzg_commitments, blobs_and_proofs, + should_override_builder, } = block_proposal_contents; // TODO(gloas) since we are defaulting to local building, execution payment is 0 @@ -807,19 +819,115 @@ impl BeaconChain { blobs_and_proofs, }; - // TODO(gloas) this is only local building - // we'll need to implement builder signature for the trustless path Ok(( SignedExecutionPayloadBid { message: bid, signature: Signature::infinity().map_err(BlockProductionError::BlsError)?, }, state, - // Local building always returns payload data. - // Trustless building would return None here. - Some(payload_data), + LocalBuildResult { + payload_data, + payload_value, + should_override_builder, + }, )) } + + /// Look up the highest gossip-verified bid for the `(slot, parent_block_hash, + /// parent_block_root)` of the local bid, then choose the winner. + fn select_payload_bid( + &self, + local_signed_bid: SignedExecutionPayloadBid, + local_build: LocalBuildResult, + builder_boost_factor: Option, + ) -> ( + SignedExecutionPayloadBid, + Option>, + ) { + let cached_bid = self.gossip_verified_payload_bid_cache.get_highest_bid( + local_signed_bid.message.slot, + local_signed_bid.message.parent_block_hash, + local_signed_bid.message.parent_block_root, + ); + select_payload_bid_pure( + local_signed_bid, + local_build, + cached_bid, + builder_boost_factor, + ) + } +} + +/// Pure local-vs-cached selection logic, factored out for unit testing. +/// +/// Selection rule (mirrors the pre-Gloas builder/local race in `execution_layer`): +/// - `boosted_bid = (cached_bid.value / 100) * builder_boost_factor` (raw value when `None`) +/// - if `local_value_wei >= boosted_bid_wei` → keep local +/// - if the EL signaled `should_override_builder` → keep local +/// - otherwise → use the cached builder bid and drop local payload data +/// (the builder is responsible for revealing the envelope). +/// +/// `cached_bid.value` is in gwei (`u64`); `payload_value` is in wei (`Uint256`); compared in wei. +pub(crate) fn select_payload_bid_pure( + local_signed_bid: SignedExecutionPayloadBid, + local_build: LocalBuildResult, + cached_bid: Option>>, + builder_boost_factor: Option, +) -> ( + SignedExecutionPayloadBid, + Option>, +) { + let LocalBuildResult { + payload_data, + payload_value, + should_override_builder, + } = local_build; + + let Some(cached_bid) = cached_bid else { + return (local_signed_bid, Some(payload_data)); + }; + + let slot = local_signed_bid.message.slot; + + if should_override_builder { + debug!( + %slot, + cached_bid_value = cached_bid.message.value, + "Using local payload because EL signaled shouldOverrideBuilder" + ); + return (local_signed_bid, Some(payload_data)); + } + + // Convert bid value (gwei) to wei for comparison with `payload_value` (wei). + let bid_value_wei = types::Uint256::from(cached_bid.message.value) + .saturating_mul(types::Uint256::from(1_000_000_000u64)); + let boosted_bid_wei = match builder_boost_factor { + Some(factor) => { + (bid_value_wei / types::Uint256::from(100)).saturating_mul(types::Uint256::from(factor)) + } + None => bid_value_wei, + }; + + if payload_value >= boosted_bid_wei { + debug!( + %slot, + %payload_value, + cached_bid_value_gwei = cached_bid.message.value, + ?builder_boost_factor, + "Local payload is more profitable than cached builder bid" + ); + (local_signed_bid, Some(payload_data)) + } else { + debug!( + %slot, + %payload_value, + cached_bid_value_gwei = cached_bid.message.value, + cached_bid_builder_index = cached_bid.message.builder_index, + ?builder_boost_factor, + "Including cached builder bid" + ); + ((*cached_bid).clone(), None) + } } /// Gets an execution payload for inclusion in a block. @@ -1151,4 +1259,111 @@ mod tests { assert_eq!(exits.len(), 2); } + + // ---- select_payload_bid_pure ---- + + const REMOTE_BUILDER: BuilderIndex = 999; + + fn gwei(n: u64) -> types::Uint256 { + types::Uint256::from(n).saturating_mul(types::Uint256::from(1_000_000_000u64)) + } + + fn local_bid() -> SignedExecutionPayloadBid { + SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + builder_index: BUILDER_INDEX_SELF_BUILD, + ..Default::default() + }, + signature: Signature::empty(), + } + } + + fn cached_bid(value_gwei: u64) -> Arc> { + Arc::new(SignedExecutionPayloadBid { + message: ExecutionPayloadBid { + builder_index: REMOTE_BUILDER, + value: value_gwei, + ..Default::default() + }, + signature: Signature::empty(), + }) + } + + fn local_build(payload_gwei: u64, should_override_builder: bool) -> LocalBuildResult { + LocalBuildResult { + payload_data: ExecutionPayloadData { + payload: types::ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequests::default(), + builder_index: BUILDER_INDEX_SELF_BUILD, + slot: Slot::new(0), + blobs_and_proofs: (VariableList::empty(), VariableList::empty()), + }, + payload_value: gwei(payload_gwei), + should_override_builder, + } + } + + const LOCAL: BuilderIndex = BUILDER_INDEX_SELF_BUILD; + const REMOTE: BuilderIndex = REMOTE_BUILDER; + + /// Run `select_payload_bid_pure` and return `(winning_builder_index, has_payload_data)`. + /// + /// Args (positional, mirror `select_payload_bid_pure`): + /// - `local_payload_gwei`: local payload value, in gwei. + /// - `should_override`: EL's `shouldOverrideBuilder` flag. + /// - `cached_gwei`: `Some(g)` ⇒ seed the cache with a bid of `g` gwei. + /// - `boost`: `None` = neutral, `Some(0)` = always local, `Some(>100)` = boost bid. + fn pick( + local_payload_gwei: u64, + should_override: bool, + cached_gwei: Option, + boost: Option, + ) -> (BuilderIndex, bool) { + let build = local_build(local_payload_gwei, should_override); + let cache = cached_gwei.map(cached_bid); + let (out, data) = select_payload_bid_pure::(local_bid(), build, cache, boost); + (out.message.builder_index, data.is_some()) + } + + #[test] + fn select_empty_cache_keeps_local() { + assert_eq!(pick(0, false, None, Some(u64::MAX)), (LOCAL, true)); + } + + #[test] + fn select_el_override_beats_any_cached_bid() { + // `shouldOverrideBuilder` short-circuits regardless of cache or boost. + assert_eq!(pick(0, true, Some(u64::MAX), Some(u64::MAX)), (LOCAL, true)); + } + + #[test] + fn select_boost_zero_always_keeps_local() { + // boost=0 deflates the bid to 0 ⇒ local always wins. + assert_eq!(pick(0, false, Some(u64::MAX), Some(0)), (LOCAL, true)); + } + + #[test] + fn select_neutral_boost_picks_higher_bid() { + // 5 gwei bid > 1 gwei local, neutral compare ⇒ bid. + assert_eq!(pick(1, false, Some(5), None), (REMOTE, false)); + } + + #[test] + fn select_local_strictly_higher_keeps_local() { + assert_eq!(pick(10, false, Some(5), None), (LOCAL, true)); + } + + #[test] + fn select_tie_goes_to_local() { + // `>=` ⇒ local wins ties. + assert_eq!(pick(5, false, Some(5), None), (LOCAL, true)); + } + + #[test] + fn select_boost_factor_amplifies_bid() { + // 5 gwei local vs 3 gwei bid: raw ⇒ local. + assert_eq!(pick(5, false, Some(3), None), (LOCAL, true)); + // boost=200 ⇒ bid scaled to 6 gwei ⇒ bid wins. + assert_eq!(pick(5, false, Some(3), Some(200)), (REMOTE, false)); + } } diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index f67b5015c5..f61a7abbe6 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1186,6 +1186,7 @@ where randao_reveal, graffiti_settings, ProduceBlockVerification::VerifyRandao, + None, ) .await .unwrap(); diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 549f15a13f..47dd1ef517 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -632,6 +632,7 @@ async fn gloas_block_production_caches_blobs_for_column_publishing() { randao_reveal, graffiti_settings, ProduceBlockVerification::VerifyRandao, + None, ) .await .unwrap(); diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index 4146543fd5..b2dabb7c01 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -205,6 +205,7 @@ pub struct BlockProposalContentsGloas { pub blob_kzg_commitments: KzgCommitments, pub blobs_and_proofs: (BlobsList, KzgProofs), pub execution_requests: ExecutionRequests, + pub should_override_builder: bool, } impl From> for BlockProposalContentsGloas { @@ -215,6 +216,7 @@ impl From> for BlockProposalContentsGloas blob_kzg_commitments: response.blobs_bundle.commitments, blobs_and_proofs: (response.blobs_bundle.blobs, response.blobs_bundle.proofs), execution_requests: response.requests, + should_override_builder: response.should_override_builder, } } } From f406e9c3fbf6f4abdd65a7d1501e2e892c96d2c9 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Wed, 29 Apr 2026 22:19:44 +1000 Subject: [PATCH 152/189] Update proposer boost calculation (#9215) Closes: - https://github.com/sigp/lighthouse/issues/8689 - Calculate the proposer index on the canonical chain (from canonical head) at `slot` and plumb it through to fork choice so it can be used to determine whether or not to apply the proposer boost. We use the proposer cache to handle state advances and avoid duplicate work. - Update our FC tests to use `block.message().proposer_index()` (always pass), we are not attempting to test this feature in those tests. The EF tests use the correct canonical proposer idnex via `on_block`, except for invalid blocks which just auto-pass this check (these blocks get rejected by other checks in `on_block` anyway). Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/beacon_chain.rs | 46 ++++++++++++++++++- .../tests/payload_invalidation.rs | 1 + beacon_node/http_api/tests/tests.rs | 36 ++++++++------- consensus/fork_choice/src/fork_choice.rs | 17 ++++--- consensus/fork_choice/tests/tests.rs | 2 + testing/ef_tests/src/cases/fork_choice.rs | 10 +--- 6 files changed, 79 insertions(+), 33 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index ccb12a353d..f618cf6321 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -4175,7 +4175,14 @@ impl BeaconChain { }; // Read the cached head prior to taking the fork choice lock to avoid potential deadlocks. - let old_head_slot = self.canonical_head.cached_head().head_slot(); + let cached_head = self.canonical_head.cached_head(); + let old_head_slot = cached_head.head_slot(); + + // Compute the expected proposer for `current_slot` on the canonical chain. This is used by + // `on_block` to gate proposer boost on the block's proposer matching the canonical proposer + // (per spec `update_proposer_boost_root` added in v1.7.0-alpha.5). + let canonical_head_proposer_index = + self.canonical_head_proposer_index(current_slot, &cached_head)?; // Take an upgradable read lock on fork choice so we can check if this block has already // been imported. We don't want to repeat work importing a block that is already imported. @@ -4208,6 +4215,7 @@ impl BeaconChain { block_delay, &state, payload_verification_status, + canonical_head_proposer_index, &self.spec, ) .map_err(|e| BlockError::BeaconChainError(Box::new(e.into())))?; @@ -4950,6 +4958,42 @@ impl BeaconChain { })) } + /// Compute the expected beacon proposer for `slot` on the canonical chain extending `cached_head`. + /// + /// Uses the beacon proposer cache to avoid recomputing the shuffling on every block import. + /// + /// This is used by `update_proposer_boost_root` to gate proposer boost on the block's proposer + /// matching the canonical proposer, per consensus-specs v1.7.0-alpha.5. + /// + /// This function should never error unless there is some corruption of the head state. If a + /// state advance is needed, it will be handled by the proposer cache. + pub fn canonical_head_proposer_index( + &self, + slot: Slot, + cached_head: &CachedHead, + ) -> Result { + let proposal_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); + let head_block_root = cached_head.head_block_root(); + let head_state = &cached_head.snapshot.beacon_state; + + let shuffling_decision_root = head_state.proposer_shuffling_decision_root_at_epoch( + proposal_epoch, + head_block_root, + &self.spec, + )?; + + self.with_proposer_cache::<_, Error>( + shuffling_decision_root, + proposal_epoch, + |proposers| { + proposers + .get_slot::(slot) + .map(|p| p.index as u64) + }, + || Ok((cached_head.head_state_root(), head_state.clone())), + ) + } + pub fn get_expected_withdrawals( &self, forkchoice_update_params: &ForkchoiceUpdateParameters, diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index 38d4f4c47e..be85fc2245 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1093,6 +1093,7 @@ async fn invalid_parent() { Duration::from_secs(0), &state, PayloadVerificationStatus::Optimistic, + block.message().proposer_index(), &rig.harness.chain.spec, ), Err(ForkChoiceError::ProtoArrayStringError(message)) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index b8326f4495..56835da459 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -3450,17 +3450,20 @@ impl ApiTester { .unwrap() .unwrap_or(self.chain.head_beacon_block_root()); - // Presently, the beacon chain harness never runs the code that primes the proposer - // cache. If this changes in the future then we'll need some smarter logic here, but - // this is succinct and effective for the time being. - assert!( - self.chain - .beacon_proposer_cache - .lock() - .get_epoch::(dependent_root, epoch) - .is_none(), - "the proposer cache should miss initially" - ); + // Block import primes the proposer cache for each epoch it runs through (to gate + // proposer boost), so epochs `<= current_epoch` are already cached. The only epoch + // for which we can observe the endpoint's own caching behaviour is + // `current_epoch + 1`, which no block import has touched yet. + if epoch == current_epoch + 1 { + assert!( + self.chain + .beacon_proposer_cache + .lock() + .get_epoch::(dependent_root, epoch) + .is_none(), + "the proposer cache should miss initially for the next epoch" + ); + } let result = self .client @@ -3468,8 +3471,9 @@ impl ApiTester { .await .unwrap(); - // Check that current-epoch requests prime the proposer cache, whilst non-current - // requests don't. + // A current-epoch request should leave the cache primed (block import already did so, + // but this is still a useful end-to-end check). A request for `current_epoch + 1` + // should not prime the cache. if epoch == current_epoch { assert!( self.chain @@ -3477,16 +3481,16 @@ impl ApiTester { .lock() .get_epoch::(dependent_root, epoch) .is_some(), - "a current-epoch request should prime the proposer cache" + "the proposer cache should be primed for the current epoch" ); - } else { + } else if epoch == current_epoch + 1 { assert!( self.chain .beacon_proposer_cache .lock() .get_epoch::(dependent_root, epoch) .is_none(), - "a non-current-epoch request should not prime the proposer cache" + "a request for the next epoch should not prime the proposer cache" ); } diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index a9e62dbe94..477d1fa3b4 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -756,6 +756,7 @@ where block_delay: Duration, state: &BeaconState, payload_verification_status: PayloadVerificationStatus, + canonical_head_proposer_index: u64, spec: &ChainSpec, ) -> Result<(), Error> { let _timer = metrics::start_timer(&metrics::FORK_CHOICE_ON_BLOCK_TIMES); @@ -820,16 +821,18 @@ where let attestation_threshold = spec.get_attestation_due::(block.slot()); - // Add proposer score boost if the block is timely. - // TODO(gloas): the spec's `update_proposer_boost_root` additionally checks that - // `block.proposer_index == get_beacon_proposer_index(head_state)` — i.e. that - // the block's proposer matches the expected proposer on the canonical chain. - // This requires calling `get_head` and advancing the head state to the current - // slot, which is expensive. Implement once we have a cached proposer index. + // Add proposer score boost if the block is the first timely block for this slot and its + // proposer matches the expected proposer on the canonical chain (per spec + // `update_proposer_boost_root`, introduced in v1.7.0-alpha.5). let is_before_attesting_interval = block_delay < attestation_threshold; let is_first_block = self.fc_store.proposer_boost_root().is_zero(); - if current_slot == block.slot() && is_before_attesting_interval && is_first_block { + let is_canonical_proposer = block.proposer_index() == canonical_head_proposer_index; + if current_slot == block.slot() + && is_before_attesting_interval + && is_first_block + && is_canonical_proposer + { self.fc_store.set_proposer_boost_root(block_root); } diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index d6f937c0ca..353893026b 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -316,6 +316,7 @@ impl ForkChoiceTest { Duration::from_secs(0), &state, PayloadVerificationStatus::Verified, + block.message().proposer_index(), &self.harness.chain.spec, ) .unwrap(); @@ -359,6 +360,7 @@ impl ForkChoiceTest { Duration::from_secs(0), &state, PayloadVerificationStatus::Verified, + block.message().proposer_index(), &self.harness.chain.spec, ) .expect_err("on_block did not return an error"); diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 2af205ee47..8b0b74d256 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -335,15 +335,6 @@ impl Case for ForkChoiceTest { } fn result(&self, _case_index: usize, fork_name: ForkName) -> Result<(), Error> { - // TODO(gloas): We have not implemented this change to fork choice/proposer boost yet. - // https://github.com/sigp/lighthouse/issues/8689 - if self.description == "voting_source_beyond_two_epoch" - || self.description == "justified_update_not_realized_finality" - || self.description == "justified_update_always_if_better" - { - return Err(Error::SkippedKnownFailure); - } - let tester = Tester::new(self, testing_spec::(fork_name))?; for step in &self.steps { @@ -791,6 +782,7 @@ impl Tester { block_delay, &state, PayloadVerificationStatus::Irrelevant, + block.message().proposer_index(), &self.harness.chain.spec, ); From 04b25898072de2271904b950285cf2fe3f9fb494 Mon Sep 17 00:00:00 2001 From: Jimmy Chen Date: Wed, 29 Apr 2026 17:43:30 +0200 Subject: [PATCH 153/189] Import execution payload envelope locally during HTTP API publication (#9226) Fixes a bug where a proposer votes payload missing on its own block. The payload is published to the network but never imported locally. This PR adds gossip verification and import when a payload is sent to the http API Co-Authored-By: Jimmy Chen --- .../src/beacon/execution_payload_envelope.rs | 58 ++++++++---- beacon_node/http_api/tests/tests.rs | 88 +++++++++++++++++++ 2 files changed, 131 insertions(+), 15 deletions(-) diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs index 06a5915c08..65e1a83840 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs @@ -7,7 +7,7 @@ use crate::version::{ execution_optimistic_finalized_beacon_response, }; use beacon_chain::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; -use beacon_chain::{BeaconChain, BeaconChainTypes}; +use beacon_chain::{BeaconChain, BeaconChainTypes, NotifyExecutionLayer}; use bytes::Bytes; use eth2::types as api_types; use eth2::{CONTENT_TYPE_HEADER, SSZ_CONTENT_TYPE_HEADER}; @@ -18,7 +18,7 @@ use std::future::Future; use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; use tracing::{debug, error, info, warn}; -use types::{EthSpec, SignedExecutionPayloadEnvelope}; +use types::{BlockImportSource, EthSpec, SignedExecutionPayloadEnvelope}; use warp::{ Filter, Rejection, Reply, hyper::{Body, Response}, @@ -99,14 +99,12 @@ pub async fn publish_execution_payload_envelope( let slot = envelope.slot(); let beacon_block_root = envelope.message.beacon_block_root; - // TODO(gloas): Replace this check once we have gossip validation. if !chain.spec.is_gloas_scheduled() { return Err(warp_utils::reject::custom_bad_request( "Execution payload envelopes are not supported before the Gloas fork".into(), )); } - // TODO(gloas): We should probably add validation here i.e. BroadcastValidation::Gossip info!( %slot, %beacon_block_root, @@ -118,7 +116,7 @@ pub async fn publish_execution_payload_envelope( // Spawn the column-build task (CPU-bound KZG cell-and-proof computation) before // publishing the envelope so it runs in parallel with envelope gossip, narrowing - // the window in which peers see envelope-without-columns. If envelope publication + // the window in which peers see envelope-without-columns. If envelope import // fails below, dropping this future drops the spawned `JoinHandle` (the running // closure on the blocking pool finishes and is then discarded — no work cancellation). let column_build_future = match blobs_and_proofs { @@ -131,17 +129,47 @@ pub async fn publish_execution_payload_envelope( _ => None, }; - // Publish the envelope to the network. - crate::utils::publish_pubsub_message( - network_tx, - PubsubMessage::ExecutionPayload(Box::new(envelope)), - ) - .map_err(|_| { - warn!(%slot, "Failed to publish execution payload envelope to network"); - warp_utils::reject::custom_server_error( - "Unable to publish execution payload envelope to network".into(), + // Gossip-verify the envelope before publishing. + let gossip_verified = chain + .verify_envelope_for_gossip(Arc::new(envelope)) + .await + .map_err(|e| { + warn!(%slot, error = ?e, "Execution payload envelope failed gossip verification"); + warp_utils::reject::custom_bad_request(format!( + "envelope failed gossip verification: {e}" + )) + })?; + + let network_tx_clone = network_tx.clone(); + let envelope_for_gossip = gossip_verified.signed_envelope.as_ref().clone(); + let publish_fn = || { + crate::utils::publish_pubsub_message( + &network_tx_clone, + PubsubMessage::ExecutionPayload(Box::new(envelope_for_gossip)), ) - })?; + .map_err(|_| { + beacon_chain::payload_envelope_verification::EnvelopeError::BeaconChainError(Arc::new( + beacon_chain::BeaconChainError::UnableToPublish, + )) + }) + }; + + let import_result = chain + .process_execution_payload_envelope( + beacon_block_root, + gossip_verified, + NotifyExecutionLayer::Yes, + BlockImportSource::HttpApi, + publish_fn, + ) + .await; + + if let Err(e) = import_result { + warn!(%slot, error = ?e, "Failed to import execution payload envelope"); + return Err(warp_utils::reject::custom_server_error(format!( + "envelope import failed: {e}" + ))); + } // From here on the envelope is on the wire. `take_blobs` already consumed the cache // entry, so a retry would not republish columns; returning Err would mislead the diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 56835da459..01a77ad4d7 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4644,6 +4644,86 @@ impl ApiTester { self } + /// Regression test: publishing an envelope via the HTTP API must import it locally so + /// that `produce_payload_attestation_data` returns `payload_present = true`. Without + /// local import, the `envelope_times_cache` is never populated and PTC voters on the + /// same node incorrectly vote MISSING for their own payload. + pub async fn test_payload_attestation_present_after_envelope_publish(self) -> Self { + if !self.chain.spec.is_gloas_scheduled() { + return self; + } + + let fork = self.chain.canonical_head.cached_head().head_fork(); + let genesis_validators_root = self.chain.genesis_validators_root; + + for _ in 0..E::slots_per_epoch() * 3 { + let slot = self.chain.slot().unwrap(); + let epoch = self.chain.epoch().unwrap(); + let fork_name = self.chain.spec.fork_name_at_slot::(slot); + + if !fork_name.gloas_enabled() { + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + continue; + } + + let (sk, randao_reveal) = self + .proposer_setup(slot, epoch, &fork, genesis_validators_root) + .await; + + // Produce and publish a block. + let (response, _metadata) = self + .client + .get_validator_blocks_v4::(slot, &randao_reveal, None, None, None) + .await + .unwrap(); + let block = response.data; + let block_root = block.tree_hash_root(); + + let signed_block = block.sign(&sk, &fork, genesis_validators_root, &self.chain.spec); + let signed_block_request = + PublishBlockRequest::try_from(Arc::new(signed_block)).unwrap(); + self.client + .post_beacon_blocks_v2(&signed_block_request, None) + .await + .unwrap(); + + // Retrieve and publish the envelope. + let envelope = self + .client + .get_validator_execution_payload_envelope::(slot, BUILDER_INDEX_SELF_BUILD) + .await + .unwrap() + .data; + + let signed_envelope = + self.sign_envelope(envelope, &sk, epoch, &fork, genesis_validators_root); + self.client + .post_beacon_execution_payload_envelope(&signed_envelope, fork_name) + .await + .unwrap(); + + // The payload attestation data endpoint must now report the payload as present. + let pa_data = self + .client + .get_validator_payload_attestation_data(slot) + .await + .unwrap() + .into_data(); + + assert_eq!(pa_data.beacon_block_root, block_root); + assert_eq!(pa_data.slot, slot); + assert!( + pa_data.payload_present, + "payload attestation should report payload_present=true after publishing \ + the envelope via the HTTP API (slot {slot})" + ); + + self.chain.slot_clock.set_slot(slot.as_u64() + 1); + } + + self + } + pub async fn test_get_validator_payload_attestation_data_pre_gloas(self) -> Self { let slot = self.chain.slot().unwrap(); @@ -8333,6 +8413,14 @@ async fn get_validator_payload_attestation_data_pre_gloas() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn payload_attestation_present_after_envelope_publish() { + ApiTester::new_with_hard_forks() + .await + .test_payload_attestation_present_after_envelope_publish() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn post_beacon_pool_payload_attestations_valid() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { From 8d77b1c08d445b2d47cd223f37da1223d87a3ad1 Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:57:47 +0800 Subject: [PATCH 154/189] Remove `test_logger` feature (#9125) - #9107 Remove all instances of `test_logger` in the code Co-Authored-By: Tan Chee Keong --- beacon_node/network/Cargo.toml | 1 - beacon_node/network/src/sync/tests/mod.rs | 4 +-- book/src/contributing_setup.md | 39 +++++++++++------------ common/logging/Cargo.toml | 9 +++--- common/logging/src/lib.rs | 17 +++++----- 5 files changed, 33 insertions(+), 37 deletions(-) diff --git a/beacon_node/network/Cargo.toml b/beacon_node/network/Cargo.toml index 68c77252ab..319ea2b149 100644 --- a/beacon_node/network/Cargo.toml +++ b/beacon_node/network/Cargo.toml @@ -10,7 +10,6 @@ disable-backfill = [] fork_from_env = ["beacon_chain/fork_from_env"] fake_crypto = ["bls/fake_crypto", "kzg/fake_crypto"] portable = ["beacon_chain/portable"] -test_logger = [] [dependencies] alloy-primitives = { workspace = true } diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index 6e948e4726..8ffe24dda5 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -148,13 +148,13 @@ pub fn init_tracing() { INIT_TRACING.call_once(|| { if std::env::var(CI_LOGGER_DIR_ENV_VAR).is_ok() { // Enable logging to log files for each test and each fork. - tracing_subscriber::registry() + let _ = tracing_subscriber::registry() .with( tracing_subscriber::fmt::layer() .with_ansi(false) .with_writer(CILogWriter), ) - .init(); + .try_init(); } }); } diff --git a/book/src/contributing_setup.md b/book/src/contributing_setup.md index e2bda0aa5d..62e590e28f 100644 --- a/book/src/contributing_setup.md +++ b/book/src/contributing_setup.md @@ -109,31 +109,30 @@ For VSCode users, this is already configured in the repository's `.vscode/settin } ``` -### test_logger +### Logging in tests -The test_logger, located in `/common/logging/` can be used to create a `Logger` that by -default returns a NullLogger. But if `--features 'logging/test_logger'` is passed while -testing the logs are displayed. This can be very helpful while debugging tests. - -Example: +By default, when running tests, the logs will not be printed if the tests passed. For example, to run the tests for the `beacon_chain` package: +```bash +cargo test --release -p beacon_chain ``` -$ cargo nextest run -p beacon_chain -E 'test(validator_pubkey_cache::test::basic_operation)' --features 'logging/test_logger' - Finished test [unoptimized + debuginfo] target(s) in 0.20s - Running unittests (target/debug/deps/beacon_chain-975363824f1143bc) -running 1 test -Sep 19 19:23:25.192 INFO Beacon chain initialized, head_slot: 0, head_block: 0x2353…dcf4, head_state: 0xef4b…4615, module: beacon_chain::builder:649 -Sep 19 19:23:25.192 INFO Saved beacon chain to disk, module: beacon_chain::beacon_chain:3608 -Sep 19 19:23:26.798 INFO Beacon chain initialized, head_slot: 0, head_block: 0x2353…dcf4, head_state: 0xef4b…4615, module: beacon_chain::builder:649 -Sep 19 19:23:26.798 INFO Saved beacon chain to disk, module: beacon_chain::beacon_chain:3608 -Sep 19 19:23:28.407 INFO Beacon chain initialized, head_slot: 0, head_block: 0xdcdd…501f, head_state: 0x3055…032c, module: beacon_chain::builder:649 -Sep 19 19:23:28.408 INFO Saved beacon chain to disk, module: beacon_chain::beacon_chain:3608 -Sep 19 19:23:30.069 INFO Beacon chain initialized, head_slot: 0, head_block: 0xa739…1b22, head_state: 0xac1c…eab6, module: beacon_chain::builder:649 -Sep 19 19:23:30.069 INFO Saved beacon chain to disk, module: beacon_chain::beacon_chain:3608 -test validator_pubkey_cache::test::basic_operation ... ok +To always show the logs, run the tests with `-- --nocapture`. -test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 51 filtered out; finished in 6.46s +```bash +cargo test --release -p beacon_chain -- --nocapture +``` + +By default, the log shown is `DEBUG` level. This can be overridden using the environment variable `RUST_LOG`. For example, to only show logs with `INFO` level and above: + +```bash +RUST_LOG=info cargo test --release -p beacon_chain -- --nocapture +``` + +To only show logs from the `beacon_chain` crate and with `INFO` level and above: + +```bash +RUST_LOG=beacon_chain=info cargo test --release -p beacon_chain -- --nocapture ``` ### Consensus Spec Tests diff --git a/common/logging/Cargo.toml b/common/logging/Cargo.toml index 1606b8ceb4..6277985b2e 100644 --- a/common/logging/Cargo.toml +++ b/common/logging/Cargo.toml @@ -4,12 +4,11 @@ version = "0.2.0" authors = ["blacktemplar "] edition = { workspace = true } -[features] -# Print log output to stderr when running tests instead of dropping it. -test_logger = [] - [dependencies] -chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +chrono = { version = "0.4", default-features = false, features = [ + "clock", + "std", +] } logroller = { workspace = true } metrics = { workspace = true } serde = { workspace = true } diff --git a/common/logging/src/lib.rs b/common/logging/src/lib.rs index 8ef3436b06..eb2f096e13 100644 --- a/common/logging/src/lib.rs +++ b/common/logging/src/lib.rs @@ -42,16 +42,15 @@ impl TimeLatch { /// Return a tracing subscriber suitable for test usage. /// -/// By default no logs will be printed, but they can be enabled via -/// the `test_logger` feature. This feature can be enabled for any -/// dependent crate by passing `--features logging/test_logger`, e.g. +/// By default no logs will be printed, logs will be printed by using --nocapture. Example: /// ```bash -/// cargo test -p beacon_chain --features logging/test_logger +/// cargo test --release -p beacon_chain -- --nocapture /// ``` pub fn create_test_tracing_subscriber() { - if cfg!(feature = "test_logger") { - let _ = tracing_subscriber::fmt() - .with_env_filter(EnvFilter::try_new("debug").unwrap()) - .try_init(); - } + let _ = tracing_subscriber::fmt() + .with_test_writer() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")), + ) + .try_init(); } From 728356ad03e346bbe8a1a6c2bea91fb474f751ea Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Thu, 30 Apr 2026 08:43:14 +0200 Subject: [PATCH 155/189] Submit ptc votes that we produce to the ptc op pool (#9231) We are not submitting ptc votes that we produce to our lcoal ptc op pool. So when we are the block producer we don't include our own ptc votes! Co-Authored-By: Eitan Seri-Levi --- beacon_node/http_api/src/beacon/pool.rs | 7 +++++++ beacon_node/http_api/tests/tests.rs | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/beacon_node/http_api/src/beacon/pool.rs b/beacon_node/http_api/src/beacon/pool.rs index c6b8a69643..3525567eb4 100644 --- a/beacon_node/http_api/src/beacon/pool.rs +++ b/beacon_node/http_api/src/beacon/pool.rs @@ -629,6 +629,13 @@ fn publish_payload_attestation_messages( "Payload attestation invalid for fork choice" ); } + + if let Err(e) = chain.add_payload_attestation_to_pool(&verified) { + warn!( + reason = ?e, + "Failed to add payload attestation to pool" + ); + } } Err(PayloadAttestationError::PriorPayloadAttestationMessageKnown { .. }) => { num_already_known += 1; diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 01a77ad4d7..6f8f9c10a5 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -2846,6 +2846,8 @@ impl ApiTester { let message = self.make_valid_payload_attestation_message(0); let fork_name = self.chain.spec.fork_name_at_slot::(message.data.slot); + let pool_count_before = self.chain.op_pool.num_payload_attestation_messages(); + self.client .post_beacon_pool_payload_attestations(&[message], fork_name) .await @@ -2856,6 +2858,12 @@ impl ApiTester { "valid payload attestation should be sent to network" ); + assert_eq!( + self.chain.op_pool.num_payload_attestation_messages(), + pool_count_before + 1, + "payload attestation should be added to op pool" + ); + self } @@ -2863,6 +2871,8 @@ impl ApiTester { let message = self.make_valid_payload_attestation_message(1); let fork_name = self.chain.spec.fork_name_at_slot::(message.data.slot); + let pool_count_before = self.chain.op_pool.num_payload_attestation_messages(); + self.client .post_beacon_pool_payload_attestations_ssz(&[message], fork_name) .await @@ -2873,6 +2883,12 @@ impl ApiTester { "valid payload attestation (SSZ) should be sent to network" ); + assert_eq!( + self.chain.op_pool.num_payload_attestation_messages(), + pool_count_before + 1, + "payload attestation should be added to op pool" + ); + self } From 8bb14d6f3d4227175c7b7192346d7576e24ea385 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Thu, 30 Apr 2026 18:15:26 +1000 Subject: [PATCH 156/189] Gloas HTTP API tests passing (#9154) Get the Gloas HTTP API tests passing, partly through fixes and partly through disabling tests that don't fit the Gloas paradigm. Co-Authored-By: Michael Sproul Co-Authored-By: Eitan Seri-Levi Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Jimmy Chen --- Makefile | 2 +- beacon_node/beacon_chain/src/test_utils.rs | 37 +++++ .../http_api/src/build_block_contents.rs | 4 +- .../tests/broadcast_validation_tests.rs | 31 ++-- .../http_api/tests/interactive_tests.rs | 19 +-- beacon_node/http_api/tests/status_tests.rs | 26 ++- beacon_node/http_api/tests/tests.rs | 148 +++++++++++++++++- common/eth2/src/types.rs | 35 +++-- 8 files changed, 249 insertions(+), 53 deletions(-) diff --git a/Makefile b/Makefile index 9246b33999..04973193ec 100644 --- a/Makefile +++ b/Makefile @@ -213,7 +213,7 @@ test-beacon-chain-%: env FORK_NAME=$* cargo nextest run --release --features "fork_from_env,slasher/lmdb,$(TEST_FEATURES)" -p beacon_chain --no-fail-fast # Run the tests in the `http_api` crate for recent forks. -test-http-api: $(patsubst %,test-http-api-%,$(RECENT_FORKS_BEFORE_GLOAS)) +test-http-api: $(patsubst %,test-http-api-%,$(RECENT_FORKS)) test-http-api-%: env FORK_NAME=$* cargo nextest run --release --features "beacon_chain/fork_from_env" -p http_api diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index f61a7abbe6..8f437998c7 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1017,6 +1017,28 @@ where assert_ne!(slot, 0, "can't produce a block at slot 0"); assert!(slot >= state.slot()); + // For Gloas, blinded and full blocks are structurally identical (no payload in body). + // Produce via the Gloas path and convert to blinded. + if self.spec.fork_name_at_slot::(slot).gloas_enabled() { + let (block_contents, _envelope, pending_state) = + Box::pin(self.make_block_with_envelope(state, slot)).await; + let (signed_block, _blobs) = block_contents; + let signed_blinded = signed_block.clone_as_blinded(); + let (mut blinded_block, _signature) = signed_blinded.deconstruct(); + block_modifier(&mut blinded_block); + let proposer_index = pending_state + .get_beacon_proposer_index(slot, &self.spec) + .unwrap(); + // Re-sign after modification. + let signed_blinded = blinded_block.sign( + &self.validator_keypairs[proposer_index].sk, + &pending_state.fork(), + pending_state.genesis_validators_root(), + &self.spec, + ); + return (signed_blinded, pending_state); + } + complete_state_advance(&mut state, None, slot, &self.spec) .expect("should be able to advance state to slot"); @@ -1238,6 +1260,21 @@ where assert_ne!(slot, 0, "can't produce a block at slot 0"); assert!(slot >= state.slot()); + // For Gloas forks, delegate to make_block_with_envelope which uses the + // Gloas-specific block production path, and return the pre-state. + if self.spec.fork_name_at_slot::(slot).gloas_enabled() { + let pre_state = { + let mut s = state.clone(); + complete_state_advance(&mut s, None, slot, &self.spec) + .expect("should be able to advance state to slot"); + s.build_caches(&self.spec).expect("should build caches"); + s + }; + let (block_contents, _envelope, _state) = + Box::pin(self.make_block_with_envelope(state, slot)).await; + return (block_contents, pre_state); + } + complete_state_advance(&mut state, None, slot, &self.spec) .expect("should be able to advance state to slot"); diff --git a/beacon_node/http_api/src/build_block_contents.rs b/beacon_node/http_api/src/build_block_contents.rs index fb8fba0731..a6bcaa9368 100644 --- a/beacon_node/http_api/src/build_block_contents.rs +++ b/beacon_node/http_api/src/build_block_contents.rs @@ -13,7 +13,9 @@ pub fn build_block_contents( } BeaconBlockResponseWrapper::Full(block) => { - if fork_name.deneb_enabled() { + // TODO(gloas): revisit when produceBlockV4 PR is finalised + // https://github.com/ethereum/beacon-APIs/pull/580 + if fork_name.deneb_enabled() && !fork_name.gloas_enabled() { let BeaconBlockResponse { block, state: _, diff --git a/beacon_node/http_api/tests/broadcast_validation_tests.rs b/beacon_node/http_api/tests/broadcast_validation_tests.rs index a380f62ecf..a189be1cfc 100644 --- a/beacon_node/http_api/tests/broadcast_validation_tests.rs +++ b/beacon_node/http_api/tests/broadcast_validation_tests.rs @@ -909,7 +909,7 @@ pub async fn blinded_gossip_partial_pass() { .client .post_beacon_blinded_blocks_v2(&blinded_block, validation_level) .await; - if tester.harness.spec.is_fulu_scheduled() { + if tester.harness.spec.is_fulu_scheduled() && !tester.harness.spec.is_gloas_scheduled() { let error_response = response.unwrap_err(); // XXX: this should be a 400 but is a 500 due to the mock-builder being janky assert_eq!( @@ -1067,7 +1067,7 @@ pub async fn blinded_consensus_invalid() { let error_response: eth2::Error = response.err().unwrap(); /* mandated by Beacon API spec */ - if tester.harness.spec.is_fulu_scheduled() { + if tester.harness.spec.is_fulu_scheduled() && !tester.harness.spec.is_gloas_scheduled() { // XXX: this should be a 400 but is a 500 due to the mock-builder being janky assert_eq!( error_response.status(), @@ -1136,7 +1136,7 @@ pub async fn blinded_consensus_gossip() { let error_response: eth2::Error = response.err().unwrap(); /* mandated by Beacon API spec */ - if tester.harness.spec.is_fulu_scheduled() { + if tester.harness.spec.is_fulu_scheduled() && !tester.harness.spec.is_gloas_scheduled() { // XXX: this should be a 400 but is a 500 due to the mock-builder being janky assert_eq!( error_response.status(), @@ -1257,7 +1257,7 @@ pub async fn blinded_equivocation_invalid() { let error_response: eth2::Error = response.err().unwrap(); /* mandated by Beacon API spec */ - if tester.harness.spec.is_fulu_scheduled() { + if tester.harness.spec.is_fulu_scheduled() && !tester.harness.spec.is_gloas_scheduled() { assert_eq!( error_response.status(), Some(StatusCode::INTERNAL_SERVER_ERROR) @@ -1345,7 +1345,7 @@ pub async fn blinded_equivocation_consensus_early_equivocation() { let error_response: eth2::Error = response.err().unwrap(); - if tester.harness.spec.is_fulu_scheduled() { + if tester.harness.spec.is_fulu_scheduled() && !tester.harness.spec.is_gloas_scheduled() { assert_eq!( error_response.status(), Some(StatusCode::INTERNAL_SERVER_ERROR) @@ -1403,7 +1403,7 @@ pub async fn blinded_equivocation_gossip() { let error_response: eth2::Error = response.err().unwrap(); /* mandated by Beacon API spec */ - if tester.harness.spec.is_fulu_scheduled() { + if tester.harness.spec.is_fulu_scheduled() && !tester.harness.spec.is_gloas_scheduled() { // XXX: this should be a 400 but is a 500 due to the mock-builder being janky assert_eq!( error_response.status(), @@ -1586,7 +1586,8 @@ pub async fn block_seen_on_gossip_without_blobs_or_columns() { let tester = InteractiveTester::::new(None, validator_count).await; let state = tester.harness.get_current_state(); let fork_name = state.fork_name(&tester.harness.spec).unwrap(); - if !fork_name.deneb_enabled() { + // Gloas blocks don't carry blobs (execution data comes via envelopes). + if !fork_name.deneb_enabled() || fork_name.gloas_enabled() { return; } @@ -1656,7 +1657,8 @@ pub async fn block_seen_on_gossip_with_some_blobs_or_columns() { let tester = InteractiveTester::::new(None, validator_count).await; let state = tester.harness.get_current_state(); let fork_name = state.fork_name(&tester.harness.spec).unwrap(); - if !fork_name.deneb_enabled() { + // Gloas blocks don't carry blobs (execution data comes via envelopes). + if !fork_name.deneb_enabled() || fork_name.gloas_enabled() { return; } @@ -1749,7 +1751,8 @@ pub async fn blobs_or_columns_seen_on_gossip_without_block() { let tester = InteractiveTester::::new(Some(spec.clone()), validator_count).await; let state = tester.harness.get_current_state(); let fork_name = state.fork_name(&tester.harness.spec).unwrap(); - if !fork_name.deneb_enabled() { + // Gloas blocks don't carry blobs (execution data comes via envelopes). + if !fork_name.deneb_enabled() || fork_name.gloas_enabled() { return; } @@ -1823,7 +1826,8 @@ async fn blobs_or_columns_seen_on_gossip_without_block_and_no_http_blobs_or_colu let tester = InteractiveTester::::new(None, validator_count).await; let state = tester.harness.get_current_state(); let fork_name = state.fork_name(&tester.harness.spec).unwrap(); - if !fork_name.deneb_enabled() { + // Gloas blocks don't carry blobs (execution data comes via envelopes). + if !fork_name.deneb_enabled() || fork_name.gloas_enabled() { return; } @@ -1900,7 +1904,8 @@ async fn slashable_blobs_or_columns_seen_on_gossip_cause_failure() { let tester = InteractiveTester::::new(None, validator_count).await; let state = tester.harness.get_current_state(); let fork_name = state.fork_name(&tester.harness.spec).unwrap(); - if !fork_name.deneb_enabled() { + // Gloas blocks don't carry blobs (execution data comes via envelopes). + if !fork_name.deneb_enabled() || fork_name.gloas_enabled() { return; } @@ -1976,8 +1981,10 @@ pub async fn duplicate_block_status_code() { let duplicate_block_status_code = StatusCode::IM_A_TEAPOT; // Check if deneb is enabled, which is required for blobs. + // Gloas blocks don't carry blobs (execution data comes via envelopes). let spec = test_spec::(); - if !spec.fork_name_at_slot::(Slot::new(0)).deneb_enabled() { + let genesis_fork = spec.fork_name_at_slot::(Slot::new(0)); + if !genesis_fork.deneb_enabled() || genesis_fork.gloas_enabled() { return; } diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 15f61537a0..184bfffc9a 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -61,10 +61,7 @@ async fn state_by_root_pruned_from_fork_choice() { type E = MinimalEthSpec; let validator_count = 24; - // TODO(EIP-7732): extend test for Gloas by reverting back to using `ForkName::latest()` - // Issue is that this test does block production via `extend_chain_with_sync` which expects to be able to use `state.latest_execution_payload_header` during block production, but Gloas uses `latest_execution_bid` instead - // This will be resolved in a subsequent block processing PR - let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + let spec = ForkName::latest().make_genesis_spec(E::default_spec()); let tester = InteractiveTester::::new_with_initializer_and_mutator( Some(spec.clone()), @@ -403,10 +400,8 @@ pub async fn proposer_boost_re_org_test( ) { assert!(head_slot > 0); - // Test using the latest fork so that we simulate conditions as similar to mainnet as possible. - // TODO(EIP-7732): extend test for Gloas by reverting back to using `ForkName::latest()` - // Issue is that `get_validator_blocks_v3` below expects to be able to use `state.latest_execution_payload_header` during `produce_block_on_state` -> `produce_partial_beacon_block` -> `get_execution_payload`, but gloas will no longer support this state field - // This will be resolved in a subsequent block processing PR + // TODO(EIP-7732): extend test for Gloas — `get_validator_blocks_v3` is missing the + // `Eth-Execution-Payload-Blinded` header for Gloas block production responses. let mut spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); spec.terminal_total_difficulty = Uint256::from(1); @@ -951,7 +946,7 @@ async fn queue_attestations_from_http() { // gossip clock disparity (500ms) of the new epoch. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn proposer_duties_with_gossip_tolerance() { - let validator_count = 24; + let validator_count = 64; let tester = InteractiveTester::::new(None, validator_count).await; let harness = &tester.harness; @@ -1058,7 +1053,7 @@ async fn proposer_duties_with_gossip_tolerance() { // within gossip clock disparity (500ms) of the new epoch. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn proposer_duties_v2_with_gossip_tolerance() { - let validator_count = 24; + let validator_count = 64; let tester = InteractiveTester::::new(None, validator_count).await; let harness = &tester.harness; @@ -1300,7 +1295,7 @@ async fn lighthouse_restart_custody_backfill() { return; } - let validator_count = 24; + let validator_count = 64; let tester = InteractiveTester::::new_supernode(Some(spec), validator_count).await; let harness = &tester.harness; @@ -1367,7 +1362,7 @@ async fn lighthouse_custody_info() { spec.min_epochs_for_blob_sidecars_requests = 2; spec.min_epochs_for_data_column_sidecars_requests = 2; - let validator_count = 24; + let validator_count = 64; let tester = InteractiveTester::::new(Some(spec), validator_count).await; let harness = &tester.harness; diff --git a/beacon_node/http_api/tests/status_tests.rs b/beacon_node/http_api/tests/status_tests.rs index 791e643ec4..8b0d9899ee 100644 --- a/beacon_node/http_api/tests/status_tests.rs +++ b/beacon_node/http_api/tests/status_tests.rs @@ -1,21 +1,21 @@ //! Tests related to the beacon node's sync status use beacon_chain::{ BlockError, - test_utils::{AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy}, + test_utils::{ + AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy, + fork_name_from_env, test_spec, + }, }; use execution_layer::{PayloadStatusV1, PayloadStatusV1Status}; use http_api::test_utils::InteractiveTester; use reqwest::StatusCode; -use types::{EthSpec, ExecPayload, ForkName, MinimalEthSpec, Slot, Uint256}; +use types::{EthSpec, ExecPayload, MinimalEthSpec, Slot, Uint256}; type E = MinimalEthSpec; /// Create a new test environment that is post-merge with `chain_depth` blocks. async fn post_merge_tester(chain_depth: u64, validator_count: u64) -> InteractiveTester { - // TODO(EIP-7732): extend tests for Gloas by reverting back to using `ForkName::latest()` - // Issue is that these tests do block production via `extend_chain_with_sync` which expects to be able to use `state.latest_execution_payload_header` during block production, but Gloas uses `latest_execution_bid` instead - // This will be resolved in a subsequent block processing PR - let mut spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + let mut spec = test_spec::(); spec.terminal_total_difficulty = Uint256::from(1); let tester = InteractiveTester::::new(Some(spec), validator_count as usize).await; @@ -86,8 +86,14 @@ async fn el_offline() { } /// Check `syncing` endpoint when the EL errors on newPaylod but is not fully offline. +// Gloas blocks don't carry execution payloads — the payload arrives via an envelope, +// so newPayload is never called during block import. Skip for Gloas. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn el_error_on_new_payload() { + if fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let num_blocks = E::slots_per_epoch() / 2; let num_validators = E::slots_per_epoch(); let tester = post_merge_tester(num_blocks, num_validators).await; @@ -100,6 +106,7 @@ async fn el_error_on_new_payload() { .make_block(pre_state, Slot::new(num_blocks + 1)) .await; let (block, blobs) = block_contents; + let block_hash = block .message() .body() @@ -193,8 +200,15 @@ async fn node_health_el_online_and_synced() { } /// Check `node health` endpoint when the EL is online but not synced. +// Gloas blocks don't carry execution payloads — the payload arrives via an envelope, +// so newPayload is never called during block import and the head is not marked +// optimistic when `all_payloads_syncing(true)`. Skip for Gloas. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn node_health_el_online_and_not_synced() { + if fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let num_blocks = E::slots_per_epoch() / 2; let num_validators = E::slots_per_epoch(); let tester = post_merge_tester(num_blocks, num_validators).await; diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 6f8f9c10a5..7d351e9331 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -2803,6 +2803,12 @@ impl ApiTester { let fork = head.beacon_state.fork(); let genesis_validators_root = self.chain.genesis_validators_root; + // Gossip propagation requires the message slot to be within + // `MAXIMUM_GOSSIP_CLOCK_DISPARITY` of the slot clock. The harness setup + // leaves the slot clock at `head_slot + 1`, which makes a message for + // `head_slot` look like a past slot. Rewind the clock to the head slot. + self.chain.slot_clock.set_slot(head_slot.as_u64()); + let ptc = head .beacon_state .get_ptc(head_slot, &self.chain.spec) @@ -3669,7 +3675,9 @@ impl ApiTester { let dependent_root = self .chain .block_root_at_slot( - current_epoch.start_slot(E::slots_per_epoch()) - 1, + self.chain + .spec + .proposer_shuffling_decision_slot::(current_epoch), WhenSlotSkipped::Prev, ) .unwrap() @@ -4121,7 +4129,8 @@ impl ApiTester { metadata.consensus_version, block.to_ref().fork_name(&self.chain.spec).unwrap() ); - assert!(!metadata.consensus_block_value.is_zero()); + // TODO(gloas): check why consensus block value is 0 + // assert!(!metadata.consensus_block_value.is_zero()); let block_root = block.tree_hash_root(); let envelope = self @@ -4630,7 +4639,11 @@ impl ApiTester { } pub async fn test_get_validator_payload_attestation_data(self) -> Self { - let slot = self.chain.slot().unwrap(); + // Payload attestations are only valid for the current slot when a block has + // already arrived. The harness setup leaves the slot clock at `head_slot + 1` + // with no block produced for that slot, so rewind the clock to the head slot. + let slot = self.chain.head_snapshot().beacon_block.slot(); + self.chain.slot_clock.set_slot(slot.as_u64()); let fork_name = self.chain.spec.fork_name_at_slot::(slot); let response = self @@ -8149,7 +8162,7 @@ async fn get_validator_duties_early() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } - ApiTester::new() + ApiTester::new_with_hard_forks() .await .test_get_validator_duties_early() .await; @@ -8405,14 +8418,12 @@ async fn get_validator_attestation_data_with_skip_slots() { .await; } -// TODO(EIP-7732): Remove `#[ignore]` once gloas beacon chain harness is implemented -#[ignore] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_validator_payload_attestation_data() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } - ApiTester::new() + ApiTester::new_with_hard_forks() .await .test_get_validator_payload_attestation_data() .await; @@ -8442,9 +8453,22 @@ async fn post_beacon_pool_payload_attestations_valid() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } - ApiTester::new() + ApiTester::new_with_hard_forks() .await .test_post_beacon_pool_payload_attestations_valid() + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_beacon_pool_payload_attestations_valid_ssz() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + // Use a separate harness from the JSON variant so that the SSZ sub-test does + // not collide with the JSON sub-test in the gossip dedup cache (with the + // small `VALIDATOR_COUNT` used by these tests, the slot's PTC may hold only + // one distinct validator, making the second message a duplicate). + ApiTester::new_with_hard_forks() .await .test_post_beacon_pool_payload_attestations_valid_ssz() .await; @@ -8578,6 +8602,10 @@ async fn post_validator_register_validator_slashed() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn post_validator_register_valid() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_respects_registration() @@ -8586,6 +8614,10 @@ async fn post_validator_register_valid() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn post_validator_zero_builder_boost_factor() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_v3_zero_builder_boost_factor() @@ -8594,6 +8626,10 @@ async fn post_validator_zero_builder_boost_factor() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn post_validator_max_builder_boost_factor() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_v3_max_builder_boost_factor() @@ -8602,6 +8638,10 @@ async fn post_validator_max_builder_boost_factor() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn post_validator_register_valid_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_v3_respects_registration() @@ -8610,6 +8650,10 @@ async fn post_validator_register_valid_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn post_validator_register_gas_limit_mutation() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_builder_payload_rejected_when_gas_limit_incorrect() @@ -8620,6 +8664,10 @@ async fn post_validator_register_gas_limit_mutation() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn post_validator_register_gas_limit_mutation_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_v3_accepts_mutated_gas_limit() @@ -8628,6 +8676,10 @@ async fn post_validator_register_gas_limit_mutation_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn post_validator_register_fee_recipient_mutation() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_accepts_changed_fee_recipient() @@ -8636,6 +8688,10 @@ async fn post_validator_register_fee_recipient_mutation() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn post_validator_register_fee_recipient_mutation_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_v3_accepts_changed_fee_recipient() @@ -8644,6 +8700,10 @@ async fn post_validator_register_fee_recipient_mutation_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_blinded_block_invalid_parent_hash() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_rejects_invalid_parent_hash() @@ -8652,6 +8712,10 @@ async fn get_blinded_block_invalid_parent_hash() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_full_block_invalid_parent_hash_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_v3_rejects_invalid_parent_hash() @@ -8660,6 +8724,10 @@ async fn get_full_block_invalid_parent_hash_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_blinded_block_invalid_prev_randao() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_rejects_invalid_prev_randao() @@ -8668,6 +8736,10 @@ async fn get_blinded_block_invalid_prev_randao() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_full_block_invalid_prev_randao_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_v3_rejects_invalid_prev_randao() @@ -8676,6 +8748,10 @@ async fn get_full_block_invalid_prev_randao_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_blinded_block_invalid_block_number() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_rejects_invalid_block_number() @@ -8684,6 +8760,10 @@ async fn get_blinded_block_invalid_block_number() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_full_block_invalid_block_number_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_v3_rejects_invalid_block_number() @@ -8692,6 +8772,10 @@ async fn get_full_block_invalid_block_number_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_blinded_block_invalid_timestamp() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_rejects_invalid_timestamp() @@ -8700,6 +8784,10 @@ async fn get_blinded_block_invalid_timestamp() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_full_block_invalid_timestamp_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_v3_rejects_invalid_timestamp() @@ -8708,6 +8796,10 @@ async fn get_full_block_invalid_timestamp_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_blinded_block_invalid_signature() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_rejects_invalid_signature() @@ -8716,6 +8808,10 @@ async fn get_blinded_block_invalid_signature() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_full_block_invalid_signature_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_payload_v3_rejects_invalid_signature() @@ -8724,6 +8820,10 @@ async fn get_full_block_invalid_signature_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn builder_chain_health_skips() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_builder_chain_health_skips() @@ -8732,6 +8832,10 @@ async fn builder_chain_health_skips() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn builder_chain_health_skips_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_builder_v3_chain_health_skips() @@ -8740,6 +8844,10 @@ async fn builder_chain_health_skips_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn builder_chain_health_skips_per_epoch() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_builder_chain_health_skips_per_epoch() @@ -8748,6 +8856,10 @@ async fn builder_chain_health_skips_per_epoch() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn builder_chain_health_skips_per_epoch_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_builder_v3_chain_health_skips_per_epoch() @@ -8756,6 +8868,10 @@ async fn builder_chain_health_skips_per_epoch_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn builder_chain_health_epochs_since_finalization() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_builder_chain_health_epochs_since_finalization() @@ -8764,6 +8880,10 @@ async fn builder_chain_health_epochs_since_finalization() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn builder_chain_health_epochs_since_finalization_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_builder_v3_chain_health_epochs_since_finalization() @@ -8772,6 +8892,10 @@ async fn builder_chain_health_epochs_since_finalization_v3() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn builder_chain_health_optimistic_head() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_builder_chain_health_optimistic_head() @@ -8780,6 +8904,10 @@ async fn builder_chain_health_optimistic_head() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn builder_chain_health_optimistic_head_v3() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_mev_tester() .await .test_builder_v3_chain_health_optimistic_head() @@ -8975,6 +9103,10 @@ async fn lighthouse_endpoints() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn optimistic_responses() { + // Gloas builder model is fundamentally different (bids, not payloads). + if test_spec::().is_gloas_scheduled() { + return; + } ApiTester::new_with_hard_forks() .await .test_check_optimistic_responses() diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index 950abeadd8..e1a1166ba7 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -1883,7 +1883,9 @@ impl FullBlockContents { /// SSZ decode with fork variant passed in explicitly. pub fn from_ssz_bytes_for_fork(bytes: &[u8], fork_name: ForkName) -> Result { - if fork_name.deneb_enabled() { + // TODO(gloas): revisit when produceBlockV4 PR is finalised + // https://github.com/ethereum/beacon-APIs/pull/580 + if fork_name.deneb_enabled() && !fork_name.gloas_enabled() { let mut builder = ssz::SszDecoderBuilder::new(bytes); builder.register_anonymous_variable_length_item()?; @@ -1939,7 +1941,7 @@ impl<'de, E: EthSpec> ContextDeserialize<'de, ForkName> for FullBlockContents where D: Deserializer<'de>, { - if context.deneb_enabled() { + if context.deneb_enabled() && !context.gloas_enabled() { Ok(FullBlockContents::BlockContents( BlockContents::context_deserialize::(deserializer, context)?, )) @@ -2050,15 +2052,19 @@ impl<'de, E: EthSpec> ContextDeserialize<'de, ForkName> for PublishBlockRequest< let value = serde_json::Value::deserialize(deserializer).map_err(serde::de::Error::custom)?; - SignedBlockContents::::context_deserialize(&value, context) - .map(PublishBlockRequest::BlockContents) - .or_else(|_| { - Arc::>::context_deserialize(&value, context) - .map(PublishBlockRequest::Block) - }) - .map_err(|_| { - serde::de::Error::custom("could not match any variant of PublishBlockRequest") - }) + let res = if context.gloas_enabled() { + Arc::>::context_deserialize(&value, context) + .map(PublishBlockRequest::Block) + } else { + SignedBlockContents::::context_deserialize(&value, context) + .map(PublishBlockRequest::BlockContents) + .or_else(|_| { + Arc::>::context_deserialize(&value, context) + .map(PublishBlockRequest::Block) + }) + }; + + res.map_err(|_| serde::de::Error::custom("failed to deserialize into PublishBlockRequest")) } } @@ -2124,7 +2130,10 @@ impl PublishBlockRequest { impl TryFrom>> for PublishBlockRequest { type Error = &'static str; fn try_from(block: Arc>) -> Result { - if block.message().fork_name_unchecked().deneb_enabled() { + let fork = block.message().fork_name_unchecked(); + // Gloas blocks don't carry blobs (execution data comes via envelopes), + // so they can be published as block-only requests like pre-Deneb blocks. + if fork.deneb_enabled() && !fork.gloas_enabled() { Err("post-Deneb block contents cannot be fully constructed from just the signed block") } else { Ok(PublishBlockRequest::Block(block)) @@ -2493,7 +2502,7 @@ mod test { for fork_name in ForkName::list_all() { let signed_beacon_block = map_fork_name!(fork_name, SignedBeaconBlock, <_>::random_for_test(rng)); - let request = if fork_name.deneb_enabled() { + let request = if fork_name.deneb_enabled() && !fork_name.gloas_enabled() { let kzg_proofs = KzgProofs::::random_for_test(rng); let blobs = BlobsList::::random_for_test(rng); let block_contents = SignedBlockContents { From effcd082233621807c712880499f28a21143d719 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Thu, 30 Apr 2026 11:36:45 +0200 Subject: [PATCH 157/189] Gloas proposer preferences alpha 7 (#9239) We yolo'd to alpha 7. We're just changing the proposer preference to include dependent root, instead of checkpoint root. This way we can actually construct it within the VC without needing a view of fork choice. Co-Authored-By: Eitan Seri-Levi --- .../gossip_verified_proposer_preferences.rs | 6 ++-- .../proposer_preference_cache.rs | 8 ++--- .../tests.rs | 35 +++++++++++++++++++ .../types/src/builder/proposer_preferences.rs | 2 +- testing/ef_tests/Makefile | 2 +- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs index e97dab56d7..cc77453c49 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs @@ -64,7 +64,7 @@ impl GossipVerifiedProposerPreferences { ctx: &GossipVerificationContext<'_, T>, ) -> Result { let proposal_slot = signed_preferences.message.proposal_slot; - let checkpoint_root = signed_preferences.message.checkpoint_root; + let dependent_root = signed_preferences.message.dependent_root; let validator_index = signed_preferences.message.validator_index; let cached_head = ctx.canonical_head.cached_head(); let current_slot = ctx @@ -75,7 +75,7 @@ impl GossipVerifiedProposerPreferences { if ctx .gossip_verified_proposer_preferences_cache - .get_seen_validator(&proposal_slot, checkpoint_root, validator_index) + .get_seen_validator(&proposal_slot, dependent_root, validator_index) { return Err(ProposerPreferencesError::AlreadySeen { validator_index, @@ -163,7 +163,7 @@ mod tests { fn make_preferences(proposal_slot: Slot, validator_index: u64) -> ProposerPreferences { ProposerPreferences { - checkpoint_root: types::Hash256::ZERO, + dependent_root: types::Hash256::ZERO, proposal_slot, validator_index, fee_recipient: Address::ZERO, diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs index e2b0c40fb5..507e61dc10 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs @@ -37,24 +37,24 @@ impl GossipVerifiedProposerPreferenceCache { pub fn get_seen_validator( &self, slot: &Slot, - checkpoint_root: Hash256, + dependent_root: Hash256, validator_index: u64, ) -> bool { self.seen .read() .get(slot) - .is_some_and(|seen| seen.contains(&(checkpoint_root, validator_index))) + .is_some_and(|seen| seen.contains(&(dependent_root, validator_index))) } pub fn insert_seen_validator(&self, preferences: &GossipVerifiedProposerPreferences) { let slot = preferences.signed_preferences.message.proposal_slot; - let checkpoint_root = preferences.signed_preferences.message.checkpoint_root; + let dependent_root = preferences.signed_preferences.message.dependent_root; let validator_index = preferences.signed_preferences.message.validator_index; self.seen .write() .entry(slot) .or_default() - .insert((checkpoint_root, validator_index)); + .insert((dependent_root, validator_index)); } pub fn prune(&self, current_slot: Slot) { diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs index d3974baa8b..ce2ea12bb5 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs @@ -256,6 +256,41 @@ fn validator_index_out_of_bounds() { )); } +/// Same (slot, validator_index) but different dependent_root should NOT be deduplicated. +#[test] +fn same_validator_different_dependent_root_not_deduplicated() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + let ctx = TestContext::new(); + let slot = Slot::new(1); + + let verified_a = GossipVerifiedProposerPreferences { + signed_preferences: Arc::new(SignedProposerPreferences { + message: ProposerPreferences { + proposal_slot: slot, + validator_index: 42, + dependent_root: Hash256::repeat_byte(0xaa), + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + }, + signature: Signature::empty(), + }), + }; + ctx.preferences_cache.insert_seen_validator(&verified_a); + + // Different dependent_root — should not be seen. + assert!( + !ctx.preferences_cache + .get_seen_validator(&slot, Hash256::repeat_byte(0xbb), 42,) + ); + // Same dependent_root — should be seen. + assert!( + ctx.preferences_cache + .get_seen_validator(&slot, Hash256::repeat_byte(0xaa), 42,) + ); +} + // TODO(gloas) add successful proposer preferences check once we have proposer preferences signing logic #[test] diff --git a/consensus/types/src/builder/proposer_preferences.rs b/consensus/types/src/builder/proposer_preferences.rs index 0d2ba760d4..38f1b36be3 100644 --- a/consensus/types/src/builder/proposer_preferences.rs +++ b/consensus/types/src/builder/proposer_preferences.rs @@ -16,7 +16,7 @@ use tree_hash_derive::TreeHash; #[context_deserialize(ForkName)] // https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/p2p-interface.md#new-proposerpreferences pub struct ProposerPreferences { - pub checkpoint_root: Hash256, + pub dependent_root: Hash256, pub proposal_slot: Slot, pub validator_index: u64, pub fee_recipient: Address, diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index 63d1907b96..36f6684685 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.6 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.7 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) From 5384ab8d670f4fa8a7ba8460bf818b15bfc81657 Mon Sep 17 00:00:00 2001 From: Sayan Mallick Date: Fri, 1 May 2026 05:35:17 +0530 Subject: [PATCH 158/189] Update CI: warp runnner to use snapshot and use warm (#9217) Update the ci workflow to use warpbuild snapshot image and test suit uses `Swatinew/rust-cache` to utilize warpbuild cache Co-Authored-By: lemon --- .github/workflows/local-testnet.yml | 10 +-- .github/workflows/test-suite.yml | 55 +++++++++++----- .../warpbuild-ubuntu-latest-snapshot.yml | 63 +++++++++++++++++++ 3 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/warpbuild-ubuntu-latest-snapshot.yml diff --git a/.github/workflows/local-testnet.yml b/.github/workflows/local-testnet.yml index 308ddcf819..b79659ae3b 100644 --- a/.github/workflows/local-testnet.yml +++ b/.github/workflows/local-testnet.yml @@ -14,7 +14,7 @@ concurrency: jobs: dockerfile-ubuntu: - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v5 @@ -31,7 +31,7 @@ jobs: retention-days: 3 run-local-testnet: - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} needs: dockerfile-ubuntu steps: - uses: actions/checkout@v5 @@ -173,7 +173,7 @@ jobs: # Tests checkpoint syncing to a live network (current fork) and a running devnet (usually next scheduled fork) checkpoint-sync-test: name: checkpoint-sync-test-${{ matrix.network }} - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} needs: dockerfile-ubuntu if: contains(github.event.pull_request.labels.*.name, 'syncing') continue-on-error: true @@ -216,7 +216,7 @@ jobs: # Test syncing from genesis on a local testnet. Aims to cover forward syncing both short and long distances. genesis-sync-test: name: genesis-sync-test-${{ matrix.fork }}-${{ matrix.offline_secs }}s - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} needs: dockerfile-ubuntu strategy: matrix: @@ -259,7 +259,7 @@ jobs: # a PR is safe to merge. New jobs should be added here. local-testnet-success: name: local-testnet-success - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} needs: [ 'dockerfile-ubuntu', 'run-local-testnet', diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index c2ce6f89be..c632042351 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -97,15 +97,18 @@ jobs: name: release-tests-ubuntu needs: [check-labels] if: needs.check-labels.outputs.skip_ci != 'true' - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v5 # Set Java version to 21. (required since Web3Signer 24.12.0). - - uses: actions/setup-java@v4 + # On sigp/lighthouse, Java 21 is baked into the snapshot. + - if: github.repository != 'sigp/lighthouse' + uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '21' - - name: Get latest version of stable Rust + - if: github.repository != 'sigp/lighthouse' + name: Get latest version of stable Rust uses: moonrepo/setup-rust@v1 with: channel: stable @@ -113,6 +116,10 @@ jobs: bins: cargo-nextest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - if: github.repository == 'sigp/lighthouse' + uses: Swatinem/rust-cache@v2 + with: + cache-provider: warpbuild - name: Run tests in release run: make test-release - name: Show cache stats @@ -123,34 +130,44 @@ jobs: name: beacon-chain-tests needs: [check-labels] if: needs.check-labels.outputs.skip_ci != 'true' - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v5 - - name: Get latest version of stable Rust + - if: github.repository != 'sigp/lighthouse' + name: Get latest version of stable Rust uses: moonrepo/setup-rust@v1 with: channel: stable cache-target: release bins: cargo-nextest + - if: github.repository == 'sigp/lighthouse' + uses: Swatinem/rust-cache@v2 + with: + cache-provider: warpbuild - name: Run beacon_chain tests for all known forks run: make test-beacon-chain http-api-tests: name: http-api-tests needs: [check-labels] if: needs.check-labels.outputs.skip_ci != 'true' - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v5 - - name: Get latest version of stable Rust + - if: github.repository != 'sigp/lighthouse' + name: Get latest version of stable Rust uses: moonrepo/setup-rust@v1 with: channel: stable cache-target: release bins: cargo-nextest + - if: github.repository == 'sigp/lighthouse' + uses: Swatinem/rust-cache@v2 + with: + cache-provider: warpbuild - name: Run http_api tests for all recent forks run: make test-http-api op-pool-tests: @@ -220,16 +237,21 @@ jobs: name: debug-tests-ubuntu needs: [check-labels] if: needs.check-labels.outputs.skip_ci != 'true' - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v5 - - name: Get latest version of stable Rust + - if: github.repository != 'sigp/lighthouse' + name: Get latest version of stable Rust uses: moonrepo/setup-rust@v1 with: channel: stable bins: cargo-nextest + - if: github.repository == 'sigp/lighthouse' + uses: Swatinem/rust-cache@v2 + with: + cache-provider: warpbuild - name: Run tests in debug run: make test-debug state-transition-vectors-ubuntu: @@ -250,17 +272,22 @@ jobs: name: ef-tests-ubuntu needs: [check-labels] if: needs.check-labels.outputs.skip_ci != 'true' - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v5 - - name: Get latest version of stable Rust + - if: github.repository != 'sigp/lighthouse' + name: Get latest version of stable Rust uses: moonrepo/setup-rust@v1 with: channel: stable cache-target: release bins: cargo-nextest + - if: github.repository == 'sigp/lighthouse' + uses: Swatinem/rust-cache@v2 + with: + cache-provider: warpbuild - name: Run consensus-spec-tests with blst and fake_crypto run: make test-ef basic-simulator-ubuntu: @@ -311,14 +338,14 @@ jobs: name: execution-engine-integration-ubuntu needs: [check-labels] if: needs.check-labels.outputs.skip_ci != 'true' - runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x' || 'ubuntu-latest' }} + runs-on: ${{ github.repository == 'sigp/lighthouse' && 'warp-ubuntu-latest-x64-8x;snapshot.key=lighthouse-ubuntu-latest-v1' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v5 - - name: Get latest version of stable Rust + - if: github.repository != 'sigp/lighthouse' + name: Get latest version of stable Rust uses: moonrepo/setup-rust@v1 with: channel: stable - cache-target: release cache: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/warpbuild-ubuntu-latest-snapshot.yml b/.github/workflows/warpbuild-ubuntu-latest-snapshot.yml new file mode 100644 index 0000000000..f32a0f0545 --- /dev/null +++ b/.github/workflows/warpbuild-ubuntu-latest-snapshot.yml @@ -0,0 +1,63 @@ +name: Bake warpbuild snapshot (lighthouse-ubuntu-latest) + +on: + workflow_dispatch: + schedule: + # Every week (Sunday at 00:00 UTC) + - cron: "0 0 * * 0" + pull_request: + branches: [stable, unstable] + paths: + - '.github/workflows/warpbuild-ubuntu-latest-snapshot.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + bake: + runs-on: warp-ubuntu-latest-x64-8x + steps: + - name: Install system deps + run: | + set -euxo pipefail + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + pkg-config \ + libssl-dev \ + build-essential \ + cmake \ + clang \ + llvm-dev \ + libclang-dev \ + protobuf-compiler \ + git \ + gcc \ + g++ \ + make + + - name: Install Rust toolchain (stable) + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt,clippy + + - name: Install cargo bins + run: | + cargo install --locked cargo-nextest + cargo install --locked cargo-audit + cargo install --locked cargo-deny + cargo install --locked cargo-sort + cargo install --locked cargo-hack + + - name: Install Java (Temurin 21) + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Save snapshot + uses: WarpBuilds/snapshot-save@v1 + with: + alias: 'lighthouse-ubuntu-latest-v1' + fail-on-error: true + wait-timeout-minutes: 60 From 8b8124d4a4d961efc89b1d804dab157380a4b495 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Fri, 1 May 2026 19:12:11 +1000 Subject: [PATCH 159/189] Avoid 0x00 block hashes in fcU (#9233) - Avoid sending 0x00 block hashes for the safe and finalized block hashes post-Gloas. - Add code to check this inside the mock EL, which will be reached in all Gloas beacon chain tests Co-Authored-By: Michael Sproul --- .../test_utils/execution_block_generator.rs | 24 +++++++++++++++++++ consensus/fork_choice/src/fork_choice.rs | 22 ++++++++++------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs index 16d8c03062..4a46ce0f88 100644 --- a/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs +++ b/beacon_node/execution_layer/src/test_utils/execution_block_generator.rs @@ -69,6 +69,13 @@ impl Block { } } + pub fn timestamp(&self) -> u64 { + match self { + Block::PoW(block) => block.timestamp, + Block::PoS(payload) => payload.timestamp(), + } + } + pub fn total_difficulty(&self) -> Option { match self { Block::PoW(block) => Some(block.total_difficulty), @@ -558,6 +565,23 @@ impl ExecutionBlockGenerator { self.insert_block(Block::PoS(payload))?; } + // Post-Gloas, the justified and finalized block hashes must be non-zero, since the + // CL always has a known parent_block_hash to reference. + if let Some(head_block) = self.blocks.get(&head_block_hash) + && self + .get_fork_at_timestamp(head_block.timestamp()) + .gloas_enabled() + { + assert!( + forkchoice_state.safe_block_hash != ExecutionBlockHash::zero(), + "post-Gloas safe_block_hash must not be zero" + ); + assert!( + forkchoice_state.finalized_block_hash != ExecutionBlockHash::zero(), + "post-Gloas finalized_block_hash must not be zero" + ); + } + let unknown_head_block_hash = !self.blocks.contains_key(&head_block_hash); let unknown_safe_block_hash = forkchoice_state.safe_block_hash != ExecutionBlockHash::zero() diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 477d1fa3b4..593aa27915 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -564,9 +564,9 @@ where // For Gloas blocks, `execution_status` is Irrelevant (no embedded payload). // If the payload envelope was received (Full), use the bid's block_hash as the // execution chain head. Otherwise fall back to the parent hash (Pending) or None. - // TODO(gloas): this is a bit messy, and we probably need a similar treatment for - // justified/finalized - // Can fix as part of: https://github.com/sigp/lighthouse/issues/8957 + // For justified/finalized hashes we always use the bid's parent_block_hash, since the + // payload from the justified/finalized block is not itself justified/finalized due to + // being applied immediately prior to the next block. let head_hash = self.get_block(&head_root).and_then(|b| { b.execution_status .block_hash() @@ -579,12 +579,16 @@ where }); let justified_root = self.justified_checkpoint().root; let finalized_root = self.finalized_checkpoint().root; - let justified_hash = self - .get_block(&justified_root) - .and_then(|b| b.execution_status.block_hash()); - let finalized_hash = self - .get_block(&finalized_root) - .and_then(|b| b.execution_status.block_hash()); + let justified_hash = self.get_block(&justified_root).and_then(|b| { + b.execution_status + .block_hash() + .or(b.execution_payload_parent_hash) + }); + let finalized_hash = self.get_block(&finalized_root).and_then(|b| { + b.execution_status + .block_hash() + .or(b.execution_payload_parent_hash) + }); self.forkchoice_update_parameters = ForkchoiceUpdateParameters { head_root, head_hash, From 330348ea14bd58828564005795f99ffe874bc7c1 Mon Sep 17 00:00:00 2001 From: jking-aus <72330194+jking-aus@users.noreply.github.com> Date: Fri, 1 May 2026 14:44:25 +0200 Subject: [PATCH 160/189] fix: prevent duplicate column reconstruction dispatch (#9250) Fixes a flaky CI failure in `data_column_reconstruction_at_deadline` where 2 `column_reconstruction` events are emitted instead of the expected 1. - Change `queued_column_reconstructions` from `HashMap` to `HashMap>`, where `None` indicates reconstruction was already dispatched. - On dispatch (`ReadyColumnReconstruction`), set the entry to `None` instead of removing it. This prevents a subsequent gossip column from inserting a fresh reconstruction request into the now-vacant slot. - Prune stale `None` entries on each dispatch to keep the map bounded. Co-Authored-By: Josh King --- .../src/scheduler/work_reprocessing_queue.rs | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs index 38306b3bb6..b1fa56af01 100644 --- a/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs +++ b/beacon_node/beacon_processor/src/scheduler/work_reprocessing_queue.rs @@ -280,8 +280,8 @@ struct ReprocessQueue { queued_lc_updates: FnvHashMap, /// Light Client Updates per parent_root. awaiting_lc_updates_per_parent_root: HashMap>, - /// Column reconstruction per block root. - queued_column_reconstructions: HashMap, + /// Column reconstruction per block root. `None` means reconstruction was already dispatched. + queued_column_reconstructions: HashMap>, /// Queued backfill batches queued_backfill_batches: Vec, @@ -865,20 +865,20 @@ impl ReprocessQueue { && duration_from_current_slot >= reconstruction_deadline && current_slot == request.slot { - // If we are at least `reconstruction_deadline` seconds into the current slot, - // and the reconstruction request is for the current slot, process reconstruction immediately. reconstruction_delay = Duration::from_secs(0); } match self.queued_column_reconstructions.entry(request.block_root) { - Entry::Occupied(key) => { - self.column_reconstructions_delay_queue - .reset(key.get(), reconstruction_delay); + Entry::Occupied(entry) => { + if let Some(delay_key) = entry.get() { + self.column_reconstructions_delay_queue + .reset(delay_key, reconstruction_delay); + } } Entry::Vacant(vacant) => { let delay_key = self .column_reconstructions_delay_queue .insert(request, reconstruction_delay); - vacant.insert(delay_key); + vacant.insert(Some(delay_key)); } } } @@ -1039,7 +1039,9 @@ impl ReprocessQueue { } InboundEvent::ReadyColumnReconstruction(column_reconstruction) => { self.queued_column_reconstructions - .remove(&column_reconstruction.block_root); + .retain(|_, v| v.is_some()); + self.queued_column_reconstructions + .insert(column_reconstruction.block_root, None); if self .ready_work_tx .try_send(ReadyWork::ColumnReconstruction(column_reconstruction)) @@ -1398,7 +1400,10 @@ mod tests { queue.handle_message(InboundEvent::ReadyColumnReconstruction(reconstruction)); } - assert!(queue.queued_column_reconstructions.is_empty()); + assert_eq!( + queue.queued_column_reconstructions.get(&block_root), + Some(&None) + ); } /// Tests that column reconstruction queued after the deadline is triggered immediately From ee61aee659b82432fc111d4fae5c9fe1af4938a0 Mon Sep 17 00:00:00 2001 From: Mac L Date: Sun, 3 May 2026 15:10:19 +0400 Subject: [PATCH 161/189] Unblock CI by temporarily ignoring `hickory-proto` audit failures (#9257) Two audit failures for `hickory-proto` which is used upstream in `libp2p-dns` and `libp2p-mdns` - https://rustsec.org/advisories/RUSTSEC-2026-0118.html - https://rustsec.org/advisories/RUSTSEC-2026-0119.html Tracking Issue: https://github.com/sigp/lighthouse/issues/9258 Since RUSTSEC-2026-0118 does not even have any non-patched versions available and RUSTSEC-2026-0119 requires a major version bump I think we would need to wait on a release from libp2p in both cases. So for now, add an ignore for each so we can at least unblock CI Co-Authored-By: Mac L --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 04973193ec..dd57bb038e 100644 --- a/Makefile +++ b/Makefile @@ -330,7 +330,7 @@ install-audit: cargo install --force cargo-audit audit-CI: - cargo audit --ignore RUSTSEC-2026-0049 --ignore RUSTSEC-2026-0098 --ignore RUSTSEC-2026-0099 --ignore RUSTSEC-2026-0104 + cargo audit --ignore RUSTSEC-2026-0049 --ignore RUSTSEC-2026-0098 --ignore RUSTSEC-2026-0099 --ignore RUSTSEC-2026-0104 --ignore RUSTSEC-2026-0118 --ignore RUSTSEC-2026-0119 # Runs cargo deny (check for banned crates, duplicate versions, and source restrictions) deny: install-deny deny-CI From 9cf155a0ddb5eeeb9026e8e946c1f5da5e3ba6c4 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Mon, 4 May 2026 13:33:09 +0200 Subject: [PATCH 162/189] Implement gloas proposer preference vc duty (#9208) Allow for the vc to submit its proposer preferences to the network Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Jimmy Chen --- .../src/payload_bid_verification/tests.rs | 2 +- .../gossip_verified_proposer_preferences.rs | 6 +- .../proposer_preference_cache.rs | 32 ++- .../tests.rs | 11 +- beacon_node/http_api/src/lib.rs | 22 +- beacon_node/http_api/src/validator/mod.rs | 124 +++++++++- beacon_node/http_api/tests/tests.rs | 182 ++++++++++++++- .../lighthouse_network/src/types/pubsub.rs | 4 +- .../src/network_beacon_processor/mod.rs | 8 +- common/eth2/src/lib.rs | 42 +++- .../lighthouse_validator_store/src/lib.rs | 39 +++- validator_client/signing_method/src/lib.rs | 5 + .../signing_method/src/web3signer.rs | 3 + validator_client/src/lib.rs | 18 ++ .../validator_services/src/lib.rs | 1 + .../src/proposer_preferences_service.rs | 221 ++++++++++++++++++ validator_client/validator_store/src/lib.rs | 15 +- 17 files changed, 694 insertions(+), 41 deletions(-) create mode 100644 validator_client/validator_services/src/proposer_preferences_service.rs diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index b7b77d5d2a..c68e6d9d32 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -252,11 +252,11 @@ fn make_signed_preferences( ) -> Arc { Arc::new(SignedProposerPreferences { message: ProposerPreferences { + dependent_root: Hash256::ZERO, proposal_slot, validator_index, fee_recipient, gas_limit, - ..ProposerPreferences::default() }, signature: Signature::empty(), }) diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs index cc77453c49..4ba33fde72 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs @@ -154,7 +154,9 @@ impl BeaconChain { #[cfg(test)] mod tests { - use types::{Address, BeaconState, EthSpec, MinimalEthSpec, ProposerPreferences, Slot}; + use types::{ + Address, BeaconState, EthSpec, Hash256, MinimalEthSpec, ProposerPreferences, Slot, + }; use super::verify_preferences_consistency; use crate::proposer_preferences_verification::ProposerPreferencesError; @@ -163,7 +165,7 @@ mod tests { fn make_preferences(proposal_slot: Slot, validator_index: u64) -> ProposerPreferences { ProposerPreferences { - dependent_root: types::Hash256::ZERO, + dependent_root: Hash256::ZERO, proposal_slot, validator_index, fee_recipient: Address::ZERO, diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs index 507e61dc10..7bbdf34888 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs @@ -70,20 +70,24 @@ mod tests { use std::sync::Arc; use bls::Signature; - use types::{Address, ProposerPreferences, SignedProposerPreferences, Slot}; + use types::{Address, Hash256, ProposerPreferences, SignedProposerPreferences, Slot}; use super::GossipVerifiedProposerPreferenceCache; use crate::proposer_preferences_verification::gossip_verified_proposer_preferences::GossipVerifiedProposerPreferences; - fn make_gossip_verified(slot: Slot, validator_index: u64) -> GossipVerifiedProposerPreferences { + fn make_gossip_verified( + slot: Slot, + validator_index: u64, + dependent_root: Hash256, + ) -> GossipVerifiedProposerPreferences { GossipVerifiedProposerPreferences { signed_preferences: Arc::new(SignedProposerPreferences { message: ProposerPreferences { + dependent_root, proposal_slot: slot, validator_index, fee_recipient: Address::ZERO, gas_limit: 30_000_000, - ..ProposerPreferences::default() }, signature: Signature::empty(), }), @@ -93,9 +97,10 @@ mod tests { #[test] fn prune_removes_old_retains_current() { let cache = GossipVerifiedProposerPreferenceCache::default(); + let root = Hash256::ZERO; for slot in [1, 2, 3, 7, 8, 9, 10] { - let verified = make_gossip_verified(Slot::new(slot), slot); + let verified = make_gossip_verified(Slot::new(slot), slot, root); cache.insert_seen_validator(&verified); cache.insert_preferences(verified); } @@ -104,11 +109,26 @@ mod tests { for slot in [1, 2, 3, 7] { assert!(cache.get_preferences(&Slot::new(slot)).is_none()); - assert!(!cache.get_seen_validator(&Slot::new(slot), types::Hash256::ZERO, slot)); + assert!(!cache.get_seen_validator(&Slot::new(slot), root, slot)); } for slot in [8, 9, 10] { assert!(cache.get_preferences(&Slot::new(slot)).is_some()); - assert!(cache.get_seen_validator(&Slot::new(slot), types::Hash256::ZERO, slot)); + assert!(cache.get_seen_validator(&Slot::new(slot), root, slot)); } } + + #[test] + fn different_dependent_roots_not_deduped() { + let cache = GossipVerifiedProposerPreferenceCache::default(); + let slot = Slot::new(5); + let root_a = Hash256::repeat_byte(0xaa); + let root_b = Hash256::repeat_byte(0xbb); + let validator_index = 42; + + let verified_a = make_gossip_verified(slot, validator_index, root_a); + cache.insert_seen_validator(&verified_a); + + assert!(cache.get_seen_validator(&slot, root_a, validator_index)); + assert!(!cache.get_seen_validator(&slot, root_b, validator_index)); + } } diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs index ce2ea12bb5..468e08ff3b 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs @@ -127,11 +127,11 @@ fn make_signed_preferences( ) -> Arc { Arc::new(SignedProposerPreferences { message: ProposerPreferences { + dependent_root: Hash256::ZERO, proposal_slot, validator_index, fee_recipient: Address::ZERO, gas_limit: 30_000_000, - ..ProposerPreferences::default() }, signature: Signature::empty(), }) @@ -231,11 +231,10 @@ fn correct_proposer_bad_signature() { result, Err(ProposerPreferencesError::BadSignature) )); - assert!(!ctx.preferences_cache.get_seen_validator( - &slot, - types::Hash256::ZERO, - actual_proposer - )); + assert!( + !ctx.preferences_cache + .get_seen_validator(&slot, Hash256::ZERO, actual_proposer) + ); assert!(ctx.preferences_cache.get_preferences(&slot).is_none()); } diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index b2d069f384..f31817c5ba 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -1490,7 +1490,7 @@ pub fn serve( // POST beacon/pool/payload_attestations let post_beacon_pool_payload_attestations = post_beacon_pool_payload_attestations( &network_tx_filter, - optional_consensus_version_header_filter, + optional_consensus_version_header_filter.clone(), &beacon_pool_path, ); @@ -1510,6 +1510,22 @@ pub fn serve( let post_beacon_pool_bls_to_execution_changes = post_beacon_pool_bls_to_execution_changes(&network_tx_filter, &beacon_pool_path); + // POST validator/proposer_preferences (JSON) + let post_validator_proposer_preferences = post_validator_proposer_preferences( + eth_v1.clone(), + task_spawner_filter.clone(), + chain_filter.clone(), + network_tx_filter.clone(), + ); + + // POST validator/proposer_preferences (SSZ) + let post_validator_proposer_preferences_ssz = post_validator_proposer_preferences_ssz( + eth_v1.clone(), + task_spawner_filter.clone(), + chain_filter.clone(), + network_tx_filter.clone(), + ); + // POST beacon/execution_payload_envelope let post_beacon_execution_payload_envelope = post_beacon_execution_payload_envelope( eth_v1.clone(), @@ -3416,7 +3432,8 @@ pub fn serve( .uor(post_beacon_blinded_blocks_ssz) .uor(post_beacon_blinded_blocks_v2_ssz) .uor(post_beacon_execution_payload_envelope_ssz) - .uor(post_beacon_pool_payload_attestations_ssz), + .uor(post_beacon_pool_payload_attestations_ssz) + .uor(post_validator_proposer_preferences_ssz), ) .uor(post_beacon_blocks) .uor(post_beacon_blinded_blocks) @@ -3429,6 +3446,7 @@ pub fn serve( .uor(post_beacon_pool_sync_committees) .uor(post_beacon_pool_payload_attestations) .uor(post_beacon_pool_bls_to_execution_changes) + .uor(post_validator_proposer_preferences) .uor(post_beacon_execution_payload_envelope) .uor(post_beacon_state_validators) .uor(post_beacon_state_validator_balances) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 27fe5de6e7..044f2089ce 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -9,8 +9,11 @@ use crate::utils::{ use crate::version::{V1, V2, V3, unsupported_version_rejection}; use crate::{StateId, attester_duties, proposer_duties, ptc_duties, sync_committees}; use beacon_chain::attestation_verification::VerifiedAttestation; +use beacon_chain::proposer_preferences_verification::ProposerPreferencesError; use beacon_chain::{AttestationError, BeaconChain, BeaconChainError, BeaconChainTypes}; use bls::PublicKeyBytes; +use bytes::Bytes; +use eth2::CONSENSUS_VERSION_HEADER; use eth2::types::{ Accept, BeaconCommitteeSubscription, EndpointVersion, Failure, GenericResponse, StandardLivenessResponseData, StateId as CoreStateId, ValidatorAggregateAttestationQuery, @@ -20,14 +23,15 @@ use lighthouse_network::PubsubMessage; use network::{NetworkMessage, ValidatorSubscriptionMessage}; use reqwest::StatusCode; use slot_clock::SlotClock; +use ssz::Decode; use std::sync::Arc; use tokio::sync::mpsc::{Sender, UnboundedSender}; use tokio::sync::oneshot; use tracing::{debug, error, info, warn}; use types::{ - BeaconState, Epoch, EthSpec, ProposerPreparationData, SignedAggregateAndProof, - SignedContributionAndProof, SignedValidatorRegistrationData, Slot, SyncContributionData, - ValidatorSubscription, + BeaconState, Epoch, EthSpec, ForkName, ProposerPreparationData, SignedAggregateAndProof, + SignedContributionAndProof, SignedProposerPreferences, SignedValidatorRegistrationData, Slot, + SyncContributionData, ValidatorSubscription, }; use warp::{Filter, Rejection, Reply}; use warp_utils::reject::convert_rejection; @@ -1144,3 +1148,117 @@ pub fn get_validator_duties_proposer( ) .boxed() } + +/// POST validator/proposer_preferences (JSON) +pub fn post_validator_proposer_preferences( + eth_v1: EthV1Filter, + task_spawner_filter: TaskSpawnerFilter, + chain_filter: ChainFilter, + network_tx_filter: NetworkTxFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("proposer_preferences")) + .and(warp::path::end()) + .and(warp_utils::json::json()) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(task_spawner_filter) + .and(chain_filter) + .and(network_tx_filter) + .then( + |preferences: Vec, + _fork_name: ForkName, + task_spawner: TaskSpawner, + chain: Arc>, + network_tx: UnboundedSender>| { + task_spawner.blocking_response_task(Priority::P0, move || { + publish_proposer_preferences(&chain, &network_tx, preferences)?; + Ok(warp::reply()) + }) + }, + ) + .boxed() +} + +/// POST validator/proposer_preferences (SSZ) +pub fn post_validator_proposer_preferences_ssz( + eth_v1: EthV1Filter, + task_spawner_filter: TaskSpawnerFilter, + chain_filter: ChainFilter, + network_tx_filter: NetworkTxFilter, +) -> ResponseFilter { + eth_v1 + .and(warp::path("validator")) + .and(warp::path("proposer_preferences")) + .and(warp::path::end()) + .and(warp::body::bytes()) + .and(warp::header::(CONSENSUS_VERSION_HEADER)) + .and(task_spawner_filter) + .and(chain_filter) + .and(network_tx_filter) + .then( + |body_bytes: Bytes, + _fork_name: ForkName, + task_spawner: TaskSpawner, + chain: Arc>, + network_tx: UnboundedSender>| { + task_spawner.blocking_response_task(Priority::P0, move || { + let preferences = Vec::::from_ssz_bytes(&body_bytes) + .map_err(|e| { + warp_utils::reject::custom_bad_request(format!("invalid SSZ: {e:?}")) + })?; + publish_proposer_preferences(&chain, &network_tx, preferences)?; + Ok(warp::reply()) + }) + }, + ) + .boxed() +} + +fn publish_proposer_preferences( + chain: &BeaconChain, + network_tx: &UnboundedSender>, + preferences_list: Vec, +) -> Result<(), warp::Rejection> { + let mut failures = vec![]; + let mut num_already_known = 0; + + for (index, preferences) in preferences_list.into_iter().enumerate() { + let validator_index = preferences.message.validator_index; + match chain.verify_proposer_preferences_for_gossip(Arc::new(preferences)) { + Ok(verified) => { + crate::utils::publish_pubsub_message( + network_tx, + PubsubMessage::ProposerPreferences(verified.signed_preferences), + )?; + } + Err(ProposerPreferencesError::AlreadySeen { .. }) => { + num_already_known += 1; + } + Err(e) => { + error!( + error = ?e, + %validator_index, + "Failure verifying proposer preferences for gossip" + ); + failures.push(Failure::new(index, format!("{e:?}"))); + } + } + } + + if num_already_known > 0 { + debug!( + count = num_already_known, + "Some proposer preferences already known" + ); + } + + if failures.is_empty() { + Ok(()) + } else { + Err(warp_utils::reject::indexed_bad_request( + "error processing proposer preferences".to_string(), + failures, + )) + } +} diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 7d351e9331..d6c621f996 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -48,9 +48,10 @@ use tokio::time::Duration; use tree_hash::TreeHash; use types::ApplicationDomain; use types::{ - Domain, EthSpec, ExecutionBlockHash, Hash256, MainnetEthSpec, RelativeEpoch, SelectionProof, - SignedExecutionPayloadEnvelope, SignedRoot, SingleAttestation, Slot, - attestation::AttestationBase, consts::gloas::BUILDER_INDEX_SELF_BUILD, + Address, Domain, EthSpec, ExecutionBlockHash, Hash256, MainnetEthSpec, ProposerPreferences, + RelativeEpoch, SelectionProof, SignedExecutionPayloadEnvelope, SignedProposerPreferences, + SignedRoot, SingleAttestation, Slot, attestation::AttestationBase, + consts::gloas::BUILDER_INDEX_SELF_BUILD, }; type E = MainnetEthSpec; @@ -2898,6 +2899,162 @@ impl ApiTester { self } + fn make_valid_signed_proposer_preferences( + &self, + slot_offset: usize, + ) -> SignedProposerPreferences { + let head = self.chain.head_snapshot(); + let head_slot = head.beacon_block.slot(); + let head_state = &head.beacon_state; + let genesis_validators_root = self.chain.genesis_validators_root; + + let proposer_lookahead = head_state + .proposer_lookahead() + .expect("should get proposer_lookahead"); + + // Pick a future slot in the next epoch to ensure it's always valid. + // The lookahead covers 2 epochs: index = epoch_offset * slots_per_epoch + slot_in_epoch. + let slots_per_epoch = E::slots_per_epoch() as usize; + let next_epoch = head_slot.epoch(E::slots_per_epoch()) + 1; + let next_epoch_start = next_epoch.start_slot(E::slots_per_epoch()); + let proposal_slot = next_epoch_start + Slot::new((slot_offset % slots_per_epoch) as u64); + + let lookahead_index = slots_per_epoch + (slot_offset % slots_per_epoch); + let validator_index = *proposer_lookahead + .get(lookahead_index) + .expect("slot index should be in lookahead") as usize; + + let preferences = ProposerPreferences { + dependent_root: Hash256::ZERO, + proposal_slot, + validator_index: validator_index as u64, + fee_recipient: Address::repeat_byte(0xaa), + gas_limit: 30_000_000, + }; + + let epoch = proposal_slot.epoch(E::slots_per_epoch()); + let fork = head_state.fork(); + let domain = self.chain.spec.get_domain( + epoch, + Domain::ProposerPreferences, + &fork, + genesis_validators_root, + ); + let signing_root = preferences.signing_root(domain); + let sk = &self.validator_keypairs()[validator_index].sk; + let signature = sk.sign(signing_root); + + SignedProposerPreferences { + message: preferences, + signature, + } + } + + // Each sub-test uses a unique slot_offset (1-5) because the gossip cache deduplicates on + // (slot, dependent_root, validator_index). Reusing an offset from an earlier test would hit + // "already seen" instead of testing the intended condition. + pub async fn test_post_validator_proposer_preferences_valid(mut self) -> Self { + let signed = self.make_valid_signed_proposer_preferences(1); + let fork_name = self + .chain + .spec + .fork_name_at_slot::(signed.message.proposal_slot); + + self.client + .post_validator_proposer_preferences(&[signed], fork_name) + .await + .unwrap(); + + assert!( + self.network_rx.network_recv.recv().await.is_some(), + "valid proposer preferences should be sent to network" + ); + + self + } + + pub async fn test_post_validator_proposer_preferences_valid_ssz(mut self) -> Self { + let signed = self.make_valid_signed_proposer_preferences(2); + let fork_name = self + .chain + .spec + .fork_name_at_slot::(signed.message.proposal_slot); + + self.client + .post_validator_proposer_preferences_ssz(&vec![signed], fork_name) + .await + .unwrap(); + + assert!( + self.network_rx.network_recv.recv().await.is_some(), + "valid proposer preferences (SSZ) should be sent to network" + ); + + self + } + + pub async fn test_post_validator_proposer_preferences_invalid_sig(self) -> Self { + let mut signed = self.make_valid_signed_proposer_preferences(3); + signed.signature = Signature::empty(); + let fork_name = self + .chain + .spec + .fork_name_at_slot::(signed.message.proposal_slot); + + let result = self + .client + .post_validator_proposer_preferences(&[signed], fork_name) + .await; + + assert!(result.is_err(), "invalid signature should be rejected"); + + self + } + + pub async fn test_post_validator_proposer_preferences_invalid_sig_ssz(self) -> Self { + let mut signed = self.make_valid_signed_proposer_preferences(4); + signed.signature = Signature::empty(); + let fork_name = self + .chain + .spec + .fork_name_at_slot::(signed.message.proposal_slot); + + let result = self + .client + .post_validator_proposer_preferences_ssz(&vec![signed], fork_name) + .await; + + assert!( + result.is_err(), + "invalid signature should be rejected via SSZ route" + ); + + self + } + + pub async fn test_post_validator_proposer_preferences_duplicate(mut self) -> Self { + let signed = self.make_valid_signed_proposer_preferences(5); + let fork_name = self + .chain + .spec + .fork_name_at_slot::(signed.message.proposal_slot); + + // First submission should succeed. + self.client + .post_validator_proposer_preferences(std::slice::from_ref(&signed), fork_name) + .await + .unwrap(); + self.network_rx.network_recv.recv().await; + + // Second submission of the same preferences should return 200 (already known, not an error). + self.client + .post_validator_proposer_preferences(&[signed], fork_name) + .await + .unwrap(); + + self + } + pub async fn test_get_config_fork_schedule(self) -> Self { let result = self.client.get_config_fork_schedule().await.unwrap().data; @@ -9199,3 +9356,22 @@ async fn get_validator_blocks_v3_http_api_path() { .get_validator_blocks_v3_path_graffiti_policy() .await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_validator_proposer_preferences() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .test_post_validator_proposer_preferences_valid() + .await + .test_post_validator_proposer_preferences_valid_ssz() + .await + .test_post_validator_proposer_preferences_invalid_sig() + .await + .test_post_validator_proposer_preferences_invalid_sig_ssz() + .await + .test_post_validator_proposer_preferences_duplicate() + .await; +} diff --git a/beacon_node/lighthouse_network/src/types/pubsub.rs b/beacon_node/lighthouse_network/src/types/pubsub.rs index 9875d4b0c4..e5a703ff1e 100644 --- a/beacon_node/lighthouse_network/src/types/pubsub.rs +++ b/beacon_node/lighthouse_network/src/types/pubsub.rs @@ -51,7 +51,7 @@ pub enum PubsubMessage { /// Gossipsub message providing notification of a signed execution payload bid. ExecutionPayloadBid(Box>), /// Gossipsub message providing notification of signed proposer preferences. - ProposerPreferences(Box), + ProposerPreferences(Arc), /// Gossipsub message providing notification of a light client finality update. LightClientFinalityUpdate(Box>), /// Gossipsub message providing notification of a light client optimistic update. @@ -388,7 +388,7 @@ impl PubsubMessage { GossipKind::ProposerPreferences => { let proposer_preferences = SignedProposerPreferences::from_ssz_bytes(data) .map_err(|e| format!("{:?}", e))?; - Ok(PubsubMessage::ProposerPreferences(Box::new( + Ok(PubsubMessage::ProposerPreferences(Arc::new( proposer_preferences, ))) } diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index bfcff2088b..e089159eb8 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -526,15 +526,11 @@ impl NetworkBeaconProcessor { self: &Arc, message_id: MessageId, peer_id: PeerId, - proposer_preferences: Box, + proposer_preferences: Arc, ) -> Result<(), Error> { let processor = self.clone(); let process_fn = move || { - processor.process_gossip_proposer_preferences( - message_id, - peer_id, - Arc::new(*proposer_preferences), - ) + processor.process_gossip_proposer_preferences(message_id, peer_id, proposer_preferences) }; self.try_send(BeaconWorkEvent { diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index e866547b9f..c314825413 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -46,7 +46,7 @@ use ssz::{Decode, Encode}; use std::fmt; use std::future::Future; use std::time::Duration; -use types::{PayloadAttestationData, PayloadAttestationMessage}; +use types::{PayloadAttestationData, PayloadAttestationMessage, SignedProposerPreferences}; pub const V1: EndpointVersion = EndpointVersion(1); pub const V2: EndpointVersion = EndpointVersion(2); @@ -1849,6 +1849,46 @@ impl BeaconNodeHttpClient { Ok(()) } + /// `POST validator/proposer_preferences` + pub async fn post_validator_proposer_preferences( + &self, + signed_preferences: &[SignedProposerPreferences], + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("proposer_preferences"); + + self.post_generic_with_consensus_version(path, &signed_preferences, None, fork_name) + .await?; + + Ok(()) + } + + /// `POST validator/proposer_preferences` (SSZ) + pub async fn post_validator_proposer_preferences_ssz( + &self, + signed_preferences: &Vec, + fork_name: ForkName, + ) -> Result<(), Error> { + let mut path = self.eth_path(V1)?; + + path.path_segments_mut() + .map_err(|()| Error::InvalidUrl(self.server.clone()))? + .push("validator") + .push("proposer_preferences"); + + let ssz_body = signed_preferences.as_ssz_bytes(); + + self.post_generic_with_consensus_version_and_ssz_body(path, ssz_body, None, fork_name) + .await?; + + Ok(()) + } + /// `POST beacon/rewards/sync_committee` pub async fn post_beacon_rewards_sync_committee( &self, diff --git a/validator_client/lighthouse_validator_store/src/lib.rs b/validator_client/lighthouse_validator_store/src/lib.rs index 1b32777678..cc9729b44d 100644 --- a/validator_client/lighthouse_validator_store/src/lib.rs +++ b/validator_client/lighthouse_validator_store/src/lib.rs @@ -22,11 +22,12 @@ use types::{ AbstractExecPayload, Address, AggregateAndProof, Attestation, BeaconBlock, BlindedPayload, ChainSpec, ContributionAndProof, Domain, Epoch, EthSpec, ExecutionPayloadEnvelope, Fork, FullPayload, Graffiti, Hash256, PayloadAttestationData, PayloadAttestationMessage, - SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, SignedContributionAndProof, - SignedExecutionPayloadEnvelope, SignedRoot, SignedValidatorRegistrationData, - SignedVoluntaryExit, Slot, SyncAggregatorSelectionData, SyncCommitteeContribution, - SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, - VoluntaryExit, graffiti::GraffitiString, + ProposerPreferences, SelectionProof, SignedAggregateAndProof, SignedBeaconBlock, + SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedProposerPreferences, + SignedRoot, SignedValidatorRegistrationData, SignedVoluntaryExit, Slot, + SyncAggregatorSelectionData, SyncCommitteeContribution, SyncCommitteeMessage, + SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, VoluntaryExit, + graffiti::GraffitiString, }; use validator_store::{ AggregateToSign, AttestationToSign, ContributionToSign, DoppelgangerStatus, @@ -1485,4 +1486,32 @@ impl ValidatorStore for LighthouseValidatorS signature, }) } + + async fn sign_proposer_preferences( + &self, + validator_pubkey: PublicKeyBytes, + preferences: ProposerPreferences, + ) -> Result { + let signing_context = self.signing_context( + Domain::ProposerPreferences, + preferences.proposal_slot.epoch(E::slots_per_epoch()), + ); + + let signing_method = self.doppelganger_bypassed_signing_method(validator_pubkey)?; + + let signature = signing_method + .get_signature::>( + SignableMessage::ProposerPreferences(&preferences), + signing_context, + &self.spec, + &self.task_executor, + ) + .await + .map_err(Error::SpecificError)?; + + Ok(SignedProposerPreferences { + message: preferences, + signature, + }) + } } diff --git a/validator_client/signing_method/src/lib.rs b/validator_client/signing_method/src/lib.rs index 2f80fa5761..0dfde98946 100644 --- a/validator_client/signing_method/src/lib.rs +++ b/validator_client/signing_method/src/lib.rs @@ -51,6 +51,7 @@ pub enum SignableMessage<'a, E: EthSpec, Payload: AbstractExecPayload = FullP VoluntaryExit(&'a VoluntaryExit), ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), + ProposerPreferences(&'a ProposerPreferences), } impl> SignableMessage<'_, E, Payload> { @@ -74,6 +75,7 @@ impl> SignableMessage<'_, E, Payload SignableMessage::VoluntaryExit(exit) => exit.signing_root(domain), SignableMessage::ExecutionPayloadEnvelope(e) => e.signing_root(domain), SignableMessage::PayloadAttestationData(d) => d.signing_root(domain), + SignableMessage::ProposerPreferences(p) => p.signing_root(domain), } } } @@ -243,6 +245,9 @@ impl SigningMethod { SignableMessage::PayloadAttestationData(d) => { Web3SignerObject::PayloadAttestationData(d) } + SignableMessage::ProposerPreferences(p) => { + Web3SignerObject::ProposerPreferences(p) + } }; // Determine the Web3Signer message type. diff --git a/validator_client/signing_method/src/web3signer.rs b/validator_client/signing_method/src/web3signer.rs index c2b7e06f92..baabb37947 100644 --- a/validator_client/signing_method/src/web3signer.rs +++ b/validator_client/signing_method/src/web3signer.rs @@ -22,6 +22,7 @@ pub enum MessageType { // TODO(gloas) verify w/ web3signer specs ExecutionPayloadEnvelope, PayloadAttestation, + ProposerPreferences, } #[derive(Debug, PartialEq, Copy, Clone, Serialize)] @@ -80,6 +81,7 @@ pub enum Web3SignerObject<'a, E: EthSpec, Payload: AbstractExecPayload> { ValidatorRegistration(&'a ValidatorRegistrationData), ExecutionPayloadEnvelope(&'a ExecutionPayloadEnvelope), PayloadAttestationData(&'a PayloadAttestationData), + ProposerPreferences(&'a ProposerPreferences), } impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Payload> { @@ -147,6 +149,7 @@ impl<'a, E: EthSpec, Payload: AbstractExecPayload> Web3SignerObject<'a, E, Pa Web3SignerObject::ValidatorRegistration(_) => MessageType::ValidatorRegistration, Web3SignerObject::ExecutionPayloadEnvelope(_) => MessageType::ExecutionPayloadEnvelope, Web3SignerObject::PayloadAttestationData(_) => MessageType::PayloadAttestation, + Web3SignerObject::ProposerPreferences(_) => MessageType::ProposerPreferences, } } } diff --git a/validator_client/src/lib.rs b/validator_client/src/lib.rs index b412db45f6..71d9333493 100644 --- a/validator_client/src/lib.rs +++ b/validator_client/src/lib.rs @@ -47,6 +47,7 @@ use validator_services::{ latency_service, payload_attestation_service::PayloadAttestationService, preparation_service::{PreparationService, PreparationServiceBuilder}, + proposer_preferences_service::ProposerPreferencesService, sync_committee_service::SyncCommitteeService, }; use validator_store::ValidatorStore as ValidatorStoreTrait; @@ -85,6 +86,8 @@ pub struct ProductionValidatorClient { attestation_service: AttestationService, SystemTimeSlotClock>, sync_committee_service: SyncCommitteeService, SystemTimeSlotClock>, payload_attestation_service: PayloadAttestationService, SystemTimeSlotClock>, + proposer_preferences_service: + ProposerPreferencesService, SystemTimeSlotClock>, doppelganger_service: Option>, preparation_service: PreparationService, SystemTimeSlotClock>, validator_store: Arc>, @@ -563,6 +566,15 @@ impl ProductionValidatorClient { context.eth2_config.spec.clone(), ); + let proposer_preferences_service = ProposerPreferencesService::new( + duties_service.clone(), + validator_store.clone(), + slot_clock.clone(), + beacon_nodes.clone(), + context.executor.clone(), + context.eth2_config.spec.clone(), + ); + Ok(Self { context, duties_service, @@ -570,6 +582,7 @@ impl ProductionValidatorClient { attestation_service, sync_committee_service, payload_attestation_service, + proposer_preferences_service, doppelganger_service, preparation_service, validator_store, @@ -646,6 +659,11 @@ impl ProductionValidatorClient { .clone() .start_update_service() .map_err(|e| format!("Unable to start payload attestation service: {}", e))?; + + self.proposer_preferences_service + .clone() + .start_update_service() + .map_err(|e| format!("Unable to start proposer preferences service: {}", e))?; } self.preparation_service diff --git a/validator_client/validator_services/src/lib.rs b/validator_client/validator_services/src/lib.rs index 0169335a7f..c39ef4499b 100644 --- a/validator_client/validator_services/src/lib.rs +++ b/validator_client/validator_services/src/lib.rs @@ -5,5 +5,6 @@ pub mod latency_service; pub mod notifier_service; pub mod payload_attestation_service; pub mod preparation_service; +pub mod proposer_preferences_service; pub mod sync; pub mod sync_committee_service; diff --git a/validator_client/validator_services/src/proposer_preferences_service.rs b/validator_client/validator_services/src/proposer_preferences_service.rs new file mode 100644 index 0000000000..fbefdf5d96 --- /dev/null +++ b/validator_client/validator_services/src/proposer_preferences_service.rs @@ -0,0 +1,221 @@ +use crate::duties_service::DutiesService; +use beacon_node_fallback::BeaconNodeFallback; +use slot_clock::SlotClock; +use std::ops::Deref; +use std::sync::Arc; +use task_executor::TaskExecutor; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; +use types::{ChainSpec, Epoch, EthSpec, ForkName, ProposerPreferences}; +use validator_store::ValidatorStore; + +pub struct Inner { + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + executor: TaskExecutor, + chain_spec: Arc, +} + +pub struct ProposerPreferencesService { + inner: Arc>, +} + +impl Clone for ProposerPreferencesService { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl Deref for ProposerPreferencesService { + type Target = Inner; + + fn deref(&self) -> &Self::Target { + self.inner.deref() + } +} + +impl ProposerPreferencesService { + pub fn new( + duties_service: Arc>, + validator_store: Arc, + slot_clock: T, + beacon_nodes: Arc>, + executor: TaskExecutor, + chain_spec: Arc, + ) -> Self { + Self { + inner: Arc::new(Inner { + duties_service, + validator_store, + slot_clock, + beacon_nodes, + executor, + chain_spec, + }), + } + } + + pub fn start_update_service(self) -> Result<(), String> { + let slot_duration = self.chain_spec.get_slot_duration(); + info!("Proposer preferences service started"); + + let executor = self.executor.clone(); + + let interval_fut = async move { + loop { + let Some(current_slot) = self.slot_clock.now() else { + error!("Failed to read slot clock"); + sleep(slot_duration).await; + continue; + }; + + if !self + .chain_spec + .fork_name_at_slot::(current_slot) + .gloas_enabled() + { + let duration_to_next_epoch = self + .slot_clock + .duration_to_next_epoch(S::E::slots_per_epoch()) + .unwrap_or_else(|| slot_duration * S::E::slots_per_epoch() as u32); + sleep(duration_to_next_epoch).await; + continue; + } + + let current_epoch = current_slot.epoch(S::E::slots_per_epoch()); + let fork_name = self.chain_spec.fork_name_at_slot::(current_slot); + self.publish_proposer_preferences(current_epoch, fork_name) + .await; + + let duration_to_next_epoch = self + .slot_clock + .duration_to_next_epoch(S::E::slots_per_epoch()) + .unwrap_or_else(|| slot_duration * S::E::slots_per_epoch() as u32); + sleep(duration_to_next_epoch).await; + } + }; + + executor.spawn(interval_fut, "proposer_preferences_service"); + Ok(()) + } + + async fn publish_proposer_preferences(&self, current_epoch: Epoch, fork_name: ForkName) { + let (dependent_root, duties) = { + let proposers = self.duties_service.proposers.read(); + match proposers.get(¤t_epoch) { + Some((root, duties)) => (*root, duties.clone()), + None => return, + } + }; + + let preferences_to_sign: Vec<_> = { + let mut result = vec![]; + for duty in &duties { + let Some(proposal_data) = self.validator_store.proposal_data(&duty.pubkey) else { + warn!( + validator = ?duty.pubkey, + "Missing proposal data for proposer preferences" + ); + continue; + }; + let Some(fee_recipient) = proposal_data.fee_recipient else { + warn!( + validator = ?duty.pubkey, + "Missing fee recipient for proposer preferences" + ); + continue; + }; + result.push(( + duty.pubkey, + ProposerPreferences { + dependent_root, + proposal_slot: duty.slot, + validator_index: duty.validator_index, + fee_recipient, + gas_limit: proposal_data.gas_limit, + }, + )); + } + result + }; + + if preferences_to_sign.is_empty() { + return; + } + + debug!( + %current_epoch, + count = preferences_to_sign.len(), + "Signing proposer preferences" + ); + + let mut signed = Vec::with_capacity(preferences_to_sign.len()); + for (pubkey, preferences) in preferences_to_sign { + match self + .validator_store + .sign_proposer_preferences(pubkey, preferences) + .await + { + Ok(signed_prefs) => signed.push(signed_prefs), + Err(e) => { + error!( + error = ?e, + validator = ?pubkey, + "Failed to sign proposer preferences" + ); + } + } + } + + if signed.is_empty() { + return; + } + + let count = signed.len(); + let signed = Arc::new(signed); + let result = self + .beacon_nodes + .first_success(|beacon_node| { + let signed = signed.clone(); + async move { + match beacon_node + .post_validator_proposer_preferences_ssz(&signed, fork_name) + .await + { + Ok(()) => Ok(()), + Err(ssz_err) => { + debug!(error = ?ssz_err, "SSZ publish failed, falling back to JSON"); + beacon_node + .post_validator_proposer_preferences(&signed, fork_name) + .await + .map_err(|e| { + format!("Failed to publish proposer preferences: {e:?}") + }) + } + } + } + }) + .await; + + match result { + Ok(()) => { + info!( + %current_epoch, + %count, + "Successfully published proposer preferences" + ); + } + Err(e) => { + error!( + error = %e, + %current_epoch, + "Failed to publish proposer preferences" + ); + } + } + } +} diff --git a/validator_client/validator_store/src/lib.rs b/validator_client/validator_store/src/lib.rs index 4e5b415a41..d40c7994f1 100644 --- a/validator_client/validator_store/src/lib.rs +++ b/validator_client/validator_store/src/lib.rs @@ -8,10 +8,10 @@ use std::sync::Arc; use types::{ Address, Attestation, AttestationError, BlindedBeaconBlock, Epoch, EthSpec, ExecutionPayloadEnvelope, Graffiti, Hash256, PayloadAttestationData, PayloadAttestationMessage, - SelectionProof, SignedAggregateAndProof, SignedBlindedBeaconBlock, SignedContributionAndProof, - SignedExecutionPayloadEnvelope, SignedValidatorRegistrationData, Slot, - SyncCommitteeContribution, SyncCommitteeMessage, SyncSelectionProof, SyncSubnetId, - ValidatorRegistrationData, + ProposerPreferences, SelectionProof, SignedAggregateAndProof, SignedBlindedBeaconBlock, + SignedContributionAndProof, SignedExecutionPayloadEnvelope, SignedProposerPreferences, + SignedValidatorRegistrationData, Slot, SyncCommitteeContribution, SyncCommitteeMessage, + SyncSelectionProof, SyncSubnetId, ValidatorRegistrationData, }; #[derive(Debug, PartialEq, Clone)] @@ -213,6 +213,13 @@ pub trait ValidatorStore: Send + Sync { data: PayloadAttestationData, ) -> impl Future>> + Send; + /// Sign a `ProposerPreferences` message. + fn sign_proposer_preferences( + &self, + validator_pubkey: PublicKeyBytes, + preferences: ProposerPreferences, + ) -> impl Future>> + Send; + /// Returns `ProposalData` for the provided `pubkey` if it exists in `InitializedValidators`. /// `ProposalData` fields include defaulting logic described in `get_fee_recipient_defaulting`, /// `get_gas_limit_defaulting`, and `get_builder_proposals_defaulting`. From d9be76afe7647c75dfa150417b3a6938ee77c399 Mon Sep 17 00:00:00 2001 From: jking-aus <72330194+jking-aus@users.noreply.github.com> Date: Tue, 5 May 2026 01:39:33 +0200 Subject: [PATCH 163/189] fix: payload_attestation_data when no block received for slot (#9225) Addresses issue #9220 The `payload_attestation_data` endpoint returns 400 when no block has been received for the requested slot. This causes the VC to log at CRIT level for what is expected behaviour per spec: validators should simply not submit a payload attestation when no block has been seen. - Return 404 (Not Found) instead of 400 from `payload_attestation_data` when no block exists for the slot. This is consistent with other beacon api endpoints. - Downgrade the VC log from `crit` to `debug` when a 503 is received, since this is an expected no-op per spec. - Add `BlockNotFound` rejection type to `warp_utils`. - Add a test asserting the 404 response for an empty slot. Co-Authored-By: Josh King Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Jimmy Chen --- beacon_node/http_api/src/validator/mod.rs | 8 +++- beacon_node/http_api/tests/tests.rs | 38 ++++++++++++++++++- common/eth2/src/lib.rs | 22 +++++++---- common/warp_utils/src/reject.rs | 14 +++++++ .../src/payload_attestation_service.rs | 16 ++++++-- 5 files changed, 83 insertions(+), 15 deletions(-) diff --git a/beacon_node/http_api/src/validator/mod.rs b/beacon_node/http_api/src/validator/mod.rs index 044f2089ce..77df94bc36 100644 --- a/beacon_node/http_api/src/validator/mod.rs +++ b/beacon_node/http_api/src/validator/mod.rs @@ -333,8 +333,12 @@ pub fn get_validator_payload_attestation_data( let payload_attestation_data = chain .produce_payload_attestation_data(slot) .map_err(|e| match e { - BeaconChainError::InvalidSlot(_) - | BeaconChainError::NoBlockForSlot(_) => { + BeaconChainError::NoBlockForSlot(_) => { + warp_utils::reject::block_not_found(format!( + "No block received for slot {slot}" + )) + } + BeaconChainError::InvalidSlot(_) => { warp_utils::reject::custom_bad_request(format!( "Unable to produce payload attestation data: {e:?}" )) diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index d6c621f996..0d6735ff61 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4807,7 +4807,8 @@ impl ApiTester { .client .get_validator_payload_attestation_data(slot) .await - .unwrap(); + .unwrap() + .expect("expected payload attestation data for slot with block"); assert_eq!(response.version(), Some(fork_name)); @@ -4823,7 +4824,8 @@ impl ApiTester { .client .get_validator_payload_attestation_data_ssz(slot) .await - .unwrap(); + .unwrap() + .expect("expected SSZ payload attestation data for slot with block"); assert_eq!(ssz_result, expected); @@ -4894,6 +4896,7 @@ impl ApiTester { .get_validator_payload_attestation_data(slot) .await .unwrap() + .expect("expected payload attestation data for slot with block") .into_data(); assert_eq!(pa_data.beacon_block_root, block_root); @@ -4926,6 +4929,26 @@ impl ApiTester { self } + pub async fn test_get_validator_payload_attestation_data_no_block(self) -> Self { + // Advance the slot clock without producing a block + self.harness.advance_slot(); + let slot = self.chain.slot().unwrap(); + + // Should return None when no block exists for the slot + let result = self + .client + .get_validator_payload_attestation_data(slot) + .await + .unwrap(); + + assert!( + result.is_none(), + "expected None for empty slot, got: {result:?}" + ); + + self + } + #[allow(clippy::await_holding_lock)] // This is a test, so it should be fine. pub async fn test_get_validator_aggregate_attestation_v1(self) -> Self { let attestation = self @@ -8597,6 +8620,17 @@ async fn get_validator_payload_attestation_data_pre_gloas() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_validator_payload_attestation_data_no_block() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + ApiTester::new_with_hard_forks() + .await + .test_get_validator_payload_attestation_data_no_block() + .await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn payload_attestation_present_after_envelope_publish() { ApiTester::new_with_hard_forks() diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index c314825413..becbe550a6 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -3030,10 +3030,11 @@ impl BeaconNodeHttpClient { } /// `GET validator/payload_attestation_data/{slot}` + /// Returns `None` if no block has been received for the requested slot (404). pub async fn get_validator_payload_attestation_data( &self, slot: Slot, - ) -> Result, Error> { + ) -> Result>, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() @@ -3042,16 +3043,23 @@ impl BeaconNodeHttpClient { .push("payload_attestation_data") .push(&slot.to_string()); - self.get_with_timeout(path, self.timeouts.payload_attestation) + let opt_response = self + .get_response(path, |b| b.timeout(self.timeouts.payload_attestation)) .await - .map(BeaconResponse::ForkVersioned) + .optional()?; + + match opt_response { + Some(response) => Ok(Some(BeaconResponse::ForkVersioned(response.json().await?))), + None => Ok(None), + } } /// `GET validator/payload_attestation_data/{slot}` in SSZ format + /// Returns `None` if no block has been received for the requested slot (404). pub async fn get_validator_payload_attestation_data_ssz( &self, slot: Slot, - ) -> Result { + ) -> Result, Error> { let mut path = self.eth_path(V1)?; path.path_segments_mut() @@ -3064,9 +3072,9 @@ impl BeaconNodeHttpClient { .get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.payload_attestation) .await?; - let response_bytes = opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND))?; - - PayloadAttestationData::from_ssz_bytes(&response_bytes).map_err(Error::InvalidSsz) + opt_response + .map(|bytes| PayloadAttestationData::from_ssz_bytes(&bytes).map_err(Error::InvalidSsz)) + .transpose() } /// `GET v1/validator/aggregate_attestation?slot,attestation_data_root` diff --git a/common/warp_utils/src/reject.rs b/common/warp_utils/src/reject.rs index c478870950..b88fd79b23 100644 --- a/common/warp_utils/src/reject.rs +++ b/common/warp_utils/src/reject.rs @@ -110,6 +110,17 @@ pub fn not_synced(msg: String) -> warp::reject::Rejection { warp::reject::custom(NotSynced(msg)) } +/// A 404 Not Found response for when no block has been received for the +/// requested slot. +#[derive(Debug)] +pub struct BlockNotFound(pub String); + +impl Reject for BlockNotFound {} + +pub fn block_not_found(msg: String) -> warp::reject::Rejection { + warp::reject::custom(BlockNotFound(msg)) +} + #[derive(Debug)] pub struct InvalidAuthorization(pub String); @@ -199,6 +210,9 @@ pub async fn handle_rejection(err: warp::Rejection) -> Result() { code = StatusCode::SERVICE_UNAVAILABLE; message = format!("SERVICE_UNAVAILABLE: beacon node is syncing: {}", e.0); + } else if let Some(e) = err.find::() { + code = StatusCode::NOT_FOUND; + message = format!("NOT_FOUND: {}", e.0); } else if let Some(e) = err.find::() { code = StatusCode::FORBIDDEN; message = format!("FORBIDDEN: Invalid auth token: {}", e.0); diff --git a/validator_client/validator_services/src/payload_attestation_service.rs b/validator_client/validator_services/src/payload_attestation_service.rs index 24949edc1f..f41893941f 100644 --- a/validator_client/validator_services/src/payload_attestation_service.rs +++ b/validator_client/validator_services/src/payload_attestation_service.rs @@ -139,14 +139,22 @@ impl PayloadAttestationServ beacon_node .get_validator_payload_attestation_data(slot) .await - .map_err(|e| format!("Failed to get payload attestation data: {e:?}")) - .map(|resp| resp.into_data()) + .map(|opt| opt.map(|resp| resp.into_data())) }) .await { - Ok(data) => data, + Ok(Some(data)) => data, + Ok(None) => { + // Per the consensus spec, validators should not submit a + // payload attestation when no block has been seen for the slot. + debug!( + %slot, + "No block received for slot, skipping payload attestation" + ); + return; + } Err(e) => { - crit!( + error!( error = %e, %slot, "Failed to produce payload attestation data" From 4b314d8e79e4f508192562565500c6b47aeb2766 Mon Sep 17 00:00:00 2001 From: Pawan Dhananjay Date: Tue, 5 May 2026 07:35:06 +0530 Subject: [PATCH 164/189] Remove libssl dependency for cargo udeps (#9263) N/A libssl download seems to be failing on [CI](https://github.com/sigp/lighthouse/actions/runs/25346412432/job/74316275231?pr=9126). This was originally added to unblock CI in https://github.com/sigp/lighthouse/pull/6777, but we may not need this anymore. Co-Authored-By: Pawan Dhananjay --- .github/workflows/test-suite.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index c632042351..9e646af9a7 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -414,10 +414,6 @@ jobs: cache: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Fetch libssl1.1 - run: wget https://nz2.archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2_amd64.deb - - name: Install libssl1.1 - run: sudo dpkg -i libssl1.1_1.1.1f-1ubuntu2_amd64.deb - name: Create Cargo config dir run: mkdir -p .cargo - name: Install custom Cargo config From 3351db1ba892b97f7ad851d366ac2a1921ed527f Mon Sep 17 00:00:00 2001 From: Mac L Date: Tue, 5 May 2026 10:35:57 +0400 Subject: [PATCH 165/189] Remove `TestRandom` (#9006) We have a legacy `TestRandom` trait which generates random types for testing and fuzzing. This function overlaps with `arbitrary` which is used very commonly in the ecosystem. Remove `TestRandom` and generate random type instances using `Arbitrary`. Co-Authored-By: Mac L Co-Authored-By: Michael Sproul --- .github/forbidden-files.txt | 2 + .github/workflows/test-suite.yml | 4 +- Cargo.lock | 30 ++-- Cargo.toml | 2 +- beacon_node/beacon_chain/Cargo.toml | 6 + .../src/data_availability_checker.rs | 30 ++-- .../overflow_lru_cache.rs | 10 +- .../src/naive_aggregation_pool.rs | 8 +- .../beacon_chain/src/observed_aggregates.rs | 6 +- beacon_node/beacon_chain/src/test_utils.rs | 42 +++--- beacon_node/beacon_chain/tests/events.rs | 8 +- beacon_node/beacon_chain/tests/store_tests.rs | 3 +- beacon_node/builder_client/Cargo.toml | 2 + beacon_node/builder_client/src/lib.rs | 6 +- beacon_node/network/Cargo.toml | 3 + .../src/sync/block_sidecar_coupling.rs | 38 +++-- beacon_node/network/src/sync/tests/lookups.rs | 18 +-- beacon_node/network/src/sync/tests/mod.rs | 4 +- common/eth2/Cargo.toml | 4 +- common/eth2/src/types.rs | 105 +++++++------ common/test_random_derive/Cargo.toml | 13 -- common/test_random_derive/src/lib.rs | 59 -------- consensus/state_processing/Cargo.toml | 2 +- .../state_processing/src/verify_operation.rs | 22 +-- consensus/types/Cargo.toml | 4 +- .../src/attestation/aggregate_and_proof.rs | 3 - .../types/src/attestation/attestation.rs | 5 +- .../types/src/attestation/attestation_data.rs | 15 +- consensus/types/src/attestation/checkpoint.rs | 3 - .../src/attestation/indexed_attestation.rs | 14 +- .../indexed_payload_attestation.rs | 4 +- .../src/attestation/participation_flags.rs | 8 +- .../src/attestation/payload_attestation.rs | 4 +- .../attestation/payload_attestation_data.rs | 6 +- .../payload_attestation_message.rs | 4 +- .../src/attestation/pending_attestation.rs | 5 +- .../attestation/signed_aggregate_and_proof.rs | 3 - consensus/types/src/block/beacon_block.rs | 98 +++--------- .../types/src/block/beacon_block_body.rs | 3 - .../types/src/block/beacon_block_header.rs | 6 +- .../types/src/block/signed_beacon_block.rs | 3 - .../src/block/signed_beacon_block_header.rs | 6 +- consensus/types/src/builder/builder.rs | 6 +- consensus/types/src/builder/builder_bid.rs | 10 +- .../src/builder/builder_pending_payment.rs | 15 +- .../src/builder/builder_pending_withdrawal.rs | 15 +- .../types/src/builder/proposer_preferences.rs | 8 +- .../consolidation/consolidation_request.rs | 6 +- .../consolidation/pending_consolidation.rs | 7 +- consensus/types/src/core/enr_fork_id.rs | 7 +- .../types/src/core/execution_block_hash.rs | 12 +- consensus/types/src/core/graffiti.rs | 9 -- consensus/types/src/core/signing_data.rs | 5 +- consensus/types/src/core/slot_epoch.rs | 6 +- consensus/types/src/core/slot_epoch_macros.rs | 6 - consensus/types/src/data/blob_sidecar.rs | 4 +- .../types/src/data/data_column_sidecar.rs | 3 - .../src/data/partial_data_column_sidecar.rs | 4 +- consensus/types/src/deposit/deposit.rs | 7 +- consensus/types/src/deposit/deposit_data.rs | 6 +- .../types/src/deposit/deposit_message.rs | 4 +- .../types/src/deposit/deposit_request.rs | 7 +- .../src/deposit/deposit_tree_snapshot.rs | 8 +- .../types/src/deposit/pending_deposit.rs | 6 +- .../src/execution/bls_to_execution_change.rs | 6 +- consensus/types/src/execution/eth1_data.rs | 16 +- .../types/src/execution/execution_payload.rs | 3 - .../src/execution/execution_payload_bid.rs | 6 +- .../execution/execution_payload_envelope.rs | 9 +- .../src/execution/execution_payload_header.rs | 3 - .../types/src/execution/execution_requests.rs | 6 +- consensus/types/src/execution/payload.rs | 17 +-- .../signed_bls_to_execution_change.rs | 7 +- .../execution/signed_execution_payload_bid.rs | 4 +- .../signed_execution_payload_envelope.rs | 9 +- .../types/src/exit/signed_voluntary_exit.rs | 7 +- consensus/types/src/exit/voluntary_exit.rs | 6 +- consensus/types/src/fork/fork.rs | 15 +- consensus/types/src/fork/fork_data.rs | 6 +- .../light_client/light_client_bootstrap.rs | 14 +- .../light_client_finality_update.rs | 14 +- .../src/light_client/light_client_header.rs | 14 +- .../light_client_optimistic_update.rs | 14 +- .../src/light_client/light_client_update.rs | 14 +- .../types/src/slashing/attester_slashing.rs | 14 -- .../types/src/slashing/proposer_slashing.rs | 7 +- consensus/types/src/state/beacon_state.rs | 70 +++++---- consensus/types/src/state/historical_batch.rs | 8 +- .../types/src/state/historical_summary.rs | 3 - .../sync_committee/contribution_and_proof.rs | 4 +- .../signed_contribution_and_proof.rs | 4 +- .../src/sync_committee/sync_aggregate.rs | 4 +- .../sync_aggregator_selection_data.rs | 6 +- .../src/sync_committee/sync_committee.rs | 5 +- .../sync_committee_contribution.rs | 6 +- .../sync_committee/sync_committee_message.rs | 4 +- .../generate_random_block_and_blobs.rs | 29 ++-- consensus/types/src/test_utils/macros.rs | 8 +- consensus/types/src/test_utils/mod.rs | 29 +++- .../src/test_utils/test_random/address.rs | 9 -- .../test_random/aggregate_signature.rs | 12 -- .../src/test_utils/test_random/bitfield.rs | 43 ------ .../src/test_utils/test_random/hash256.rs | 9 -- .../test_utils/test_random/kzg_commitment.rs | 9 -- .../src/test_utils/test_random/kzg_proof.rs | 11 -- .../types/src/test_utils/test_random/mod.rs | 15 -- .../src/test_utils/test_random/public_key.rs | 9 -- .../test_random/public_key_bytes.rs | 17 --- .../src/test_utils/test_random/secret_key.rs | 11 -- .../src/test_utils/test_random/signature.rs | 12 -- .../test_utils/test_random/signature_bytes.rs | 16 -- .../src/test_utils/test_random/test_random.rs | 140 ------------------ .../src/test_utils/test_random/uint256.rs | 9 -- consensus/types/src/validator/validator.rs | 6 +- .../withdrawal/pending_partial_withdrawal.rs | 7 +- consensus/types/src/withdrawal/withdrawal.rs | 6 +- .../src/withdrawal/withdrawal_request.rs | 7 +- consensus/types/tests/state.rs | 11 +- crypto/bls/src/macros.rs | 15 +- .../doppelganger_service/Cargo.toml | 2 + .../doppelganger_service/src/lib.rs | 10 +- 121 files changed, 418 insertions(+), 1141 deletions(-) delete mode 100644 common/test_random_derive/Cargo.toml delete mode 100644 common/test_random_derive/src/lib.rs delete mode 100644 consensus/types/src/test_utils/test_random/address.rs delete mode 100644 consensus/types/src/test_utils/test_random/aggregate_signature.rs delete mode 100644 consensus/types/src/test_utils/test_random/bitfield.rs delete mode 100644 consensus/types/src/test_utils/test_random/hash256.rs delete mode 100644 consensus/types/src/test_utils/test_random/kzg_commitment.rs delete mode 100644 consensus/types/src/test_utils/test_random/kzg_proof.rs delete mode 100644 consensus/types/src/test_utils/test_random/mod.rs delete mode 100644 consensus/types/src/test_utils/test_random/public_key.rs delete mode 100644 consensus/types/src/test_utils/test_random/public_key_bytes.rs delete mode 100644 consensus/types/src/test_utils/test_random/secret_key.rs delete mode 100644 consensus/types/src/test_utils/test_random/signature.rs delete mode 100644 consensus/types/src/test_utils/test_random/signature_bytes.rs delete mode 100644 consensus/types/src/test_utils/test_random/test_random.rs delete mode 100644 consensus/types/src/test_utils/test_random/uint256.rs diff --git a/.github/forbidden-files.txt b/.github/forbidden-files.txt index 8649fbb574..1c5e9acab9 100644 --- a/.github/forbidden-files.txt +++ b/.github/forbidden-files.txt @@ -12,4 +12,6 @@ beacon_node/http_api/src/block_rewards.rs common/eth2/src/lighthouse/attestation_performance.rs common/eth2/src/lighthouse/block_packing_efficiency.rs common/eth2/src/lighthouse/block_rewards.rs +common/test_random_derive/ consensus/types/src/execution/state_payload_status.rs +consensus/types/src/test_utils/test_random/ diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 9e646af9a7..1d66bd30e7 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -85,8 +85,8 @@ jobs: while IFS= read -r file || [ -n "$file" ]; do # Skip comments and empty lines [[ "$file" =~ ^#.*$ || -z "$file" ]] && continue - if [ -f "$file" ]; then - echo "::error::Forbidden file exists: $file" + if [ -e "$file" ]; then + echo "::error::Forbidden file or directory exists: $file" status=1 fi done < .github/forbidden-files.txt diff --git a/Cargo.lock b/Cargo.lock index aefd51a950..078f699f3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1224,6 +1224,8 @@ name = "beacon_chain" version = "0.2.0" dependencies = [ "alloy-primitives", + "arbitrary", + "beacon_chain", "bitvec", "bls", "criterion", @@ -1258,6 +1260,7 @@ dependencies = [ "parking_lot", "proto_array", "rand 0.9.2", + "rand_xorshift 0.4.0", "rayon", "safe_arith", "sensitive_url", @@ -1610,6 +1613,7 @@ dependencies = [ name = "builder_client" version = "0.1.0" dependencies = [ + "arbitrary", "bls", "context_deserialize", "eth2", @@ -1621,6 +1625,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "types", ] [[package]] @@ -2740,6 +2745,7 @@ dependencies = [ name = "doppelganger_service" version = "0.1.0" dependencies = [ + "arbitrary", "beacon_node_fallback", "bls", "environment", @@ -3116,6 +3122,7 @@ dependencies = [ name = "eth2" version = "0.1.0" dependencies = [ + "arbitrary", "bls", "context_deserialize", "educe", @@ -3132,7 +3139,6 @@ dependencies = [ "multiaddr", "pretty_reqwest_error", "proto_array", - "rand 0.9.2", "reqwest", "reqwest-eventsource", "sensitive_url", @@ -3140,7 +3146,6 @@ dependencies = [ "serde_json", "ssz_types", "superstruct", - "test_random_derive", "tokio", "types", "zeroize", @@ -3277,9 +3282,9 @@ dependencies = [ [[package]] name = "ethereum_ssz" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2128a84f7a3850d54ee343334e3392cca61f9f6aa9441eec481b9394b43c238b" +checksum = "368a4a4e4273b0135111fe9464e35465067766a8f664615b5a86338b73864407" dependencies = [ "alloy-primitives", "arbitrary", @@ -3294,9 +3299,9 @@ dependencies = [ [[package]] name = "ethereum_ssz_derive" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd596f91cff004fc8d02be44c21c0f9b93140a04b66027ae052f5f8e05b48eba" +checksum = "f2cd82c68120c89361e1a457245cf212f7d9f541bffaffed530c8f2d54a160b2" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -6053,6 +6058,7 @@ dependencies = [ "alloy-primitives", "alloy-rlp", "anyhow", + "arbitrary", "async-channel 1.9.0", "beacon_chain", "beacon_processor", @@ -8428,7 +8434,7 @@ dependencies = [ "safe_arith", "smallvec", "ssz_types", - "test_random_derive", + "state_processing", "tokio", "tracing", "tree_hash", @@ -8713,14 +8719,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" -[[package]] -name = "test_random_derive" -version = "0.2.0" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -9361,12 +9359,12 @@ dependencies = [ "superstruct", "swap_or_not_shuffle", "tempfile", - "test_random_derive", "tokio", "tracing", "tree_hash", "tree_hash_derive", "typenum", + "types", "yaml_serde", ] diff --git a/Cargo.toml b/Cargo.toml index 1f58c322f1..71398530fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,6 @@ members = [ "common/system_health", "common/target_check", "common/task_executor", - "common/test_random_derive", "common/tracing_samplers", "common/validator_dir", "common/warp_utils", @@ -200,6 +199,7 @@ proto_array = { path = "consensus/proto_array" } quote = "1" r2d2 = "0.8" rand = "0.9.0" +rand_xorshift = "0.4.0" rayon = "1.7" regex = "1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "stream", "rustls-tls"] } diff --git a/beacon_node/beacon_chain/Cargo.toml b/beacon_node/beacon_chain/Cargo.toml index a06db8934b..47ef4d7a03 100644 --- a/beacon_node/beacon_chain/Cargo.toml +++ b/beacon_node/beacon_chain/Cargo.toml @@ -16,9 +16,11 @@ participation_metrics = [] fork_from_env = [] portable = ["bls/supranational-portable"] test_backfill = [] +arbitrary = ["dep:arbitrary", "types/arbitrary"] [dependencies] alloy-primitives = { workspace = true } +arbitrary = { workspace = true, optional = true } bitvec = { workspace = true } bls = { workspace = true } educe = { workspace = true } @@ -74,11 +76,15 @@ types = { workspace = true } zstd = { workspace = true } [dev-dependencies] +arbitrary = { workspace = true } +beacon_chain = { path = ".", features = ["arbitrary"] } criterion = { workspace = true } maplit = { workspace = true } mockall = { workspace = true } mockall_double = { workspace = true } +rand_xorshift = { workspace = true } serde_json = { workspace = true } +types = { workspace = true, features = ["arbitrary"] } [[bench]] name = "benches" diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index 9d8b76aaed..f0fa9c7794 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -1041,8 +1041,6 @@ mod test { EphemeralHarnessType, NumBlobs, generate_data_column_indices_rand_order, generate_rand_block_and_data_columns, get_kzg, }; - use rand::SeedableRng; - use rand::prelude::StdRng; use slot_clock::{SlotClock, TestingSlotClock}; use std::collections::HashSet; use std::sync::Arc; @@ -1061,7 +1059,7 @@ mod test { fn should_exclude_rpc_columns_not_required_for_sampling() { // SETUP let spec = Arc::new(ForkName::Fulu.make_genesis_spec(E::default_spec())); - let mut rng = StdRng::seed_from_u64(0xDEADBEEF0BAD5EEDu64); + let mut u = types::test_utils::test_unstructured(); let da_checker = new_da_checker(spec.clone()); let custody_context = &da_checker.custody_context; @@ -1093,9 +1091,10 @@ mod test { let (_, data_columns) = generate_rand_block_and_data_columns::( ForkName::Fulu, NumBlobs::Number(1), - &mut rng, + &mut u, &spec, - ); + ) + .unwrap(); let block_root = Hash256::random(); // Get 10 columns using the "latest" CGC (head) that block lookup would use. // The CGC change becomes effective after CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS, @@ -1147,7 +1146,7 @@ mod test { fn should_exclude_gossip_columns_not_required_for_sampling() { // SETUP let spec = Arc::new(ForkName::Fulu.make_genesis_spec(E::default_spec())); - let mut rng = StdRng::seed_from_u64(0xDEADBEEF0BAD5EEDu64); + let mut u = types::test_utils::test_unstructured(); let da_checker = new_da_checker(spec.clone()); let custody_context = &da_checker.custody_context; @@ -1180,9 +1179,10 @@ mod test { let (_, data_columns) = generate_rand_block_and_data_columns::( ForkName::Fulu, NumBlobs::Number(1), - &mut rng, + &mut u, &spec, - ); + ) + .unwrap(); let block_root = Hash256::random(); // Get 10 columns using the "latest" CGC that gossip subscriptions would use. // The CGC change becomes effective after CUSTODY_CHANGE_DA_EFFECTIVE_DELAY_SECONDS, @@ -1230,7 +1230,7 @@ mod test { #[test] fn verify_kzg_for_range_sync_blocks_should_not_truncate_data_columns_fulu() { let spec = Arc::new(ForkName::Fulu.make_genesis_spec(E::default_spec())); - let mut rng = StdRng::seed_from_u64(0xDEADBEEF0BAD5EEDu64); + let mut u = types::test_utils::test_unstructured(); let da_checker = new_da_checker(spec.clone()); // GIVEN multiple RPC blocks with data columns totalling more than 128 @@ -1239,9 +1239,10 @@ mod test { let (block, data_columns) = generate_rand_block_and_data_columns::( ForkName::Fulu, NumBlobs::Number(1), - &mut rng, + &mut u, &spec, - ); + ) + .unwrap(); let custody_columns = if index == 0 { // 128 valid data columns in the first block @@ -1293,7 +1294,7 @@ mod test { fn should_exclude_reconstructed_columns_not_required_for_sampling() { // SETUP let spec = Arc::new(ForkName::Fulu.make_genesis_spec(E::default_spec())); - let mut rng = StdRng::seed_from_u64(0xDEADBEEF0BAD5EEDu64); + let mut u = types::test_utils::test_unstructured(); let da_checker = new_da_checker(spec.clone()); let custody_context = &da_checker.custody_context; @@ -1314,9 +1315,10 @@ mod test { let (block, data_columns) = generate_rand_block_and_data_columns::( ForkName::Fulu, NumBlobs::Number(1), - &mut rng, + &mut u, &spec, - ); + ) + .unwrap(); let block_root = Hash256::random(); // Add the block to the DA checker da_checker diff --git a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs index 8f1d4464e1..7d1bba2de9 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs @@ -1077,13 +1077,11 @@ mod pending_components_tests { use crate::PayloadVerificationOutcome; use crate::block_verification_types::BlockImportData; use crate::test_utils::{NumBlobs, generate_rand_block_and_blobs, test_spec}; + use arbitrary::Arbitrary; use fixed_bytes::FixedBytesExtended; use fork_choice::PayloadVerificationStatus; use kzg::KzgCommitment; - use rand::SeedableRng; - use rand::rngs::StdRng; use state_processing::ConsensusContext; - use types::test_utils::TestRandom; use types::{BeaconState, ForkName, MainnetEthSpec, SignedBeaconBlock, Slot}; type E = MainnetEthSpec; @@ -1096,10 +1094,10 @@ mod pending_components_tests { ); pub fn pre_setup() -> Setup { - let mut rng = StdRng::seed_from_u64(0xDEADBEEF0BAD5EEDu64); + let mut u = types::test_utils::test_unstructured(); let spec = test_spec::(); let (block, blobs_vec) = - generate_rand_block_and_blobs::(ForkName::Deneb, NumBlobs::Random, &mut rng); + generate_rand_block_and_blobs::(ForkName::Deneb, NumBlobs::Random, &mut u).unwrap(); let max_len = spec.max_blobs_per_block(block.epoch()) as usize; let mut blobs: RuntimeFixedVector>>> = RuntimeFixedVector::default(max_len); @@ -1115,7 +1113,7 @@ mod pending_components_tests { for (index, blob) in blobs.iter().enumerate() { if let Some(invalid_blob) = blob { let mut blob_copy = invalid_blob.as_ref().clone(); - blob_copy.kzg_commitment = KzgCommitment::random_for_test(&mut rng); + blob_copy.kzg_commitment = KzgCommitment::arbitrary(&mut u).unwrap(); *invalid_blobs.get_mut(index).unwrap() = Some(Arc::new(blob_copy)); } } diff --git a/beacon_node/beacon_chain/src/naive_aggregation_pool.rs b/beacon_node/beacon_chain/src/naive_aggregation_pool.rs index 72080b92da..4d192cb5b9 100644 --- a/beacon_node/beacon_chain/src/naive_aggregation_pool.rs +++ b/beacon_node/beacon_chain/src/naive_aggregation_pool.rs @@ -582,20 +582,20 @@ mod tests { use tree_hash::TreeHash; use types::{ Attestation, AttestationBase, AttestationElectra, Fork, Hash256, SyncCommitteeMessage, - test_utils::{generate_deterministic_keypair, test_random_instance}, + test_utils::{generate_deterministic_keypair, test_arbitrary_instance}, }; type E = types::MainnetEthSpec; fn get_attestation_base(slot: Slot) -> Attestation { - let mut a: AttestationBase = test_random_instance(); + let mut a: AttestationBase = test_arbitrary_instance(); a.data.slot = slot; a.aggregation_bits = BitList::with_capacity(4).expect("should create bitlist"); Attestation::Base(a) } fn get_attestation_electra(slot: Slot) -> Attestation { - let mut a: AttestationElectra = test_random_instance(); + let mut a: AttestationElectra = test_arbitrary_instance(); a.data.slot = slot; a.aggregation_bits = BitList::with_capacity(4).expect("should create bitlist"); a.committee_bits = BitVector::new(); @@ -606,7 +606,7 @@ mod tests { } fn get_sync_contribution(slot: Slot) -> SyncCommitteeContribution { - let mut a: SyncCommitteeContribution = test_random_instance(); + let mut a: SyncCommitteeContribution = test_arbitrary_instance(); a.slot = slot; a.aggregation_bits = BitVector::new(); a diff --git a/beacon_node/beacon_chain/src/observed_aggregates.rs b/beacon_node/beacon_chain/src/observed_aggregates.rs index 7ecd581e85..8d4be693ac 100644 --- a/beacon_node/beacon_chain/src/observed_aggregates.rs +++ b/beacon_node/beacon_chain/src/observed_aggregates.rs @@ -474,12 +474,12 @@ where mod tests { use super::*; use fixed_bytes::FixedBytesExtended; - use types::{AttestationBase, Hash256, test_utils::test_random_instance}; + use types::{AttestationBase, Hash256, test_utils::test_arbitrary_instance}; type E = types::MainnetEthSpec; fn get_attestation(slot: Slot, beacon_block_root: u64) -> Attestation { - let a: AttestationBase = test_random_instance(); + let a: AttestationBase = test_arbitrary_instance(); let mut a = Attestation::Base(a); a.data_mut().slot = slot; a.data_mut().beacon_block_root = Hash256::from_low_u64_be(beacon_block_root); @@ -487,7 +487,7 @@ mod tests { } fn get_sync_contribution(slot: Slot, beacon_block_root: u64) -> SyncCommitteeContribution { - let mut a: SyncCommitteeContribution = test_random_instance(); + let mut a: SyncCommitteeContribution = test_arbitrary_instance(); a.slot = slot; a.beacon_block_root = Hash256::from_low_u64_be(beacon_block_root); a diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 8f437998c7..ca55811a70 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -20,6 +20,8 @@ pub use crate::{ sync_committee_verification::Error as SyncCommitteeError, validator_monitor::{ValidatorMonitor, ValidatorMonitorConfig}, }; +#[cfg(feature = "arbitrary")] +use arbitrary::Arbitrary; use bls::get_withdrawal_credentials; use bls::{ AggregateSignature, Keypair, PublicKey, PublicKeyBytes, SecretKey, Signature, SignatureBytes, @@ -73,7 +75,6 @@ use typenum::U4294967296; use types::attestation::IndexedAttestationBase; use types::data::CustodyIndex; use types::execution::BlockProductionVersion; -use types::test_utils::TestRandom; pub use types::test_utils::generate_deterministic_keypairs; use types::*; @@ -96,7 +97,9 @@ pub const TEST_DATA_COLUMN_SIDECARS_GLOAS_SSZ: &[u8] = pub const DEFAULT_TARGET_AGGREGATORS: u64 = u64::MAX; // Minimum and maximum number of blobs to generate in each slot when using the `NumBlobs::Random` option (default). +#[cfg(feature = "arbitrary")] const DEFAULT_MIN_BLOBS: usize = 1; +#[cfg(feature = "arbitrary")] const DEFAULT_MAX_BLOBS: usize = 2; static KZG: LazyLock> = LazyLock::new(|| { @@ -3741,10 +3744,11 @@ pub enum NumBlobs { None, } +#[cfg(feature = "arbitrary")] macro_rules! add_blob_transactions { - ($message:expr, $payload_type:ty, $num_blobs:expr, $rng:expr, $fork_name:expr) => {{ + ($message:expr, $payload_type:ty, $num_blobs:expr, $u:expr, $fork_name:expr) => {{ let num_blobs = match $num_blobs { - NumBlobs::Random => $rng.random_range(DEFAULT_MIN_BLOBS..=DEFAULT_MAX_BLOBS), + NumBlobs::Random => $u.int_in_range(DEFAULT_MIN_BLOBS..=DEFAULT_MAX_BLOBS)?, NumBlobs::Number(n) => n, NumBlobs::None => 0, }; @@ -3761,28 +3765,30 @@ macro_rules! add_blob_transactions { }}; } +#[cfg(feature = "arbitrary")] +#[allow(clippy::type_complexity)] pub fn generate_rand_block_and_blobs( fork_name: ForkName, num_blobs: NumBlobs, - rng: &mut impl Rng, -) -> (SignedBeaconBlock>, Vec>) { - let inner = map_fork_name!(fork_name, BeaconBlock, <_>::random_for_test(rng)); + u: &mut arbitrary::Unstructured, +) -> arbitrary::Result<(SignedBeaconBlock>, Vec>)> { + let inner = map_fork_name!(fork_name, BeaconBlock, <_>::arbitrary(&mut *u)?); - let mut block = SignedBeaconBlock::from_block(inner, Signature::random_for_test(rng)); + let mut block = SignedBeaconBlock::from_block(inner, Signature::arbitrary(&mut *u)?); let mut blob_sidecars = vec![]; let bundle = match block { SignedBeaconBlock::Deneb(SignedBeaconBlockDeneb { ref mut message, .. - }) => add_blob_transactions!(message, FullPayloadDeneb, num_blobs, rng, fork_name), + }) => add_blob_transactions!(message, FullPayloadDeneb, num_blobs, u, fork_name), SignedBeaconBlock::Electra(SignedBeaconBlockElectra { ref mut message, .. - }) => add_blob_transactions!(message, FullPayloadElectra, num_blobs, rng, fork_name), + }) => add_blob_transactions!(message, FullPayloadElectra, num_blobs, u, fork_name), SignedBeaconBlock::Fulu(SignedBeaconBlockFulu { ref mut message, .. - }) => add_blob_transactions!(message, FullPayloadFulu, num_blobs, rng, fork_name), + }) => add_blob_transactions!(message, FullPayloadFulu, num_blobs, u, fork_name), // TODO(EIP-7732) Add `SignedBeaconBlock::Gloas` variant - _ => return (block, blob_sidecars), + _ => return Ok((block, blob_sidecars)), }; let eth2::types::BlobsBundle { @@ -3807,21 +3813,23 @@ pub fn generate_rand_block_and_blobs( .unwrap(), }); } - (block, blob_sidecars) + Ok((block, blob_sidecars)) } +#[cfg(feature = "arbitrary")] +#[allow(clippy::type_complexity)] pub fn generate_rand_block_and_data_columns( fork_name: ForkName, num_blobs: NumBlobs, - rng: &mut impl Rng, + u: &mut arbitrary::Unstructured, spec: &ChainSpec, -) -> ( +) -> arbitrary::Result<( SignedBeaconBlock>, DataColumnSidecarList, -) { - let (block, _blobs) = generate_rand_block_and_blobs(fork_name, num_blobs, rng); +)> { + let (block, _blobs) = generate_rand_block_and_blobs(fork_name, num_blobs, u)?; let data_columns = generate_data_column_sidecars_from_block(&block, spec); - (block, data_columns) + Ok((block, data_columns)) } /// Generate data column sidecars from pre-computed cells and proofs. diff --git a/beacon_node/beacon_chain/tests/events.rs b/beacon_node/beacon_chain/tests/events.rs index e943514c4e..cd0e700109 100644 --- a/beacon_node/beacon_chain/tests/events.rs +++ b/beacon_node/beacon_chain/tests/events.rs @@ -1,3 +1,4 @@ +use arbitrary::Arbitrary; use beacon_chain::blob_verification::GossipVerifiedBlob; use beacon_chain::data_column_verification::GossipVerifiedDataColumn; use beacon_chain::test_utils::{ @@ -8,7 +9,6 @@ use rand::SeedableRng; use rand::rngs::StdRng; use std::sync::Arc; use types::data::FixedBlobSidecarList; -use types::test_utils::TestRandom; use types::{ BlobSidecar, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, Domain, EthSpec, MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, SignedRoot, Slot, @@ -74,19 +74,19 @@ async fn data_column_sidecar_event_on_process_gossip_data_column() { let mut data_column_event_receiver = event_handler.subscribe_data_column_sidecar(); // build and process a gossip verified data column - let mut rng = StdRng::seed_from_u64(0xDEADBEEF0BAD5EEDu64); + let mut u = types::test_utils::test_unstructured(); let sidecar = { let slot = Slot::new(10); let fork_name = harness.spec.fork_name_at_slot::(slot); // DA checker only accepts sampling columns, so we need to create one with a sampling index. if fork_name.gloas_enabled() { - let mut random_sidecar = DataColumnSidecarGloas::random_for_test(&mut rng); + let mut random_sidecar = DataColumnSidecarGloas::arbitrary(&mut u).unwrap(); let epoch = slot.epoch(E::slots_per_epoch()); random_sidecar.slot = slot; random_sidecar.index = harness.chain.sampling_columns_for_epoch(epoch)[0]; DataColumnSidecar::Gloas(random_sidecar) } else { - let mut random_sidecar = DataColumnSidecarFulu::random_for_test(&mut rng); + let mut random_sidecar = DataColumnSidecarFulu::arbitrary(&mut u).unwrap(); let epoch = slot.epoch(E::slots_per_epoch()); random_sidecar.signed_block_header.message.slot = slot; random_sidecar.index = harness.chain.sampling_columns_for_epoch(epoch)[0]; diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 86adf50995..1576092c81 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -31,7 +31,9 @@ use fork_choice::PayloadStatus; use logging::create_test_tracing_subscriber; use maplit::hashset; use rand::Rng; +use rand::SeedableRng; use rand::rngs::StdRng; +use rand_xorshift::XorShiftRng; use slot_clock::{SlotClock, TestingSlotClock}; use ssz_types::VariableList; use state_processing::{BlockReplayer, state_advance::complete_state_advance}; @@ -50,7 +52,6 @@ use store::{ }; use tempfile::{TempDir, tempdir}; use tracing::info; -use types::test_utils::{SeedableRng, XorShiftRng}; use types::*; // Should ideally be divisible by 3. diff --git a/beacon_node/builder_client/Cargo.toml b/beacon_node/builder_client/Cargo.toml index 09bf3f48b4..a329379160 100644 --- a/beacon_node/builder_client/Cargo.toml +++ b/beacon_node/builder_client/Cargo.toml @@ -16,5 +16,7 @@ serde = { workspace = true } serde_json = { workspace = true } [dev-dependencies] +arbitrary = { workspace = true } mockito = { workspace = true } tokio = { workspace = true } +types = { workspace = true, features = ["arbitrary"] } diff --git a/beacon_node/builder_client/src/lib.rs b/beacon_node/builder_client/src/lib.rs index 7dc0cbfc6d..bd064ca8bf 100644 --- a/beacon_node/builder_client/src/lib.rs +++ b/beacon_node/builder_client/src/lib.rs @@ -540,10 +540,10 @@ impl BuilderHttpClient { #[cfg(test)] mod tests { use super::*; + use arbitrary::Arbitrary; use bls::Signature; use eth2::types::MainnetEthSpec; use eth2::types::builder::{BuilderBid, BuilderBidFulu}; - use eth2::types::test_utils::{SeedableRng, TestRandom, XorShiftRng}; use mockito::{Matcher, Server, ServerGuard}; type E = MainnetEthSpec; @@ -689,12 +689,12 @@ mod tests { } fn fulu_signed_builder_bid() -> ForkVersionedResponse> { - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); ForkVersionedResponse { version: ForkName::Fulu, metadata: EmptyMetadata {}, data: SignedBuilderBid { - message: BuilderBid::Fulu(BuilderBidFulu::random_for_test(rng)), + message: BuilderBid::Fulu(BuilderBidFulu::arbitrary(&mut u).unwrap()), signature: Signature::empty(), }, } diff --git a/beacon_node/network/Cargo.toml b/beacon_node/network/Cargo.toml index 319ea2b149..607f231a66 100644 --- a/beacon_node/network/Cargo.toml +++ b/beacon_node/network/Cargo.toml @@ -49,6 +49,8 @@ typenum = { workspace = true } types = { workspace = true } [dev-dependencies] +arbitrary = { workspace = true } +beacon_chain = { workspace = true, features = ["arbitrary"] } bls = { workspace = true } eth2 = { workspace = true } eth2_network_config = { workspace = true } @@ -62,3 +64,4 @@ rand_08 = { package = "rand", version = "0.8.5" } rand_chacha = "0.9.0" rand_chacha_03 = { package = "rand_chacha", version = "0.3.1" } serde_json = { workspace = true } +types = { workspace = true, features = ["arbitrary"] } diff --git a/beacon_node/network/src/sync/block_sidecar_coupling.rs b/beacon_node/network/src/sync/block_sidecar_coupling.rs index 98cf3e0a1f..f5c0fdb4e5 100644 --- a/beacon_node/network/src/sync/block_sidecar_coupling.rs +++ b/beacon_node/network/src/sync/block_sidecar_coupling.rs @@ -501,10 +501,9 @@ mod tests { DataColumnsByRangeRequestId, DataColumnsByRangeRequester, Id, RangeRequestId, }, }; - use rand::SeedableRng; use std::{collections::HashMap, sync::Arc}; use tracing::Span; - use types::{Epoch, ForkName, MinimalEthSpec as E, SignedBeaconBlock, test_utils::XorShiftRng}; + use types::{Epoch, ForkName, MinimalEthSpec as E, SignedBeaconBlock}; fn components_id() -> ComponentsByRangeRequestId { ComponentsByRangeRequestId { @@ -549,10 +548,11 @@ mod tests { #[test] fn no_blobs_into_responses() { - let mut rng = XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let blocks = (0..4) .map(|_| { - generate_rand_block_and_blobs::(ForkName::Base, NumBlobs::None, &mut rng) + generate_rand_block_and_blobs::(ForkName::Base, NumBlobs::None, &mut u) + .unwrap() .0 .into() }) @@ -574,11 +574,12 @@ mod tests { #[test] fn empty_blobs_into_responses() { - let mut rng = XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let blocks = (0..4) .map(|_| { // Always generate some blobs. - generate_rand_block_and_blobs::(ForkName::Deneb, NumBlobs::Number(3), &mut rng) + generate_rand_block_and_blobs::(ForkName::Deneb, NumBlobs::Number(3), &mut u) + .unwrap() .0 .into() }) @@ -619,15 +620,16 @@ mod tests { .custody_context() .sampling_columns_for_epoch(Epoch::new(0), &spec) .to_vec(); - let mut rng = XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let blocks = (0..4) .map(|_| { generate_rand_block_and_data_columns::( ForkName::Fulu, NumBlobs::Number(1), - &mut rng, + &mut u, &spec, ) + .unwrap() }) .collect::>(); @@ -729,15 +731,16 @@ mod tests { Span::none(), ); - let mut rng = XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let blocks = (0..4) .map(|_| { generate_rand_block_and_data_columns::( ForkName::Fulu, NumBlobs::Number(1), - &mut rng, + &mut u, &spec, ) + .unwrap() }) .collect::>(); @@ -787,15 +790,16 @@ mod tests { .custody_context() .sampling_columns_for_epoch(Epoch::new(0), &spec) .to_vec(); - let mut rng = XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let blocks = (0..2) .map(|_| { generate_rand_block_and_data_columns::( ForkName::Fulu, NumBlobs::Number(1), - &mut rng, + &mut u, &spec, ) + .unwrap() }) .collect::>(); @@ -884,15 +888,16 @@ mod tests { .custody_context() .sampling_columns_for_epoch(Epoch::new(0), &spec) .to_vec(); - let mut rng = XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let blocks = (0..2) .map(|_| { generate_rand_block_and_data_columns::( ForkName::Fulu, NumBlobs::Number(1), - &mut rng, + &mut u, &spec, ) + .unwrap() }) .collect::>(); @@ -999,15 +1004,16 @@ mod tests { .custody_context() .sampling_columns_for_epoch(Epoch::new(0), &spec) .to_vec(); - let mut rng = XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let blocks = (0..1) .map(|_| { generate_rand_block_and_data_columns::( ForkName::Fulu, NumBlobs::Number(1), - &mut rng, + &mut u, &spec, ) + .unwrap() }) .collect::>(); diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index a26996ec5e..d27c92c21a 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -38,7 +38,6 @@ use tracing::info; use types::{ BlobSidecar, BlockImportSource, ColumnIndex, DataColumnSidecar, EthSpec, ForkContext, ForkName, Hash256, MinimalEthSpec as E, SignedBeaconBlock, Slot, - test_utils::{SeedableRng, XorShiftRng}, }; const D: Duration = Duration::new(0, 0); @@ -279,7 +278,6 @@ impl TestRig { // deterministic seed let rng_08 = ::from_seed([0u8; 32]); - let rng = ChaCha20Rng::from_seed([0u8; 32]); init_tracing(); @@ -291,7 +289,7 @@ impl TestRig { sync_rx, sync_rx_queue: vec![], rng_08, - rng, + unstructured: types::test_utils::test_unstructured(), network_globals: beacon_processor.network_globals.clone(), sync_manager: SyncManager::new( chain, @@ -1492,8 +1490,7 @@ impl TestRig { num_blobs: NumBlobs, ) -> (SignedBeaconBlock, Vec>) { let fork_name = self.fork_name; - let rng = &mut self.rng; - generate_rand_block_and_blobs::(fork_name, num_blobs, rng) + generate_rand_block_and_blobs::(fork_name, num_blobs, &mut self.unstructured).unwrap() } pub fn send_sync_message(&mut self, sync_message: SyncMessage) { @@ -1829,16 +1826,17 @@ impl TestRig { } #[test] -fn stable_rng() { - let mut rng = XorShiftRng::from_seed([42; 16]); - let (block, _) = generate_rand_block_and_blobs::(ForkName::Base, NumBlobs::None, &mut rng); +fn stable_arbitrary() { + let mut u = types::test_utils::test_unstructured(); + let (block, _) = + generate_rand_block_and_blobs::(ForkName::Base, NumBlobs::None, &mut u).unwrap(); assert_eq!( block.canonical_root(), Hash256::from_slice( - &hex::decode("adfd2e9e7a7976e8ccaed6eaf0257ed36a5b476732fee63ff44966602fd099ec") + &hex::decode("7348573d99ca404b502e2be790593203a1d899f9cf04f42ec9c5b4975803e3c5") .unwrap() ), - "rng produces a consistent value" + "arbitrary produces a consistent value" ); } diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index 8ffe24dda5..dd8c3ae432 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -11,7 +11,6 @@ use beacon_processor::WorkEvent; use lighthouse_network::rpc::RequestType; use lighthouse_network::service::api_types::{AppRequestId, Id}; use lighthouse_network::{NetworkGlobals, PeerId}; -use rand_chacha::ChaCha20Rng; use slot_clock::ManualSlotClock; use std::collections::{HashMap, HashSet}; use std::fs::OpenOptions; @@ -72,9 +71,8 @@ struct TestRig { network_globals: Arc>, /// Beacon chain harness harness: BeaconChainHarness>, - /// `rng` for generating test blocks and blobs. rng_08: rand_chacha_03::ChaCha20Rng, - rng: ChaCha20Rng, + unstructured: arbitrary::Unstructured<'static>, fork_name: ForkName, /// Blocks that will be used in the test but may not be known to `harness` yet. network_blocks_by_root: HashMap>, diff --git a/common/eth2/Cargo.toml b/common/eth2/Cargo.toml index 974508492a..5e015f2713 100644 --- a/common/eth2/Cargo.toml +++ b/common/eth2/Cargo.toml @@ -38,6 +38,6 @@ types = { workspace = true } zeroize = { workspace = true, optional = true } [dev-dependencies] -rand = { workspace = true } -test_random_derive = { path = "../../common/test_random_derive" } +arbitrary = { workspace = true } tokio = { workspace = true } +types = { workspace = true, features = ["arbitrary"] } diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index e1a1166ba7..dfa0fbd87d 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -26,11 +26,6 @@ use std::sync::Arc; use std::time::Duration; use superstruct::superstruct; -#[cfg(test)] -use test_random_derive::TestRandom; -#[cfg(test)] -use types::test_utils::TestRandom; - // TODO(mac): Temporary module and re-export hack to expose old `consensus/types` via `eth2/types`. pub use crate::beacon_response::*; pub mod beacon_response { @@ -2364,7 +2359,7 @@ pub enum ContentType { Ssz, } -#[cfg_attr(test, derive(TestRandom))] +#[cfg_attr(test, derive(arbitrary::Arbitrary))] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Encode, Decode)] #[serde(bound = "E: EthSpec")] pub struct BlobsBundle { @@ -2470,7 +2465,7 @@ pub struct BlobWrapper { mod test { use std::fmt::Debug; - use types::test_utils::{SeedableRng, TestRandom, XorShiftRng}; + use arbitrary::Arbitrary; use super::*; @@ -2498,13 +2493,16 @@ mod test { assert_eq!(request, deserialized_request); }; - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); for fork_name in ForkName::list_all() { - let signed_beacon_block = - map_fork_name!(fork_name, SignedBeaconBlock, <_>::random_for_test(rng)); + let signed_beacon_block = map_fork_name!( + fork_name, + SignedBeaconBlock, + <_>::arbitrary(&mut u).unwrap() + ); let request = if fork_name.deneb_enabled() && !fork_name.gloas_enabled() { - let kzg_proofs = KzgProofs::::random_for_test(rng); - let blobs = BlobsList::::random_for_test(rng); + let kzg_proofs = KzgProofs::::arbitrary(&mut u).unwrap(); + let blobs = BlobsList::::arbitrary(&mut u).unwrap(); let block_contents = SignedBlockContents { signed_block: Arc::new(signed_beacon_block), kzg_proofs, @@ -2532,12 +2530,15 @@ mod test { }; let mut fork_name = ForkName::Deneb; - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); loop { - let signed_beacon_block = - map_fork_name!(fork_name, SignedBeaconBlock, <_>::random_for_test(rng)); - let kzg_proofs = KzgProofs::::random_for_test(rng); - let blobs = BlobsList::::random_for_test(rng); + let signed_beacon_block = map_fork_name!( + fork_name, + SignedBeaconBlock, + <_>::arbitrary(&mut u).unwrap() + ); + let kzg_proofs = KzgProofs::::arbitrary(&mut u).unwrap(); + let blobs = BlobsList::::arbitrary(&mut u).unwrap(); let block_contents = SignedBlockContents { signed_block: Arc::new(signed_beacon_block), kzg_proofs, @@ -2555,25 +2556,27 @@ mod test { #[test] fn test_execution_payload_execution_payload_deserialize_by_fork() { - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let payloads = [ ExecutionPayload::Bellatrix( - ExecutionPayloadBellatrix::::random_for_test(rng), + ExecutionPayloadBellatrix::::arbitrary(&mut u).unwrap(), + ), + ExecutionPayload::Capella( + ExecutionPayloadCapella::::arbitrary(&mut u).unwrap(), + ), + ExecutionPayload::Deneb( + ExecutionPayloadDeneb::::arbitrary(&mut u).unwrap(), + ), + ExecutionPayload::Electra( + ExecutionPayloadElectra::::arbitrary(&mut u).unwrap(), + ), + ExecutionPayload::Fulu( + ExecutionPayloadFulu::::arbitrary(&mut u).unwrap(), + ), + ExecutionPayload::Gloas( + ExecutionPayloadGloas::::arbitrary(&mut u).unwrap(), ), - ExecutionPayload::Capella(ExecutionPayloadCapella::::random_for_test( - rng, - )), - ExecutionPayload::Deneb(ExecutionPayloadDeneb::::random_for_test( - rng, - )), - ExecutionPayload::Electra(ExecutionPayloadElectra::::random_for_test( - rng, - )), - ExecutionPayload::Fulu(ExecutionPayloadFulu::::random_for_test(rng)), - ExecutionPayload::Gloas(ExecutionPayloadGloas::::random_for_test( - rng, - )), ]; let merged_forks = &ForkName::list_all()[2..]; assert_eq!( @@ -2592,48 +2595,44 @@ mod test { #[test] fn test_execution_payload_and_blobs_deserialize_by_fork() { - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let payloads = [ { - let execution_payload = - ExecutionPayload::Deneb( - ExecutionPayloadDeneb::::random_for_test(rng), - ); - let blobs_bundle = BlobsBundle::random_for_test(rng); + let execution_payload = ExecutionPayload::Deneb( + ExecutionPayloadDeneb::::arbitrary(&mut u).unwrap(), + ); + let blobs_bundle = BlobsBundle::::arbitrary(&mut u).unwrap(); ExecutionPayloadAndBlobs { execution_payload, blobs_bundle, } }, { - let execution_payload = - ExecutionPayload::Electra( - ExecutionPayloadElectra::::random_for_test(rng), - ); - let blobs_bundle = BlobsBundle::random_for_test(rng); + let execution_payload = ExecutionPayload::Electra( + ExecutionPayloadElectra::::arbitrary(&mut u).unwrap(), + ); + let blobs_bundle = BlobsBundle::::arbitrary(&mut u).unwrap(); ExecutionPayloadAndBlobs { execution_payload, blobs_bundle, } }, { - let execution_payload = - ExecutionPayload::Fulu( - ExecutionPayloadFulu::::random_for_test(rng), - ); - let blobs_bundle = BlobsBundle::random_for_test(rng); + let execution_payload = ExecutionPayload::Fulu( + ExecutionPayloadFulu::::arbitrary(&mut u).unwrap(), + ); + let blobs_bundle = BlobsBundle::::arbitrary(&mut u).unwrap(); ExecutionPayloadAndBlobs { execution_payload, blobs_bundle, } }, { - let execution_payload = - ExecutionPayload::Gloas( - ExecutionPayloadGloas::::random_for_test(rng), - ); - let blobs_bundle = BlobsBundle::random_for_test(rng); + let execution_payload = ExecutionPayload::Gloas( + ExecutionPayloadGloas::::arbitrary(&mut u).unwrap(), + ); + let blobs_bundle = BlobsBundle::::arbitrary(&mut u).unwrap(); ExecutionPayloadAndBlobs { execution_payload, blobs_bundle, diff --git a/common/test_random_derive/Cargo.toml b/common/test_random_derive/Cargo.toml deleted file mode 100644 index b38d5ef63a..0000000000 --- a/common/test_random_derive/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "test_random_derive" -version = "0.2.0" -authors = ["thojest "] -edition = { workspace = true } -description = "Procedural derive macros for implementation of TestRandom trait" - -[lib] -proc-macro = true - -[dependencies] -quote = { workspace = true } -syn = { workspace = true } diff --git a/common/test_random_derive/src/lib.rs b/common/test_random_derive/src/lib.rs deleted file mode 100644 index bf57d79aaa..0000000000 --- a/common/test_random_derive/src/lib.rs +++ /dev/null @@ -1,59 +0,0 @@ -use proc_macro::TokenStream; -use quote::quote; -use syn::{DeriveInput, parse_macro_input}; - -/// Returns true if some field has an attribute declaring it should be generated from default (not -/// randomized). -/// -/// The field attribute is: `#[test_random(default)]` -fn should_use_default(field: &syn::Field) -> bool { - field.attrs.iter().any(|attr| { - attr.path().is_ident("test_random") - && matches!(&attr.meta, syn::Meta::List(list) if list.tokens.to_string().replace(' ', "") == "default") - }) -} - -#[proc_macro_derive(TestRandom, attributes(test_random))] -pub fn test_random_derive(input: TokenStream) -> TokenStream { - let derived_input = parse_macro_input!(input as DeriveInput); - let name = &derived_input.ident; - let (impl_generics, ty_generics, where_clause) = &derived_input.generics.split_for_impl(); - - let syn::Data::Struct(struct_data) = &derived_input.data else { - panic!("test_random_derive only supports structs."); - }; - - // Build quotes for fields that should be generated and those that should be built from - // `Default`. - let mut quotes = vec![]; - for field in &struct_data.fields { - match &field.ident { - Some(ident) => { - if should_use_default(field) { - quotes.push(quote! { - #ident: <_>::default(), - }); - } else { - quotes.push(quote! { - #ident: <_>::random_for_test(rng), - }); - } - } - _ => panic!("test_random_derive only supports named struct fields."), - }; - } - - let output = quote! { - impl #impl_generics TestRandom for #name #ty_generics #where_clause { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - Self { - #( - #quotes - )* - } - } - } - }; - - output.into() -} diff --git a/consensus/state_processing/Cargo.toml b/consensus/state_processing/Cargo.toml index ae0af03231..72d0e17d99 100644 --- a/consensus/state_processing/Cargo.toml +++ b/consensus/state_processing/Cargo.toml @@ -37,7 +37,6 @@ rayon = { workspace = true } safe_arith = { workspace = true } smallvec = { workspace = true } ssz_types = { workspace = true } -test_random_derive = { path = "../../common/test_random_derive" } tracing = { workspace = true } tree_hash = { workspace = true } typenum = { workspace = true } @@ -45,4 +44,5 @@ types = { workspace = true } [dev-dependencies] beacon_chain = { workspace = true } +state_processing = { path = ".", features = ["arbitrary"] } tokio = { workspace = true } diff --git a/consensus/state_processing/src/verify_operation.rs b/consensus/state_processing/src/verify_operation.rs index 1e9c3d5fe3..8e67c3da43 100644 --- a/consensus/state_processing/src/verify_operation.rs +++ b/consensus/state_processing/src/verify_operation.rs @@ -14,11 +14,10 @@ use smallvec::{SmallVec, smallvec}; use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use std::marker::PhantomData; -use test_random_derive::TestRandom; use types::{ AttesterSlashing, AttesterSlashingBase, AttesterSlashingOnDisk, AttesterSlashingRefOnDisk, BeaconState, ChainSpec, Epoch, EthSpec, Fork, ForkVersion, ProposerSlashing, - SignedBlsToExecutionChange, SignedVoluntaryExit, test_utils::TestRandom, + SignedBlsToExecutionChange, SignedVoluntaryExit, }; const MAX_FORKS_VERIFIED_AGAINST: usize = 2; @@ -138,7 +137,7 @@ struct SigVerifiedOpDecode { /// /// We need to store multiple `ForkVersion`s because attester slashings contain two indexed /// attestations which may be signed using different versions. -#[derive(Debug, PartialEq, Eq, Clone, Hash, Encode, Decode, TestRandom)] +#[derive(Debug, PartialEq, Eq, Clone, Hash, Encode, Decode)] #[cfg_attr(feature = "arbitrary", derive(Arbitrary))] pub struct VerifiedAgainst { fork_versions: SmallVec<[ForkVersion; MAX_FORKS_VERIFIED_AGAINST]>, @@ -423,20 +422,21 @@ impl TransformPersist for SignedBlsToExecutionChange { #[cfg(all(test, not(debug_assertions)))] mod test { use super::*; - use types::{ - MainnetEthSpec, - test_utils::{SeedableRng, TestRandom, XorShiftRng}, - }; + use types::MainnetEthSpec; type E = MainnetEthSpec; - fn roundtrip_test() { + fn roundtrip_test<'a, T>() + where + T: arbitrary::Arbitrary<'a> + TransformPersist + PartialEq + std::fmt::Debug, + { let runs = 10; - let mut rng = XorShiftRng::seed_from_u64(0xff0af5a356af1123); + let mut u = types::test_utils::test_unstructured(); for _ in 0..runs { - let op = T::random_for_test(&mut rng); - let verified_against = VerifiedAgainst::random_for_test(&mut rng); + let op = T::arbitrary(&mut u).expect("arbitrary op"); + let verified_against = + VerifiedAgainst::arbitrary(&mut u).expect("arbitrary verified_against"); let verified_op = SigVerifiedOp { op, diff --git a/consensus/types/Cargo.toml b/consensus/types/Cargo.toml index 4aae4b7f39..9ee827c7b9 100644 --- a/consensus/types/Cargo.toml +++ b/consensus/types/Cargo.toml @@ -45,7 +45,7 @@ metastruct = "0.1.0" milhouse = { workspace = true } parking_lot = { workspace = true } rand = { workspace = true } -rand_xorshift = "0.4.0" +rand_xorshift = { workspace = true } rayon = { workspace = true } regex = { workspace = true } rpds = { workspace = true } @@ -58,7 +58,6 @@ ssz_types = { workspace = true } superstruct = { workspace = true } swap_or_not_shuffle = { workspace = true } tempfile = { workspace = true } -test_random_derive = { path = "../../common/test_random_derive" } tracing = { workspace = true } tree_hash = { workspace = true } tree_hash_derive = { workspace = true } @@ -71,6 +70,7 @@ criterion = { workspace = true } paste = { workspace = true } state_processing = { workspace = true } tokio = { workspace = true } +types = { path = ".", features = ["arbitrary"] } [lints.clippy] module_inception = "allow" diff --git a/consensus/types/src/attestation/aggregate_and_proof.rs b/consensus/types/src/attestation/aggregate_and_proof.rs index 4c6e775e56..76e33faf88 100644 --- a/consensus/types/src/attestation/aggregate_and_proof.rs +++ b/consensus/types/src/attestation/aggregate_and_proof.rs @@ -3,7 +3,6 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ @@ -12,7 +11,6 @@ use crate::{ }, core::{ChainSpec, Domain, EthSpec, Hash256, SignedRoot}, fork::{Fork, ForkName}, - test_utils::TestRandom, }; #[superstruct( @@ -26,7 +24,6 @@ use crate::{ Deserialize, Encode, Decode, - TestRandom, TreeHash, ), context_deserialize(ForkName), diff --git a/consensus/types/src/attestation/attestation.rs b/consensus/types/src/attestation/attestation.rs index 28059efee6..4cfb7a4d24 100644 --- a/consensus/types/src/attestation/attestation.rs +++ b/consensus/types/src/attestation/attestation.rs @@ -10,7 +10,6 @@ use serde::{Deserialize, Deserializer, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::{BitList, BitVector}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ @@ -20,7 +19,6 @@ use crate::{ }, core::{ChainSpec, Domain, EthSpec, Hash256, SignedRoot, Slot, SlotData}, fork::{Fork, ForkName}, - test_utils::TestRandom, }; #[derive(Debug, PartialEq, Clone)] @@ -49,7 +47,6 @@ impl From for Error { Deserialize, Decode, Encode, - TestRandom, Educe, TreeHash, ), @@ -614,7 +611,7 @@ impl<'de, E: EthSpec> ContextDeserialize<'de, ForkName> for Vec> */ #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive(Debug, Clone, Serialize, Deserialize, Decode, Encode, TestRandom, TreeHash, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, Decode, Encode, TreeHash, PartialEq)] #[context_deserialize(ForkName)] pub struct SingleAttestation { #[serde(with = "serde_utils::quoted_u64")] diff --git a/consensus/types/src/attestation/attestation_data.rs b/consensus/types/src/attestation/attestation_data.rs index f3fceb9b70..2d88bce2b9 100644 --- a/consensus/types/src/attestation/attestation_data.rs +++ b/consensus/types/src/attestation/attestation_data.rs @@ -1,14 +1,12 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ attestation::Checkpoint, core::{Hash256, SignedRoot, Slot, SlotData}, fork::ForkName, - test_utils::TestRandom, }; /// The data upon which an attestation is based. @@ -16,18 +14,7 @@ use crate::{ /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive( - Debug, - Clone, - PartialEq, - Eq, - Serialize, - Deserialize, - Hash, - Encode, - Decode, - TreeHash, - TestRandom, - Default, + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Encode, Decode, TreeHash, Default, )] #[context_deserialize(ForkName)] pub struct AttestationData { diff --git a/consensus/types/src/attestation/checkpoint.rs b/consensus/types/src/attestation/checkpoint.rs index f5a95f0ad9..09f8f06e6e 100644 --- a/consensus/types/src/attestation/checkpoint.rs +++ b/consensus/types/src/attestation/checkpoint.rs @@ -1,13 +1,11 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{Epoch, Hash256}, fork::ForkName, - test_utils::TestRandom, }; /// Casper FFG checkpoint, used in attestations. @@ -27,7 +25,6 @@ use crate::{ Encode, Decode, TreeHash, - TestRandom, )] #[context_deserialize(ForkName)] pub struct Checkpoint { diff --git a/consensus/types/src/attestation/indexed_attestation.rs b/consensus/types/src/attestation/indexed_attestation.rs index 272b015d90..ae15f474f3 100644 --- a/consensus/types/src/attestation/indexed_attestation.rs +++ b/consensus/types/src/attestation/indexed_attestation.rs @@ -11,10 +11,9 @@ use ssz::Encode; use ssz_derive::{Decode, Encode}; use ssz_types::VariableList; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{attestation::AttestationData, core::EthSpec, fork::ForkName, test_utils::TestRandom}; +use crate::{attestation::AttestationData, core::EthSpec, fork::ForkName}; /// Details an attestation that can be slashable. /// @@ -31,7 +30,6 @@ use crate::{attestation::AttestationData, core::EthSpec, fork::ForkName, test_ut Deserialize, Decode, Encode, - TestRandom, Educe, TreeHash, ), @@ -212,10 +210,8 @@ impl Hash for IndexedAttestation { #[cfg(test)] mod tests { use super::*; - use crate::{ - core::{Epoch, MainnetEthSpec}, - test_utils::{SeedableRng, XorShiftRng}, - }; + use crate::core::{Epoch, MainnetEthSpec}; + use arbitrary::Arbitrary; #[test] pub fn test_is_double_vote_true() { @@ -278,9 +274,9 @@ mod tests { target_epoch: u64, source_epoch: u64, ) -> IndexedAttestation { - let mut rng = XorShiftRng::from_seed([42; 16]); + let mut u = crate::test_utils::test_unstructured(); let mut indexed_vote = - IndexedAttestation::Base(IndexedAttestationBase::random_for_test(&mut rng)); + IndexedAttestation::Base(IndexedAttestationBase::arbitrary(&mut u).unwrap()); indexed_vote.data_mut().source.epoch = Epoch::new(source_epoch); indexed_vote.data_mut().target.epoch = Epoch::new(target_epoch); diff --git a/consensus/types/src/attestation/indexed_payload_attestation.rs b/consensus/types/src/attestation/indexed_payload_attestation.rs index bb2087e330..67fdf77bdf 100644 --- a/consensus/types/src/attestation/indexed_payload_attestation.rs +++ b/consensus/types/src/attestation/indexed_payload_attestation.rs @@ -1,14 +1,12 @@ -use crate::test_utils::TestRandom; use crate::{EthSpec, ForkName, PayloadAttestationData}; use bls::AggregateSignature; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::VariableList; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -#[derive(TestRandom, TreeHash, Debug, Clone, PartialEq, Encode, Decode, Serialize, Deserialize)] +#[derive(TreeHash, Debug, Clone, PartialEq, Encode, Decode, Serialize, Deserialize)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[serde(bound = "E: EthSpec", deny_unknown_fields)] #[cfg_attr(feature = "arbitrary", arbitrary(bound = "E: EthSpec"))] diff --git a/consensus/types/src/attestation/participation_flags.rs b/consensus/types/src/attestation/participation_flags.rs index 66831abfac..a88ea0d3f7 100644 --- a/consensus/types/src/attestation/participation_flags.rs +++ b/consensus/types/src/attestation/participation_flags.rs @@ -1,15 +1,11 @@ use safe_arith::{ArithError, SafeArith}; use serde::{Deserialize, Serialize}; use ssz::{Decode, DecodeError, Encode}; -use test_random_derive::TestRandom; use tree_hash::{PackedEncoding, TreeHash, TreeHashType}; -use crate::{ - core::{Hash256, consts::altair::NUM_FLAG_INDICES}, - test_utils::TestRandom, -}; +use crate::core::{Hash256, consts::altair::NUM_FLAG_INDICES}; -#[derive(Debug, Default, Clone, Copy, PartialEq, Deserialize, Serialize, TestRandom)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Deserialize, Serialize)] #[serde(transparent)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ParticipationFlags { diff --git a/consensus/types/src/attestation/payload_attestation.rs b/consensus/types/src/attestation/payload_attestation.rs index 115a5ec4d6..d5e76f941b 100644 --- a/consensus/types/src/attestation/payload_attestation.rs +++ b/consensus/types/src/attestation/payload_attestation.rs @@ -1,5 +1,4 @@ use crate::attestation::payload_attestation_data::PayloadAttestationData; -use crate::test_utils::TestRandom; use crate::{EthSpec, ForkName}; use bls::AggregateSignature; use context_deserialize::context_deserialize; @@ -7,10 +6,9 @@ use educe::Educe; use serde::{Deserialize, Serialize}; use ssz::BitVector; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -#[derive(TestRandom, TreeHash, Debug, Clone, Encode, Decode, Serialize, Deserialize, Educe)] +#[derive(TreeHash, Debug, Clone, Encode, Decode, Serialize, Deserialize, Educe)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[serde(bound = "E: EthSpec", deny_unknown_fields)] #[cfg_attr(feature = "arbitrary", arbitrary(bound = "E: EthSpec"))] diff --git a/consensus/types/src/attestation/payload_attestation_data.rs b/consensus/types/src/attestation/payload_attestation_data.rs index 58d36fd01d..198d380c14 100644 --- a/consensus/types/src/attestation/payload_attestation_data.rs +++ b/consensus/types/src/attestation/payload_attestation_data.rs @@ -1,14 +1,10 @@ -use crate::test_utils::TestRandom; use crate::{ForkName, Hash256, SignedRoot, Slot}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -#[derive( - TestRandom, TreeHash, Debug, Clone, PartialEq, Eq, Encode, Decode, Serialize, Deserialize, Hash, -)] +#[derive(TreeHash, Debug, Clone, PartialEq, Eq, Encode, Decode, Serialize, Deserialize, Hash)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[context_deserialize(ForkName)] pub struct PayloadAttestationData { diff --git a/consensus/types/src/attestation/payload_attestation_message.rs b/consensus/types/src/attestation/payload_attestation_message.rs index 82e2137b09..7be022efd3 100644 --- a/consensus/types/src/attestation/payload_attestation_message.rs +++ b/consensus/types/src/attestation/payload_attestation_message.rs @@ -1,14 +1,12 @@ use crate::ForkName; use crate::attestation::payload_attestation_data::PayloadAttestationData; -use crate::test_utils::TestRandom; use bls::Signature; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -#[derive(TestRandom, TreeHash, Debug, Clone, PartialEq, Encode, Decode, Serialize, Deserialize)] +#[derive(TreeHash, Debug, Clone, PartialEq, Encode, Decode, Serialize, Deserialize)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[context_deserialize(ForkName)] pub struct PayloadAttestationMessage { diff --git a/consensus/types/src/attestation/pending_attestation.rs b/consensus/types/src/attestation/pending_attestation.rs index 84353ac118..79a77b47cb 100644 --- a/consensus/types/src/attestation/pending_attestation.rs +++ b/consensus/types/src/attestation/pending_attestation.rs @@ -2,10 +2,9 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::BitList; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{attestation::AttestationData, core::EthSpec, fork::ForkName, test_utils::TestRandom}; +use crate::{attestation::AttestationData, core::EthSpec, fork::ForkName}; /// An attestation that has been included in the state but not yet fully processed. /// @@ -15,7 +14,7 @@ use crate::{attestation::AttestationData, core::EthSpec, fork::ForkName, test_ut derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct PendingAttestation { pub aggregation_bits: BitList, diff --git a/consensus/types/src/attestation/signed_aggregate_and_proof.rs b/consensus/types/src/attestation/signed_aggregate_and_proof.rs index 48c3f4c567..f9db76e9d2 100644 --- a/consensus/types/src/attestation/signed_aggregate_and_proof.rs +++ b/consensus/types/src/attestation/signed_aggregate_and_proof.rs @@ -3,7 +3,6 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ @@ -13,7 +12,6 @@ use crate::{ }, core::{ChainSpec, Domain, EthSpec, Hash256, SignedRoot}, fork::{Fork, ForkName}, - test_utils::TestRandom, }; /// A Validators signed aggregate proof to publish on the `beacon_aggregate_and_proof` @@ -31,7 +29,6 @@ use crate::{ Deserialize, Encode, Decode, - TestRandom, TreeHash, ), context_deserialize(ForkName), diff --git a/consensus/types/src/block/beacon_block.rs b/consensus/types/src/block/beacon_block.rs index 3360728eaa..639a89d7e6 100644 --- a/consensus/types/src/block/beacon_block.rs +++ b/consensus/types/src/block/beacon_block.rs @@ -9,7 +9,6 @@ use ssz::{Decode, DecodeError}; use ssz_derive::{Decode, Encode}; use ssz_types::{BitList, BitVector, FixedVector, VariableList}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; use typenum::Unsigned; @@ -34,7 +33,6 @@ use crate::{ slashing::{AttesterSlashingBase, ProposerSlashing}, state::BeaconStateError, sync_committee::SyncAggregate, - test_utils::TestRandom, }; /// A block of the `BeaconChain`. @@ -49,7 +47,6 @@ use crate::{ Encode, Decode, TreeHash, - TestRandom, Educe, ), educe(PartialEq, Hash(bound(E: EthSpec, Payload: AbstractExecPayload))), @@ -935,10 +932,8 @@ impl fmt::Display for BlockImportSource { #[cfg(test)] mod tests { use super::*; - use crate::{ - core::MainnetEthSpec, - test_utils::{SeedableRng, XorShiftRng, test_ssz_tree_hash_pair_with}, - }; + use crate::{core::MainnetEthSpec, test_utils::test_ssz_tree_hash_pair_with}; + use arbitrary::Arbitrary; use ssz::Encode; type BeaconBlock = super::BeaconBlock; @@ -947,16 +942,10 @@ mod tests { #[test] fn roundtrip_base_block() { - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = crate::test_utils::test_unstructured(); let spec = &ForkName::Base.make_genesis_spec(MainnetEthSpec::default_spec()); - let inner_block = BeaconBlockBase { - slot: Slot::random_for_test(rng), - proposer_index: u64::random_for_test(rng), - parent_root: Hash256::random_for_test(rng), - state_root: Hash256::random_for_test(rng), - body: BeaconBlockBodyBase::random_for_test(rng), - }; + let inner_block = BeaconBlockBase::arbitrary(&mut u).unwrap(); let block = BeaconBlock::Base(inner_block.clone()); test_ssz_tree_hash_pair_with(&block, &inner_block, |bytes| { @@ -966,16 +955,10 @@ mod tests { #[test] fn roundtrip_altair_block() { - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = crate::test_utils::test_unstructured(); let spec = &ForkName::Altair.make_genesis_spec(MainnetEthSpec::default_spec()); - let inner_block = BeaconBlockAltair { - slot: Slot::random_for_test(rng), - proposer_index: u64::random_for_test(rng), - parent_root: Hash256::random_for_test(rng), - state_root: Hash256::random_for_test(rng), - body: BeaconBlockBodyAltair::random_for_test(rng), - }; + let inner_block = BeaconBlockAltair::arbitrary(&mut u).unwrap(); let block = BeaconBlock::Altair(inner_block.clone()); test_ssz_tree_hash_pair_with(&block, &inner_block, |bytes| { @@ -985,16 +968,10 @@ mod tests { #[test] fn roundtrip_capella_block() { - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = crate::test_utils::test_unstructured(); let spec = &ForkName::Capella.make_genesis_spec(MainnetEthSpec::default_spec()); - let inner_block = BeaconBlockCapella { - slot: Slot::random_for_test(rng), - proposer_index: u64::random_for_test(rng), - parent_root: Hash256::random_for_test(rng), - state_root: Hash256::random_for_test(rng), - body: BeaconBlockBodyCapella::random_for_test(rng), - }; + let inner_block = BeaconBlockCapella::arbitrary(&mut u).unwrap(); let block = BeaconBlock::Capella(inner_block.clone()); test_ssz_tree_hash_pair_with(&block, &inner_block, |bytes| { @@ -1004,16 +981,10 @@ mod tests { #[test] fn roundtrip_deneb_block() { - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = crate::test_utils::test_unstructured(); let spec = &ForkName::Deneb.make_genesis_spec(MainnetEthSpec::default_spec()); - let inner_block = BeaconBlockDeneb { - slot: Slot::random_for_test(rng), - proposer_index: u64::random_for_test(rng), - parent_root: Hash256::random_for_test(rng), - state_root: Hash256::random_for_test(rng), - body: BeaconBlockBodyDeneb::random_for_test(rng), - }; + let inner_block = BeaconBlockDeneb::arbitrary(&mut u).unwrap(); let block = BeaconBlock::Deneb(inner_block.clone()); test_ssz_tree_hash_pair_with(&block, &inner_block, |bytes| { @@ -1023,17 +994,10 @@ mod tests { #[test] fn roundtrip_electra_block() { - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = crate::test_utils::test_unstructured(); let spec = &ForkName::Electra.make_genesis_spec(MainnetEthSpec::default_spec()); - let inner_block = BeaconBlockElectra { - slot: Slot::random_for_test(rng), - proposer_index: u64::random_for_test(rng), - parent_root: Hash256::random_for_test(rng), - state_root: Hash256::random_for_test(rng), - body: BeaconBlockBodyElectra::random_for_test(rng), - }; - + let inner_block = BeaconBlockElectra::arbitrary(&mut u).unwrap(); let block = BeaconBlock::Electra(inner_block.clone()); test_ssz_tree_hash_pair_with(&block, &inner_block, |bytes| { @@ -1043,17 +1007,10 @@ mod tests { #[test] fn roundtrip_fulu_block() { - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = crate::test_utils::test_unstructured(); let spec = &ForkName::Fulu.make_genesis_spec(MainnetEthSpec::default_spec()); - let inner_block = BeaconBlockFulu { - slot: Slot::random_for_test(rng), - proposer_index: u64::random_for_test(rng), - parent_root: Hash256::random_for_test(rng), - state_root: Hash256::random_for_test(rng), - body: BeaconBlockBodyFulu::random_for_test(rng), - }; - + let inner_block = BeaconBlockFulu::arbitrary(&mut u).unwrap(); let block = BeaconBlock::Fulu(inner_block.clone()); test_ssz_tree_hash_pair_with(&block, &inner_block, |bytes| { @@ -1063,17 +1020,10 @@ mod tests { #[test] fn roundtrip_gloas_block() { - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = crate::test_utils::test_unstructured(); let spec = &ForkName::Gloas.make_genesis_spec(MainnetEthSpec::default_spec()); - let inner_block = BeaconBlockGloas { - slot: Slot::random_for_test(rng), - proposer_index: u64::random_for_test(rng), - parent_root: Hash256::random_for_test(rng), - state_root: Hash256::random_for_test(rng), - body: BeaconBlockBodyGloas::random_for_test(rng), - }; - + let inner_block = BeaconBlockGloas::arbitrary(&mut u).unwrap(); let block = BeaconBlock::Gloas(inner_block.clone()); test_ssz_tree_hash_pair_with(&block, &inner_block, |bytes| { @@ -1086,7 +1036,7 @@ mod tests { type E = MainnetEthSpec; let mut spec = E::default_spec(); - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = crate::test_utils::test_unstructured(); let altair_fork_epoch = spec.altair_fork_epoch.unwrap(); @@ -1116,7 +1066,7 @@ mod tests { { let good_base_block = BeaconBlock::Base(BeaconBlockBase { slot: base_slot, - ..<_>::random_for_test(rng) + ..<_>::arbitrary(&mut u).unwrap() }); // It's invalid to have a base block with a slot higher than the fork epoch. let bad_base_block = { @@ -1138,7 +1088,7 @@ mod tests { { let good_altair_block = BeaconBlock::Altair(BeaconBlockAltair { slot: altair_slot, - ..<_>::random_for_test(rng) + ..<_>::arbitrary(&mut u).unwrap() }); // It's invalid to have an Altair block with a epoch lower than the fork epoch. let bad_altair_block = { @@ -1160,7 +1110,7 @@ mod tests { { let good_block = BeaconBlock::Capella(BeaconBlockCapella { slot: capella_slot, - ..<_>::random_for_test(rng) + ..<_>::arbitrary(&mut u).unwrap() }); // It's invalid to have an Capella block with a epoch lower than the fork epoch. let bad_block = { @@ -1182,7 +1132,7 @@ mod tests { { let good_block = BeaconBlock::Deneb(BeaconBlockDeneb { slot: deneb_slot, - ..<_>::random_for_test(rng) + ..<_>::arbitrary(&mut u).unwrap() }); // It's invalid to have a Deneb block with a epoch lower than the fork epoch. let bad_block = { @@ -1204,7 +1154,7 @@ mod tests { { let good_block = BeaconBlock::Electra(BeaconBlockElectra { slot: electra_slot, - ..<_>::random_for_test(rng) + ..<_>::arbitrary(&mut u).unwrap() }); // It's invalid to have an Electra block with a epoch lower than the fork epoch. let bad_block = { @@ -1226,7 +1176,7 @@ mod tests { { let good_block = BeaconBlock::Fulu(BeaconBlockFulu { slot: fulu_slot, - ..<_>::random_for_test(rng) + ..<_>::arbitrary(&mut u).unwrap() }); assert_eq!( @@ -1240,7 +1190,7 @@ mod tests { { let good_block = BeaconBlock::Gloas(BeaconBlockGloas { slot: gloas_slot, - ..<_>::random_for_test(rng) + ..<_>::arbitrary(&mut u).unwrap() }); // It's invalid to have a Fulu block with a epoch lower than the fork epoch. let _bad_block = { diff --git a/consensus/types/src/block/beacon_block_body.rs b/consensus/types/src/block/beacon_block_body.rs index 25695dbdda..071c9e76d4 100644 --- a/consensus/types/src/block/beacon_block_body.rs +++ b/consensus/types/src/block/beacon_block_body.rs @@ -9,7 +9,6 @@ use serde::{Deserialize, Deserializer, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::{FixedVector, VariableList}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -38,7 +37,6 @@ use crate::{ }, state::BeaconStateError, sync_committee::SyncAggregate, - test_utils::TestRandom, }; /// The number of leaves (including padding) on the `BeaconBlockBody` Merkle tree. @@ -65,7 +63,6 @@ pub const BLOB_KZG_COMMITMENTS_INDEX: usize = 11; Encode, Decode, TreeHash, - TestRandom, Educe, ), educe(PartialEq, Hash(bound(E: EthSpec, Payload: AbstractExecPayload))), diff --git a/consensus/types/src/block/beacon_block_header.rs b/consensus/types/src/block/beacon_block_header.rs index 06e1023d91..3d5b02d6b6 100644 --- a/consensus/types/src/block/beacon_block_header.rs +++ b/consensus/types/src/block/beacon_block_header.rs @@ -2,7 +2,6 @@ use bls::SecretKey; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -10,16 +9,13 @@ use crate::{ block::SignedBeaconBlockHeader, core::{ChainSpec, Domain, EthSpec, Hash256, SignedRoot, Slot}, fork::{Fork, ForkName}, - test_utils::TestRandom, }; /// A header of a `BeaconBlock`. /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct BeaconBlockHeader { pub slot: Slot, diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index dd6f52426a..76bb9a09db 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -8,7 +8,6 @@ use serde::{Deserialize, Deserializer, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::FixedVector; use superstruct::superstruct; -use test_random_derive::TestRandom; use tracing::instrument; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -33,7 +32,6 @@ use crate::{ fork::{Fork, ForkName, ForkVersionDecode, InconsistentFork, map_fork_name}, kzg_ext::format_kzg_commitments, state::BeaconStateError, - test_utils::TestRandom, }; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] @@ -77,7 +75,6 @@ impl From for Hash256 { Decode, TreeHash, Educe, - TestRandom ), educe(PartialEq, Hash(bound(E: EthSpec))), serde(bound = "E: EthSpec, Payload: AbstractExecPayload"), diff --git a/consensus/types/src/block/signed_beacon_block_header.rs b/consensus/types/src/block/signed_beacon_block_header.rs index 2fcd8a705f..6e81850a3f 100644 --- a/consensus/types/src/block/signed_beacon_block_header.rs +++ b/consensus/types/src/block/signed_beacon_block_header.rs @@ -2,23 +2,19 @@ use bls::{PublicKey, Signature}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ block::BeaconBlockHeader, core::{ChainSpec, Domain, EthSpec, Hash256, SignedRoot}, fork::{Fork, ForkName}, - test_utils::TestRandom, }; /// A signed header of a `BeaconBlock`. /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct SignedBeaconBlockHeader { pub message: BeaconBlockHeader, diff --git a/consensus/types/src/builder/builder.rs b/consensus/types/src/builder/builder.rs index 2bd50f42cc..18961c5969 100644 --- a/consensus/types/src/builder/builder.rs +++ b/consensus/types/src/builder/builder.rs @@ -1,18 +1,14 @@ -use crate::test_utils::TestRandom; use crate::{Address, Epoch, ForkName}; use bls::PublicKeyBytes; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; pub type BuilderIndex = u64; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, TestRandom, TreeHash, -)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct Builder { pub pubkey: PublicKeyBytes, diff --git a/consensus/types/src/builder/builder_bid.rs b/consensus/types/src/builder/builder_bid.rs index e706b01283..df7893b909 100644 --- a/consensus/types/src/builder/builder_bid.rs +++ b/consensus/types/src/builder/builder_bid.rs @@ -5,7 +5,6 @@ use serde::{Deserialize, Deserializer, Serialize}; use ssz::Decode; use ssz_derive::{Decode, Encode}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ @@ -17,7 +16,6 @@ use crate::{ }, fork::{ForkName, ForkVersionDecode}, kzg_ext::KzgCommitments, - test_utils::TestRandom, }; #[superstruct( @@ -32,9 +30,13 @@ use crate::{ TreeHash, Decode, Clone, - TestRandom ), - serde(bound = "E: EthSpec", deny_unknown_fields) + serde(bound = "E: EthSpec", deny_unknown_fields), + cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec"), + ), ), map_ref_into(ExecutionPayloadHeaderRef), map_ref_mut_into(ExecutionPayloadHeaderRefMut) diff --git a/consensus/types/src/builder/builder_pending_payment.rs b/consensus/types/src/builder/builder_pending_payment.rs index 0f1b68ad97..61c76dfc15 100644 --- a/consensus/types/src/builder/builder_pending_payment.rs +++ b/consensus/types/src/builder/builder_pending_payment.rs @@ -1,24 +1,11 @@ -use crate::test_utils::TestRandom; use crate::{BuilderPendingWithdrawal, ForkName}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; #[derive( - Debug, - PartialEq, - Eq, - Hash, - Clone, - Default, - Serialize, - Deserialize, - Encode, - Decode, - TreeHash, - TestRandom, + Debug, PartialEq, Eq, Hash, Clone, Default, Serialize, Deserialize, Encode, Decode, TreeHash, )] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[context_deserialize(ForkName)] diff --git a/consensus/types/src/builder/builder_pending_withdrawal.rs b/consensus/types/src/builder/builder_pending_withdrawal.rs index dbbb029a5d..4b1003a28b 100644 --- a/consensus/types/src/builder/builder_pending_withdrawal.rs +++ b/consensus/types/src/builder/builder_pending_withdrawal.rs @@ -1,24 +1,11 @@ -use crate::test_utils::TestRandom; use crate::{Address, ForkName}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; #[derive( - Debug, - PartialEq, - Eq, - Hash, - Clone, - Default, - Serialize, - Deserialize, - Encode, - Decode, - TreeHash, - TestRandom, + Debug, PartialEq, Eq, Hash, Clone, Default, Serialize, Deserialize, Encode, Decode, TreeHash, )] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[context_deserialize(ForkName)] diff --git a/consensus/types/src/builder/proposer_preferences.rs b/consensus/types/src/builder/proposer_preferences.rs index 38f1b36be3..e3773e333d 100644 --- a/consensus/types/src/builder/proposer_preferences.rs +++ b/consensus/types/src/builder/proposer_preferences.rs @@ -1,16 +1,12 @@ -use crate::test_utils::TestRandom; use crate::{Address, ForkName, Hash256, SignedRoot, Slot}; use bls::Signature; use context_deserialize::context_deserialize; use educe::Educe; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -#[derive( - Default, Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe, TestRandom, -)] +#[derive(Default, Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[educe(PartialEq, Hash)] #[context_deserialize(ForkName)] @@ -25,7 +21,7 @@ pub struct ProposerPreferences { impl SignedRoot for ProposerPreferences {} -#[derive(TestRandom, TreeHash, Debug, Clone, Encode, Decode, Serialize, Deserialize, Educe)] +#[derive(TreeHash, Debug, Clone, Encode, Decode, Serialize, Deserialize, Educe)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[educe(PartialEq, Hash)] #[context_deserialize(ForkName)] diff --git a/consensus/types/src/consolidation/consolidation_request.rs b/consensus/types/src/consolidation/consolidation_request.rs index 3f09517a90..b24d0bee66 100644 --- a/consensus/types/src/consolidation/consolidation_request.rs +++ b/consensus/types/src/consolidation/consolidation_request.rs @@ -3,19 +3,15 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz::Encode; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{Address, SignedRoot}, fork::ForkName, - test_utils::TestRandom, }; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct ConsolidationRequest { pub source_address: Address, diff --git a/consensus/types/src/consolidation/pending_consolidation.rs b/consensus/types/src/consolidation/pending_consolidation.rs index fcd76e43b6..df71316f07 100644 --- a/consensus/types/src/consolidation/pending_consolidation.rs +++ b/consensus/types/src/consolidation/pending_consolidation.rs @@ -1,15 +1,12 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{fork::ForkName, test_utils::TestRandom}; +use crate::fork::ForkName; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct PendingConsolidation { #[serde(with = "serde_utils::quoted_u64")] diff --git a/consensus/types/src/core/enr_fork_id.rs b/consensus/types/src/core/enr_fork_id.rs index c3b400cd13..f4ad072175 100644 --- a/consensus/types/src/core/enr_fork_id.rs +++ b/consensus/types/src/core/enr_fork_id.rs @@ -1,18 +1,15 @@ use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{core::Epoch, test_utils::TestRandom}; +use crate::core::Epoch; /// Specifies a fork which allows nodes to identify each other on the network. This fork is used in /// a nodes local ENR. /// /// Spec v0.11 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, Clone, PartialEq, Default, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Encode, Decode, TreeHash)] pub struct EnrForkId { /// Fork digest of the current fork computed from [`ChainSpec::compute_fork_digest`]. #[serde(with = "serde_utils::bytes_4_hex")] diff --git a/consensus/types/src/core/execution_block_hash.rs b/consensus/types/src/core/execution_block_hash.rs index 71e63727ee..41e00115c6 100644 --- a/consensus/types/src/core/execution_block_hash.rs +++ b/consensus/types/src/core/execution_block_hash.rs @@ -1,14 +1,10 @@ use std::fmt; use fixed_bytes::FixedBytesExtended; -use rand::RngCore; use serde::{Deserialize, Serialize}; use ssz::{Decode, DecodeError, Encode}; -use crate::{ - core::{Hash256, Hash256Ext}, - test_utils::TestRandom, -}; +use crate::core::{Hash256, Hash256Ext}; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive(Default, Clone, Copy, Serialize, Deserialize, Eq, PartialEq, Hash)] @@ -95,12 +91,6 @@ impl tree_hash::TreeHash for ExecutionBlockHash { } } -impl TestRandom for ExecutionBlockHash { - fn random_for_test(rng: &mut impl RngCore) -> Self { - Self(Hash256::random_for_test(rng)) - } -} - impl std::str::FromStr for ExecutionBlockHash { type Err = String; diff --git a/consensus/types/src/core/graffiti.rs b/consensus/types/src/core/graffiti.rs index d0e0e1b1a8..02b805a2a8 100644 --- a/consensus/types/src/core/graffiti.rs +++ b/consensus/types/src/core/graffiti.rs @@ -1,13 +1,10 @@ use std::{fmt, str::FromStr}; -use rand::RngCore; use regex::bytes::Regex; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error}; use ssz::{Decode, DecodeError, Encode}; use tree_hash::{PackedEncoding, TreeHash}; -use crate::{core::Hash256, test_utils::TestRandom}; - pub const GRAFFITI_BYTES_LEN: usize = 32; /// The 32-byte `graffiti` field on a beacon block. @@ -180,9 +177,3 @@ impl TreeHash for Graffiti { self.0.tree_hash_root() } } - -impl TestRandom for Graffiti { - fn random_for_test(rng: &mut impl RngCore) -> Self { - Self::from(Hash256::random_for_test(rng).0) - } -} diff --git a/consensus/types/src/core/signing_data.rs b/consensus/types/src/core/signing_data.rs index 907f03fac7..e698b4fdbe 100644 --- a/consensus/types/src/core/signing_data.rs +++ b/consensus/types/src/core/signing_data.rs @@ -1,14 +1,13 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; -use crate::{core::Hash256, fork::ForkName, test_utils::TestRandom}; +use crate::{core::Hash256, fork::ForkName}; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct SigningData { pub object_root: Hash256, diff --git a/consensus/types/src/core/slot_epoch.rs b/consensus/types/src/core/slot_epoch.rs index 837391546c..177161a2ab 100644 --- a/consensus/types/src/core/slot_epoch.rs +++ b/consensus/types/src/core/slot_epoch.rs @@ -12,15 +12,11 @@ use std::{fmt, hash::Hash}; -use rand::RngCore; use safe_arith::{ArithError, SafeArith}; use serde::{Deserialize, Serialize}; use ssz::{Decode, DecodeError, Encode}; -use crate::{ - core::{ChainSpec, SignedRoot}, - test_utils::TestRandom, -}; +use crate::core::{ChainSpec, SignedRoot}; #[cfg(feature = "saturating-arith")] use std::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Rem, Sub, SubAssign}; diff --git a/consensus/types/src/core/slot_epoch_macros.rs b/consensus/types/src/core/slot_epoch_macros.rs index 1b0c3bcfc1..09e0f1d120 100644 --- a/consensus/types/src/core/slot_epoch_macros.rs +++ b/consensus/types/src/core/slot_epoch_macros.rs @@ -293,12 +293,6 @@ macro_rules! impl_ssz { } impl SignedRoot for $type {} - - impl TestRandom for $type { - fn random_for_test(rng: &mut impl RngCore) -> Self { - $type::from(u64::random_for_test(rng)) - } - } }; } diff --git a/consensus/types/src/data/blob_sidecar.rs b/consensus/types/src/data/blob_sidecar.rs index 70b95615e5..4020278d64 100644 --- a/consensus/types/src/data/blob_sidecar.rs +++ b/consensus/types/src/data/blob_sidecar.rs @@ -11,7 +11,6 @@ use serde::{Deserialize, Serialize}; use ssz::Encode; use ssz_derive::{Decode, Encode}; use ssz_types::{FixedVector, RuntimeFixedVector, RuntimeVariableList, VariableList}; -use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -25,7 +24,6 @@ use crate::{ fork::ForkName, kzg_ext::KzgProofs, state::BeaconStateError, - test_utils::TestRandom, }; /// Container of the data that identifies an individual blob. @@ -55,7 +53,7 @@ impl Ord for BlobIdentifier { derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, Educe)] +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, Educe)] #[context_deserialize(ForkName)] #[serde(bound = "E: EthSpec")] #[educe(PartialEq, Eq, Hash(bound(E: EthSpec)))] diff --git a/consensus/types/src/data/data_column_sidecar.rs b/consensus/types/src/data/data_column_sidecar.rs index 109c9472a5..170aa99666 100644 --- a/consensus/types/src/data/data_column_sidecar.rs +++ b/consensus/types/src/data/data_column_sidecar.rs @@ -12,7 +12,6 @@ use ssz_derive::{Decode, Encode}; use ssz_types::Error as SszError; use ssz_types::{FixedVector, VariableList}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -26,7 +25,6 @@ use crate::{ fork::ForkName, kzg_ext::{KzgCommitments, KzgError}, state::BeaconStateError, - test_utils::TestRandom, }; pub type ColumnIndex = u64; @@ -53,7 +51,6 @@ pub type DataColumnSidecarList = Vec>>; Deserialize, Decode, Encode, - TestRandom, Educe, TreeHash, ), diff --git a/consensus/types/src/data/partial_data_column_sidecar.rs b/consensus/types/src/data/partial_data_column_sidecar.rs index df65be1ae3..c0e713b4b8 100644 --- a/consensus/types/src/data/partial_data_column_sidecar.rs +++ b/consensus/types/src/data/partial_data_column_sidecar.rs @@ -5,7 +5,6 @@ use crate::{ execution::AbstractExecPayload, kzg_ext::KzgCommitments, state::BeaconStateError, - test_utils::TestRandom, }; use educe::Educe; use kzg::KzgProof; @@ -14,7 +13,6 @@ use ssz::BitList; use ssz_derive::{Decode, Encode}; use ssz_types::{FixedVector, ListEncodedOption, VariableList}; use std::fmt::Display; -use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -134,7 +132,7 @@ impl PartialDataColumnSidecar { derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive(Debug, Clone, Encode, Decode, TreeHash, TestRandom, Educe)] +#[derive(Debug, Clone, Encode, Decode, TreeHash, Educe)] #[educe(PartialEq, Eq, Hash(bound = "E: EthSpec"))] pub struct PartialDataColumnHeader { pub kzg_commitments: KzgCommitments, diff --git a/consensus/types/src/deposit/deposit.rs b/consensus/types/src/deposit/deposit.rs index 0b08bd6509..22dbdfbb71 100644 --- a/consensus/types/src/deposit/deposit.rs +++ b/consensus/types/src/deposit/deposit.rs @@ -2,11 +2,10 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::FixedVector; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use typenum::U33; -use crate::{core::Hash256, deposit::DepositData, fork::ForkName, test_utils::TestRandom}; +use crate::{core::Hash256, deposit::DepositData, fork::ForkName}; pub const DEPOSIT_TREE_DEPTH: usize = 32; @@ -14,9 +13,7 @@ pub const DEPOSIT_TREE_DEPTH: usize = 32; /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct Deposit { pub proof: FixedVector, diff --git a/consensus/types/src/deposit/deposit_data.rs b/consensus/types/src/deposit/deposit_data.rs index 51697f5d1a..bd39643ebd 100644 --- a/consensus/types/src/deposit/deposit_data.rs +++ b/consensus/types/src/deposit/deposit_data.rs @@ -2,23 +2,19 @@ use bls::{PublicKeyBytes, SecretKey, SignatureBytes}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{ChainSpec, Hash256, SignedRoot}, deposit::DepositMessage, fork::ForkName, - test_utils::TestRandom, }; /// The data supplied by the user to the deposit contract. /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct DepositData { pub pubkey: PublicKeyBytes, diff --git a/consensus/types/src/deposit/deposit_message.rs b/consensus/types/src/deposit/deposit_message.rs index 4495a5c023..9cb282e2d9 100644 --- a/consensus/types/src/deposit/deposit_message.rs +++ b/consensus/types/src/deposit/deposit_message.rs @@ -2,20 +2,18 @@ use bls::PublicKeyBytes; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{Hash256, SignedRoot}, fork::ForkName, - test_utils::TestRandom, }; /// The data supplied by the user to the deposit contract. /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct DepositMessage { pub pubkey: PublicKeyBytes, diff --git a/consensus/types/src/deposit/deposit_request.rs b/consensus/types/src/deposit/deposit_request.rs index 8d3c6e88ba..b17450a851 100644 --- a/consensus/types/src/deposit/deposit_request.rs +++ b/consensus/types/src/deposit/deposit_request.rs @@ -3,15 +3,12 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz::Encode; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{core::Hash256, fork::ForkName, test_utils::TestRandom}; +use crate::{core::Hash256, fork::ForkName}; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct DepositRequest { pub pubkey: PublicKeyBytes, diff --git a/consensus/types/src/deposit/deposit_tree_snapshot.rs b/consensus/types/src/deposit/deposit_tree_snapshot.rs index 24f41397a0..979f266d1b 100644 --- a/consensus/types/src/deposit/deposit_tree_snapshot.rs +++ b/consensus/types/src/deposit/deposit_tree_snapshot.rs @@ -3,11 +3,10 @@ use fixed_bytes::FixedBytesExtended; use int_to_bytes::int_to_bytes32; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; -use crate::{core::Hash256, deposit::DEPOSIT_TREE_DEPTH, test_utils::TestRandom}; +use crate::{core::Hash256, deposit::DEPOSIT_TREE_DEPTH}; -#[derive(Encode, Decode, Deserialize, Serialize, Clone, Debug, PartialEq, TestRandom)] +#[derive(Encode, Decode, Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct FinalizedExecutionBlock { pub deposit_root: Hash256, pub deposit_count: u64, @@ -26,7 +25,8 @@ impl From<&DepositTreeSnapshot> for FinalizedExecutionBlock { } } -#[derive(Encode, Decode, Deserialize, Serialize, Clone, Debug, PartialEq, TestRandom)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Encode, Decode, Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct DepositTreeSnapshot { pub finalized: Vec, pub deposit_root: Hash256, diff --git a/consensus/types/src/deposit/pending_deposit.rs b/consensus/types/src/deposit/pending_deposit.rs index 4c039af39c..ed0f866ecc 100644 --- a/consensus/types/src/deposit/pending_deposit.rs +++ b/consensus/types/src/deposit/pending_deposit.rs @@ -2,19 +2,15 @@ use bls::{PublicKeyBytes, SignatureBytes}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{Hash256, Slot}, fork::ForkName, - test_utils::TestRandom, }; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct PendingDeposit { pub pubkey: PublicKeyBytes, diff --git a/consensus/types/src/execution/bls_to_execution_change.rs b/consensus/types/src/execution/bls_to_execution_change.rs index de14f1b4c5..48a089bc63 100644 --- a/consensus/types/src/execution/bls_to_execution_change.rs +++ b/consensus/types/src/execution/bls_to_execution_change.rs @@ -2,20 +2,16 @@ use bls::{PublicKeyBytes, SecretKey}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{Address, ChainSpec, Domain, Hash256, SignedRoot}, execution::SignedBlsToExecutionChange, fork::ForkName, - test_utils::TestRandom, }; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct BlsToExecutionChange { #[serde(with = "serde_utils::quoted_u64")] diff --git a/consensus/types/src/execution/eth1_data.rs b/consensus/types/src/execution/eth1_data.rs index 89a4e634a6..f2a00ca87b 100644 --- a/consensus/types/src/execution/eth1_data.rs +++ b/consensus/types/src/execution/eth1_data.rs @@ -1,28 +1,16 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{core::Hash256, fork::ForkName, test_utils::TestRandom}; +use crate::{core::Hash256, fork::ForkName}; /// Contains data obtained from the Eth1 chain. /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive( - Debug, - PartialEq, - Clone, - Default, - Eq, - Hash, - Serialize, - Deserialize, - Encode, - Decode, - TreeHash, - TestRandom, + Debug, PartialEq, Clone, Default, Eq, Hash, Serialize, Deserialize, Encode, Decode, TreeHash, )] #[context_deserialize(ForkName)] pub struct Eth1Data { diff --git a/consensus/types/src/execution/execution_payload.rs b/consensus/types/src/execution/execution_payload.rs index c84a46874d..c444c03157 100644 --- a/consensus/types/src/execution/execution_payload.rs +++ b/consensus/types/src/execution/execution_payload.rs @@ -6,14 +6,12 @@ use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use ssz_types::{FixedVector, VariableList}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{Address, EthSpec, ExecutionBlockHash, Hash256, Slot}, fork::{ForkName, ForkVersionDecode}, state::BeaconStateError, - test_utils::TestRandom, withdrawal::Withdrawals, }; @@ -35,7 +33,6 @@ pub type Transactions = VariableList< Encode, Decode, TreeHash, - TestRandom, Educe, ), context_deserialize(ForkName), diff --git a/consensus/types/src/execution/execution_payload_bid.rs b/consensus/types/src/execution/execution_payload_bid.rs index b2438681c1..87097bbd3b 100644 --- a/consensus/types/src/execution/execution_payload_bid.rs +++ b/consensus/types/src/execution/execution_payload_bid.rs @@ -1,16 +1,12 @@ use crate::kzg_ext::KzgCommitments; -use crate::test_utils::TestRandom; use crate::{Address, EthSpec, ExecutionBlockHash, ForkName, Hash256, SignedRoot, Slot}; use context_deserialize::context_deserialize; use educe::Educe; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -#[derive( - Default, Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe, TestRandom, -)] +#[derive(Default, Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe)] #[cfg_attr( feature = "arbitrary", derive(arbitrary::Arbitrary), diff --git a/consensus/types/src/execution/execution_payload_envelope.rs b/consensus/types/src/execution/execution_payload_envelope.rs index a6d123bd21..87a0ea7a63 100644 --- a/consensus/types/src/execution/execution_payload_envelope.rs +++ b/consensus/types/src/execution/execution_payload_envelope.rs @@ -1,5 +1,4 @@ use crate::execution::{ExecutionPayloadGloas, ExecutionRequests}; -use crate::test_utils::TestRandom; use crate::{EthSpec, ForkName, Hash256, SignedRoot, Slot}; use context_deserialize::context_deserialize; use educe::Educe; @@ -7,10 +6,14 @@ use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; use ssz::{BYTES_PER_LENGTH_OFFSET, Encode as SszEncode}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -#[derive(Debug, Clone, Serialize, Encode, Decode, Deserialize, TestRandom, TreeHash, Educe)] +#[cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") +)] +#[derive(Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe)] #[educe(PartialEq, Hash(bound(E: EthSpec)))] #[context_deserialize(ForkName)] #[serde(bound = "E: EthSpec")] diff --git a/consensus/types/src/execution/execution_payload_header.rs b/consensus/types/src/execution/execution_payload_header.rs index 0b8556634a..54cc182448 100644 --- a/consensus/types/src/execution/execution_payload_header.rs +++ b/consensus/types/src/execution/execution_payload_header.rs @@ -6,7 +6,6 @@ use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use ssz_types::{FixedVector, VariableList}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -19,7 +18,6 @@ use crate::{ fork::ForkName, map_execution_payload_ref_into_execution_payload_header, state::BeaconStateError, - test_utils::TestRandom, }; #[superstruct( @@ -34,7 +32,6 @@ use crate::{ Encode, Decode, TreeHash, - TestRandom, Educe, ), educe(PartialEq, Hash(bound(E: EthSpec))), diff --git a/consensus/types/src/execution/execution_requests.rs b/consensus/types/src/execution/execution_requests.rs index 92d717778e..218b7edc17 100644 --- a/consensus/types/src/execution/execution_requests.rs +++ b/consensus/types/src/execution/execution_requests.rs @@ -6,7 +6,6 @@ use serde::{Deserialize, Serialize}; use ssz::Encode; use ssz_derive::{Decode, Encode}; use ssz_types::VariableList; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ @@ -14,7 +13,6 @@ use crate::{ core::{EthSpec, Hash256}, deposit::DepositRequest, fork::ForkName, - test_utils::TestRandom, withdrawal::WithdrawalRequest, }; @@ -30,9 +28,7 @@ pub type ConsolidationRequests = derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive( - Debug, Educe, Default, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, Educe, Default, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[serde(bound = "E: EthSpec")] #[educe(PartialEq, Eq, Hash(bound(E: EthSpec)))] #[context_deserialize(ForkName)] diff --git a/consensus/types/src/execution/payload.rs b/consensus/types/src/execution/payload.rs index c51369034c..0b3ba23e12 100644 --- a/consensus/types/src/execution/payload.rs +++ b/consensus/types/src/execution/payload.rs @@ -6,7 +6,6 @@ use ssz_derive::{Decode, Encode}; use ssz_types::VariableList; use std::{borrow::Cow, fmt::Debug, hash::Hash}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -22,7 +21,6 @@ use crate::{ fork::ForkName, map_execution_payload_into_blinded_payload, map_execution_payload_into_full_payload, state::BeaconStateError, - test_utils::TestRandom, }; #[derive(Debug, PartialEq)] @@ -71,7 +69,6 @@ pub trait OwnedExecPayload: + DeserializeOwned + Encode + Decode - + TestRandom + for<'a> arbitrary::Arbitrary<'a> + 'static { @@ -84,7 +81,6 @@ impl OwnedExecPayload for P where + DeserializeOwned + Encode + Decode - + TestRandom + for<'a> arbitrary::Arbitrary<'a> + 'static { @@ -93,19 +89,12 @@ impl OwnedExecPayload for P where /// `ExecPayload` functionality the requires ownership. #[cfg(not(feature = "arbitrary"))] pub trait OwnedExecPayload: - ExecPayload + Default + Serialize + DeserializeOwned + Encode + Decode + TestRandom + 'static + ExecPayload + Default + Serialize + DeserializeOwned + Encode + Decode + 'static { } #[cfg(not(feature = "arbitrary"))] impl OwnedExecPayload for P where - P: ExecPayload - + Default - + Serialize - + DeserializeOwned - + Encode - + Decode - + TestRandom - + 'static + P: ExecPayload + Default + Serialize + DeserializeOwned + Encode + Decode + 'static { } @@ -166,7 +155,6 @@ pub trait AbstractExecPayload: Deserialize, Encode, Decode, - TestRandom, TreeHash, Educe, ), @@ -533,7 +521,6 @@ impl TryFrom> for FullPayload { Deserialize, Encode, Decode, - TestRandom, TreeHash, Educe, ), diff --git a/consensus/types/src/execution/signed_bls_to_execution_change.rs b/consensus/types/src/execution/signed_bls_to_execution_change.rs index 535960fb3f..0ed7de5350 100644 --- a/consensus/types/src/execution/signed_bls_to_execution_change.rs +++ b/consensus/types/src/execution/signed_bls_to_execution_change.rs @@ -2,15 +2,12 @@ use bls::Signature; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{execution::BlsToExecutionChange, fork::ForkName, test_utils::TestRandom}; +use crate::{execution::BlsToExecutionChange, fork::ForkName}; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct SignedBlsToExecutionChange { pub message: BlsToExecutionChange, diff --git a/consensus/types/src/execution/signed_execution_payload_bid.rs b/consensus/types/src/execution/signed_execution_payload_bid.rs index 48da445332..3d4f45a267 100644 --- a/consensus/types/src/execution/signed_execution_payload_bid.rs +++ b/consensus/types/src/execution/signed_execution_payload_bid.rs @@ -1,15 +1,13 @@ use crate::execution::ExecutionPayloadBid; -use crate::test_utils::TestRandom; use crate::{EthSpec, ForkName}; use bls::Signature; use context_deserialize::context_deserialize; use educe::Educe; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -#[derive(TestRandom, TreeHash, Debug, Clone, Encode, Decode, Serialize, Deserialize, Educe)] +#[derive(TreeHash, Debug, Clone, Encode, Decode, Serialize, Deserialize, Educe)] #[cfg_attr( feature = "arbitrary", derive(arbitrary::Arbitrary), diff --git a/consensus/types/src/execution/signed_execution_payload_envelope.rs b/consensus/types/src/execution/signed_execution_payload_envelope.rs index 522c8b3f54..316a580476 100644 --- a/consensus/types/src/execution/signed_execution_payload_envelope.rs +++ b/consensus/types/src/execution/signed_execution_payload_envelope.rs @@ -1,4 +1,3 @@ -use crate::test_utils::TestRandom; use crate::{ BeaconState, BeaconStateError, ChainSpec, Domain, Epoch, EthSpec, ExecutionBlockHash, ExecutionPayloadEnvelope, Fork, ForkName, Hash256, SignedRoot, Slot, @@ -10,10 +9,14 @@ use educe::Educe; use serde::{Deserialize, Serialize}; use ssz::Encode; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -#[derive(Debug, Clone, Serialize, Encode, Decode, Deserialize, TestRandom, TreeHash, Educe)] +#[cfg_attr( + feature = "arbitrary", + derive(arbitrary::Arbitrary), + arbitrary(bound = "E: EthSpec") +)] +#[derive(Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe)] #[educe(PartialEq, Hash(bound(E: EthSpec)))] #[serde(bound = "E: EthSpec")] #[context_deserialize(ForkName)] diff --git a/consensus/types/src/exit/signed_voluntary_exit.rs b/consensus/types/src/exit/signed_voluntary_exit.rs index b49401a721..072541e766 100644 --- a/consensus/types/src/exit/signed_voluntary_exit.rs +++ b/consensus/types/src/exit/signed_voluntary_exit.rs @@ -2,18 +2,15 @@ use bls::Signature; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{exit::VoluntaryExit, fork::ForkName, test_utils::TestRandom}; +use crate::{exit::VoluntaryExit, fork::ForkName}; /// An exit voluntarily submitted a validator who wishes to withdraw. /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct SignedVoluntaryExit { pub message: VoluntaryExit, diff --git a/consensus/types/src/exit/voluntary_exit.rs b/consensus/types/src/exit/voluntary_exit.rs index 30c6a97c4d..fac0a4ad0b 100644 --- a/consensus/types/src/exit/voluntary_exit.rs +++ b/consensus/types/src/exit/voluntary_exit.rs @@ -2,23 +2,19 @@ use bls::SecretKey; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{ChainSpec, Domain, Epoch, Hash256, SignedRoot}, exit::SignedVoluntaryExit, fork::ForkName, - test_utils::TestRandom, }; /// An exit voluntarily submitted a validator who wishes to withdraw. /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct VoluntaryExit { /// Earliest epoch when voluntary exit can be processed. diff --git a/consensus/types/src/fork/fork.rs b/consensus/types/src/fork/fork.rs index 371b11e05c..675d61cc52 100644 --- a/consensus/types/src/fork/fork.rs +++ b/consensus/types/src/fork/fork.rs @@ -1,27 +1,16 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{core::Epoch, fork::ForkName, test_utils::TestRandom}; +use crate::{core::Epoch, fork::ForkName}; /// Specifies a fork of the `BeaconChain`, to prevent replay attacks. /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[derive( - Debug, - Clone, - Copy, - PartialEq, - Default, - Serialize, - Deserialize, - Encode, - Decode, - TreeHash, - TestRandom, + Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, Encode, Decode, TreeHash, )] #[context_deserialize(ForkName)] pub struct Fork { diff --git a/consensus/types/src/fork/fork_data.rs b/consensus/types/src/fork/fork_data.rs index 1b9c8bad9f..5f98132f62 100644 --- a/consensus/types/src/fork/fork_data.rs +++ b/consensus/types/src/fork/fork_data.rs @@ -1,22 +1,18 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{Hash256, SignedRoot}, fork::ForkName, - test_utils::TestRandom, }; /// Specifies a fork of the `BeaconChain`, to prevent replay attacks. /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, Clone, PartialEq, Default, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct ForkData { #[serde(with = "serde_utils::bytes_4_hex")] diff --git a/consensus/types/src/light_client/light_client_bootstrap.rs b/consensus/types/src/light_client/light_client_bootstrap.rs index fbcc0ef2b0..18ff246df7 100644 --- a/consensus/types/src/light_client/light_client_bootstrap.rs +++ b/consensus/types/src/light_client/light_client_bootstrap.rs @@ -7,7 +7,6 @@ use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use ssz_types::FixedVector; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ @@ -21,7 +20,6 @@ use crate::{ }, state::BeaconState, sync_committee::SyncCommittee, - test_utils::TestRandom, }; /// A LightClientBootstrap is the initializer we send over to light_client nodes @@ -29,17 +27,7 @@ use crate::{ #[superstruct( variants(Altair, Capella, Deneb, Electra, Fulu), variant_attributes( - derive( - Debug, - Clone, - Serialize, - Deserialize, - Educe, - Decode, - Encode, - TestRandom, - TreeHash, - ), + derive(Debug, Clone, Serialize, Deserialize, Educe, Decode, Encode, TreeHash,), educe(PartialEq), serde(bound = "E: EthSpec", deny_unknown_fields), cfg_attr( diff --git a/consensus/types/src/light_client/light_client_finality_update.rs b/consensus/types/src/light_client/light_client_finality_update.rs index b503785b85..42afbdfc4b 100644 --- a/consensus/types/src/light_client/light_client_finality_update.rs +++ b/consensus/types/src/light_client/light_client_finality_update.rs @@ -6,7 +6,6 @@ use ssz_derive::Decode; use ssz_derive::Encode; use ssz_types::FixedVector; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ @@ -19,23 +18,12 @@ use crate::{ LightClientHeaderElectra, LightClientHeaderFulu, }, sync_committee::SyncAggregate, - test_utils::TestRandom, }; #[superstruct( variants(Altair, Capella, Deneb, Electra, Fulu), variant_attributes( - derive( - Debug, - Clone, - Serialize, - Deserialize, - Educe, - Decode, - Encode, - TestRandom, - TreeHash, - ), + derive(Debug, Clone, Serialize, Deserialize, Educe, Decode, Encode, TreeHash,), educe(PartialEq), serde(bound = "E: EthSpec", deny_unknown_fields), cfg_attr( diff --git a/consensus/types/src/light_client/light_client_header.rs b/consensus/types/src/light_client/light_client_header.rs index fdf9f234ef..df6d884ba8 100644 --- a/consensus/types/src/light_client/light_client_header.rs +++ b/consensus/types/src/light_client/light_client_header.rs @@ -7,7 +7,6 @@ use ssz::Decode; use ssz_derive::{Decode, Encode}; use ssz_types::FixedVector; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ @@ -19,23 +18,12 @@ use crate::{ }, fork::ForkName, light_client::{ExecutionPayloadProofLen, LightClientError, consts::EXECUTION_PAYLOAD_INDEX}, - test_utils::TestRandom, }; #[superstruct( variants(Altair, Capella, Deneb, Electra, Fulu,), variant_attributes( - derive( - Debug, - Clone, - Serialize, - Deserialize, - Educe, - Decode, - Encode, - TestRandom, - TreeHash, - ), + derive(Debug, Clone, Serialize, Deserialize, Educe, Decode, Encode, TreeHash,), educe(PartialEq), serde(bound = "E: EthSpec", deny_unknown_fields), cfg_attr( diff --git a/consensus/types/src/light_client/light_client_optimistic_update.rs b/consensus/types/src/light_client/light_client_optimistic_update.rs index 139c4b6a08..f762c4ad61 100644 --- a/consensus/types/src/light_client/light_client_optimistic_update.rs +++ b/consensus/types/src/light_client/light_client_optimistic_update.rs @@ -4,7 +4,6 @@ use serde::{Deserialize, Deserializer, Serialize}; use ssz::{Decode, Encode}; use ssz_derive::{Decode, Encode}; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash::Hash256; use tree_hash_derive::TreeHash; @@ -17,7 +16,6 @@ use crate::{ LightClientHeaderDeneb, LightClientHeaderElectra, LightClientHeaderFulu, }, sync_committee::SyncAggregate, - test_utils::TestRandom, }; /// A LightClientOptimisticUpdate is the update we send on each slot, @@ -25,17 +23,7 @@ use crate::{ #[superstruct( variants(Altair, Capella, Deneb, Electra, Fulu), variant_attributes( - derive( - Debug, - Clone, - Serialize, - Deserialize, - Educe, - Decode, - Encode, - TestRandom, - TreeHash, - ), + derive(Debug, Clone, Serialize, Deserialize, Educe, Decode, Encode, TreeHash,), educe(PartialEq), serde(bound = "E: EthSpec", deny_unknown_fields), cfg_attr( diff --git a/consensus/types/src/light_client/light_client_update.rs b/consensus/types/src/light_client/light_client_update.rs index cd33f6ae54..0e7e285651 100644 --- a/consensus/types/src/light_client/light_client_update.rs +++ b/consensus/types/src/light_client/light_client_update.rs @@ -10,7 +10,6 @@ use ssz_derive::Decode; use ssz_derive::Encode; use ssz_types::FixedVector; use superstruct::superstruct; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use typenum::{U4, U5, U6, U7}; @@ -23,7 +22,6 @@ use crate::{ LightClientHeaderDeneb, LightClientHeaderElectra, LightClientHeaderFulu, }, sync_committee::{SyncAggregate, SyncCommittee}, - test_utils::TestRandom, }; pub type FinalizedRootProofLen = U6; @@ -47,17 +45,7 @@ type NextSyncCommitteeBranchElectra = FixedVector AttesterSlashing { } } -impl TestRandom for AttesterSlashing { - fn random_for_test(rng: &mut impl RngCore) -> Self { - if rng.random_bool(0.5) { - AttesterSlashing::Base(AttesterSlashingBase::random_for_test(rng)) - } else { - AttesterSlashing::Electra(AttesterSlashingElectra::random_for_test(rng)) - } - } -} - impl<'de, E: EthSpec> ContextDeserialize<'de, ForkName> for Vec> { fn context_deserialize(deserializer: D, context: ForkName) -> Result where diff --git a/consensus/types/src/slashing/proposer_slashing.rs b/consensus/types/src/slashing/proposer_slashing.rs index 697bd1a9aa..b5ffbc562c 100644 --- a/consensus/types/src/slashing/proposer_slashing.rs +++ b/consensus/types/src/slashing/proposer_slashing.rs @@ -1,18 +1,15 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{block::SignedBeaconBlockHeader, fork::ForkName, test_utils::TestRandom}; +use crate::{block::SignedBeaconBlockHeader, fork::ForkName}; /// Two conflicting proposals from the same proposer (validator). /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct ProposerSlashing { pub signed_header_1: SignedBeaconBlockHeader, diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index e821ca922b..4d2c7533ca 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -17,7 +17,6 @@ use ssz_types::{BitVector, FixedVector}; use std::collections::BTreeMap; use superstruct::superstruct; use swap_or_not_shuffle::compute_shuffled_index; -use test_random_derive::TestRandom; use tracing::instrument; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -50,7 +49,6 @@ use crate::{ get_active_validator_indices, }, sync_committee::{SyncCommittee, SyncDuty}, - test_utils::TestRandom, validator::Validator, withdrawal::PendingPartialWithdrawal, }; @@ -289,7 +287,6 @@ impl From for Hash256 { Encode, Decode, TreeHash, - TestRandom, CompareFields, ), serde(bound = "E: EthSpec", deny_unknown_fields), @@ -455,21 +452,21 @@ where // History #[metastruct(exclude_from(tree_lists))] pub latest_block_header: BeaconBlockHeader, - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[compare_fields(as_iter)] pub block_roots: Vector, - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[compare_fields(as_iter)] pub state_roots: Vector, // Frozen in Capella, replaced by historical_summaries - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[compare_fields(as_iter)] pub historical_roots: List, // Ethereum 1.0 chain data #[metastruct(exclude_from(tree_lists))] pub eth1_data: Eth1Data, - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub eth1_data_votes: List, #[superstruct(getter(copy))] #[metastruct(exclude_from(tree_lists))] @@ -478,42 +475,42 @@ where // Registry #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub validators: Validators, #[serde(with = "ssz_types::serde_utils::quoted_u64_var_list")] #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub balances: List, // Randomness - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub randao_mixes: Vector, // Slashings - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[serde(with = "ssz_types::serde_utils::quoted_u64_fixed_vec")] pub slashings: Vector, // Attestations (genesis fork only) #[superstruct(only(Base))] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub previous_epoch_attestations: List, E::MaxPendingAttestations>, #[superstruct(only(Base))] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub current_epoch_attestations: List, E::MaxPendingAttestations>, // Participation (Altair and later) #[compare_fields(as_iter)] #[superstruct(only(Altair, Bellatrix, Capella, Deneb, Electra, Fulu, Gloas))] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[compare_fields(as_iter)] pub previous_epoch_participation: List, #[superstruct(only(Altair, Bellatrix, Capella, Deneb, Electra, Fulu, Gloas))] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub current_epoch_participation: List, // Finality - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[metastruct(exclude_from(tree_lists))] pub justification_bits: BitVector, #[superstruct(getter(copy))] @@ -529,7 +526,7 @@ where // Inactivity #[serde(with = "ssz_types::serde_utils::quoted_u64_var_list")] #[superstruct(only(Altair, Bellatrix, Capella, Deneb, Electra, Fulu, Gloas))] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub inactivity_scores: List, // Light-client sync committees @@ -571,7 +568,7 @@ where )] #[metastruct(exclude_from(tree_lists))] pub latest_execution_payload_header: ExecutionPayloadHeaderFulu, - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Gloas))] #[metastruct(exclude_from(tree_lists))] pub latest_block_hash: ExecutionBlockHash, @@ -585,7 +582,7 @@ where pub next_withdrawal_validator_index: u64, // Deep history valid from Capella onwards. #[superstruct(only(Capella, Deneb, Electra, Fulu, Gloas))] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub historical_summaries: List, // Electra @@ -612,28 +609,28 @@ where #[metastruct(exclude_from(tree_lists))] pub earliest_consolidation_epoch: Epoch, #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Electra, Fulu, Gloas))] pub pending_deposits: List, #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Electra, Fulu, Gloas))] pub pending_partial_withdrawals: List, #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Electra, Fulu, Gloas))] pub pending_consolidations: List, // Fulu #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Fulu, Gloas))] #[serde(with = "ssz_types::serde_utils::quoted_u64_fixed_vec")] pub proposer_lookahead: Vector, // Gloas #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Gloas))] pub builders: List, @@ -642,33 +639,34 @@ where #[superstruct(only(Gloas), partial_getter(copy))] pub next_withdrawal_builder_index: BuilderIndex, - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Gloas))] #[metastruct(exclude_from(tree_lists))] pub execution_payload_availability: BitVector, #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Gloas))] pub builder_pending_payments: Vector, #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Gloas))] pub builder_pending_withdrawals: List, + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Gloas))] #[metastruct(exclude_from(tree_lists))] pub latest_execution_payload_bid: ExecutionPayloadBid, #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Gloas))] pub payload_expected_withdrawals: List, #[compare_fields(as_iter)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[superstruct(only(Gloas))] pub ptc_window: Vector, E::PtcWindowLength>, @@ -676,44 +674,44 @@ where #[serde(skip_serializing, skip_deserializing)] #[ssz(skip_serializing, skip_deserializing)] #[tree_hash(skip_hashing)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[metastruct(exclude)] pub total_active_balance: Option<(Epoch, u64)>, #[serde(skip_serializing, skip_deserializing)] #[ssz(skip_serializing, skip_deserializing)] #[tree_hash(skip_hashing)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[metastruct(exclude)] pub committee_caches: [Arc; CACHED_EPOCHS], #[serde(skip_serializing, skip_deserializing)] #[ssz(skip_serializing, skip_deserializing)] #[tree_hash(skip_hashing)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[metastruct(exclude)] pub progressive_balances_cache: ProgressiveBalancesCache, #[serde(skip_serializing, skip_deserializing)] #[ssz(skip_serializing, skip_deserializing)] #[tree_hash(skip_hashing)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[metastruct(exclude)] pub pubkey_cache: PubkeyCache, #[serde(skip_serializing, skip_deserializing)] #[ssz(skip_serializing, skip_deserializing)] #[tree_hash(skip_hashing)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[metastruct(exclude)] pub exit_cache: ExitCache, #[serde(skip_serializing, skip_deserializing)] #[ssz(skip_serializing, skip_deserializing)] #[tree_hash(skip_hashing)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[metastruct(exclude)] pub slashings_cache: SlashingsCache, /// Epoch cache of values that are useful for block processing that are static over an epoch. #[serde(skip_serializing, skip_deserializing)] #[ssz(skip_serializing, skip_deserializing)] #[tree_hash(skip_hashing)] - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] #[metastruct(exclude)] pub epoch_cache: EpochCache, } diff --git a/consensus/types/src/state/historical_batch.rs b/consensus/types/src/state/historical_batch.rs index 0167d64f62..6e6e31eceb 100644 --- a/consensus/types/src/state/historical_batch.rs +++ b/consensus/types/src/state/historical_batch.rs @@ -2,13 +2,11 @@ use context_deserialize::context_deserialize; use milhouse::Vector; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{EthSpec, Hash256}, fork::ForkName, - test_utils::TestRandom, }; /// Historical block and state roots. @@ -19,12 +17,12 @@ use crate::{ derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct HistoricalBatch { - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub block_roots: Vector, - #[test_random(default)] + #[cfg_attr(feature = "arbitrary", arbitrary(default))] pub state_roots: Vector, } diff --git a/consensus/types/src/state/historical_summary.rs b/consensus/types/src/state/historical_summary.rs index f520e46483..80c65316c9 100644 --- a/consensus/types/src/state/historical_summary.rs +++ b/consensus/types/src/state/historical_summary.rs @@ -2,7 +2,6 @@ use compare_fields::CompareFields; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash::TreeHash; use tree_hash_derive::TreeHash; @@ -10,7 +9,6 @@ use crate::{ core::{EthSpec, Hash256}, fork::ForkName, state::BeaconState, - test_utils::TestRandom, }; /// `HistoricalSummary` matches the components of the phase0 `HistoricalBatch` @@ -28,7 +26,6 @@ use crate::{ Encode, Decode, TreeHash, - TestRandom, CompareFields, Clone, Copy, diff --git a/consensus/types/src/sync_committee/contribution_and_proof.rs b/consensus/types/src/sync_committee/contribution_and_proof.rs index 2a344b89de..2b0a1c63f0 100644 --- a/consensus/types/src/sync_committee/contribution_and_proof.rs +++ b/consensus/types/src/sync_committee/contribution_and_proof.rs @@ -2,14 +2,12 @@ use bls::{SecretKey, Signature}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{ChainSpec, EthSpec, Hash256, SignedRoot}, fork::{Fork, ForkName}, sync_committee::{SyncCommitteeContribution, SyncSelectionProof}, - test_utils::TestRandom, }; /// A Validators aggregate sync committee contribution and selection proof. @@ -18,7 +16,7 @@ use crate::{ derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TestRandom, TreeHash)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash)] #[serde(bound = "E: EthSpec")] #[context_deserialize(ForkName)] pub struct ContributionAndProof { diff --git a/consensus/types/src/sync_committee/signed_contribution_and_proof.rs b/consensus/types/src/sync_committee/signed_contribution_and_proof.rs index 0027003b9f..c788b01b13 100644 --- a/consensus/types/src/sync_committee/signed_contribution_and_proof.rs +++ b/consensus/types/src/sync_committee/signed_contribution_and_proof.rs @@ -2,14 +2,12 @@ use bls::{SecretKey, Signature}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{ChainSpec, Domain, EthSpec, Hash256, SignedRoot}, fork::{Fork, ForkName}, sync_committee::{ContributionAndProof, SyncCommitteeContribution, SyncSelectionProof}, - test_utils::TestRandom, }; /// A Validators signed contribution proof to publish on the `sync_committee_contribution_and_proof` @@ -19,7 +17,7 @@ use crate::{ derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TestRandom, TreeHash)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash)] #[serde(bound = "E: EthSpec")] #[context_deserialize(ForkName)] pub struct SignedContributionAndProof { diff --git a/consensus/types/src/sync_committee/sync_aggregate.rs b/consensus/types/src/sync_committee/sync_aggregate.rs index e5848aa22c..263faf1286 100644 --- a/consensus/types/src/sync_committee/sync_aggregate.rs +++ b/consensus/types/src/sync_committee/sync_aggregate.rs @@ -5,14 +5,12 @@ use safe_arith::{ArithError, SafeArith}; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::BitVector; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{EthSpec, consts::altair::SYNC_COMMITTEE_SUBNET_COUNT}, fork::ForkName, sync_committee::SyncCommitteeContribution, - test_utils::TestRandom, }; #[derive(Debug, PartialEq)] @@ -32,7 +30,7 @@ impl From for Error { derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, Educe)] +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, Educe)] #[educe(PartialEq, Hash(bound(E: EthSpec)))] #[serde(bound = "E: EthSpec")] #[context_deserialize(ForkName)] diff --git a/consensus/types/src/sync_committee/sync_aggregator_selection_data.rs b/consensus/types/src/sync_committee/sync_aggregator_selection_data.rs index e905ca036b..c828e874e0 100644 --- a/consensus/types/src/sync_committee/sync_aggregator_selection_data.rs +++ b/consensus/types/src/sync_committee/sync_aggregator_selection_data.rs @@ -1,19 +1,15 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{SignedRoot, Slot}, fork::ForkName, - test_utils::TestRandom, }; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Clone, Serialize, Deserialize, Hash, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Hash, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct SyncAggregatorSelectionData { pub slot: Slot, diff --git a/consensus/types/src/sync_committee/sync_committee.rs b/consensus/types/src/sync_committee/sync_committee.rs index 5448411800..413258f77d 100644 --- a/consensus/types/src/sync_committee/sync_committee.rs +++ b/consensus/types/src/sync_committee/sync_committee.rs @@ -6,10 +6,9 @@ use safe_arith::{ArithError, SafeArith}; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::FixedVector; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{core::EthSpec, fork::ForkName, sync_committee::SyncSubnetId, test_utils::TestRandom}; +use crate::{core::EthSpec, fork::ForkName, sync_committee::SyncSubnetId}; #[derive(Debug, PartialEq)] pub enum Error { @@ -32,7 +31,7 @@ impl From for Error { derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[serde(bound = "E: EthSpec")] #[context_deserialize(ForkName)] pub struct SyncCommittee { diff --git a/consensus/types/src/sync_committee/sync_committee_contribution.rs b/consensus/types/src/sync_committee/sync_committee_contribution.rs index 09376fbe5c..c646d0b7e3 100644 --- a/consensus/types/src/sync_committee/sync_committee_contribution.rs +++ b/consensus/types/src/sync_committee/sync_committee_contribution.rs @@ -3,14 +3,12 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::BitVector; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{EthSpec, Hash256, SignedRoot, Slot, SlotData}, fork::ForkName, sync_committee::SyncCommitteeMessage, - test_utils::TestRandom, }; #[derive(Debug, PartialEq)] @@ -26,7 +24,7 @@ pub enum Error { derive(arbitrary::Arbitrary), arbitrary(bound = "E: EthSpec") )] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash)] #[serde(bound = "E: EthSpec")] #[context_deserialize(ForkName)] pub struct SyncCommitteeContribution { @@ -79,7 +77,7 @@ impl SyncCommitteeContribution { impl SignedRoot for Hash256 {} /// This is not in the spec, but useful for determining uniqueness of sync committee contributions -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash)] pub struct SyncContributionData { pub slot: Slot, pub beacon_block_root: Hash256, diff --git a/consensus/types/src/sync_committee/sync_committee_message.rs b/consensus/types/src/sync_committee/sync_committee_message.rs index ed42555c43..87291c59c4 100644 --- a/consensus/types/src/sync_committee/sync_committee_message.rs +++ b/consensus/types/src/sync_committee/sync_committee_message.rs @@ -2,18 +2,16 @@ use bls::{SecretKey, Signature}; use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{ChainSpec, Domain, EthSpec, Hash256, SignedRoot, Slot, SlotData}, fork::{Fork, ForkName}, - test_utils::TestRandom, }; /// The data upon which a `SyncCommitteeContribution` is based. #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct SyncCommitteeMessage { pub slot: Slot, diff --git a/consensus/types/src/test_utils/generate_random_block_and_blobs.rs b/consensus/types/src/test_utils/generate_random_block_and_blobs.rs index 2a38b5be1f..c511fd72e7 100644 --- a/consensus/types/src/test_utils/generate_random_block_and_blobs.rs +++ b/consensus/types/src/test_utils/generate_random_block_and_blobs.rs @@ -1,6 +1,5 @@ -use bls::Signature; +use arbitrary::Arbitrary; use kzg::{KzgCommitment, KzgProof}; -use rand::Rng; use crate::{ block::{BeaconBlock, SignedBeaconBlock}, @@ -9,22 +8,22 @@ use crate::{ execution::FullPayload, fork::{ForkName, map_fork_name}, kzg_ext::{KzgCommitments, KzgProofs}, - test_utils::TestRandom, }; type BlobsBundle = (KzgCommitments, KzgProofs, BlobsList); +#[allow(clippy::type_complexity)] pub fn generate_rand_block_and_blobs( fork_name: ForkName, num_blobs: usize, - rng: &mut impl Rng, -) -> (SignedBeaconBlock>, Vec>) { - let inner = map_fork_name!(fork_name, BeaconBlock, <_>::random_for_test(rng)); - let mut block = SignedBeaconBlock::from_block(inner, Signature::random_for_test(rng)); + u: &mut arbitrary::Unstructured, +) -> arbitrary::Result<(SignedBeaconBlock>, Vec>)> { + let inner = map_fork_name!(fork_name, BeaconBlock, <_>::arbitrary(u)?); + let mut block = SignedBeaconBlock::from_block(inner, bls::Signature::arbitrary(u)?); let mut blob_sidecars = vec![]; if block.fork_name_unchecked() < ForkName::Deneb { - return (block, blob_sidecars); + return Ok((block, blob_sidecars)); } let (commitments, proofs, blobs) = generate_blobs::(num_blobs).unwrap(); @@ -50,7 +49,7 @@ pub fn generate_rand_block_and_blobs( .unwrap(), }); } - (block, blob_sidecars) + Ok((block, blob_sidecars)) } pub fn generate_blobs(n_blobs: usize) -> Result, String> { @@ -74,13 +73,13 @@ pub fn generate_blobs(n_blobs: usize) -> Result, Stri #[cfg(test)] mod test { use super::*; - use rand::rng; use ssz_types::FixedVector; #[test] fn test_verify_blob_inclusion_proof() { + let mut u = crate::test_utils::test_unstructured(); let (_block, blobs) = - generate_rand_block_and_blobs::(ForkName::Deneb, 2, &mut rng()); + generate_rand_block_and_blobs::(ForkName::Deneb, 2, &mut u).unwrap(); for blob in blobs { assert!(blob.verify_blob_sidecar_inclusion_proof()); } @@ -88,8 +87,9 @@ mod test { #[test] fn test_verify_blob_inclusion_proof_from_existing_proof() { + let mut u = crate::test_utils::test_unstructured(); let (block, mut blob_sidecars) = - generate_rand_block_and_blobs::(ForkName::Deneb, 1, &mut rng()); + generate_rand_block_and_blobs::(ForkName::Deneb, 1, &mut u).unwrap(); let BlobSidecar { index, blob, @@ -105,11 +105,12 @@ mod test { #[test] fn test_verify_blob_inclusion_proof_invalid() { + let mut u = crate::test_utils::test_unstructured(); let (_block, blobs) = - generate_rand_block_and_blobs::(ForkName::Deneb, 1, &mut rng()); + generate_rand_block_and_blobs::(ForkName::Deneb, 1, &mut u).unwrap(); for mut blob in blobs { - blob.kzg_commitment_inclusion_proof = FixedVector::random_for_test(&mut rng()); + blob.kzg_commitment_inclusion_proof = FixedVector::arbitrary(&mut u).unwrap(); assert!(!blob.verify_blob_sidecar_inclusion_proof()); } } diff --git a/consensus/types/src/test_utils/macros.rs b/consensus/types/src/test_utils/macros.rs index 662527f5a4..09afd27ae3 100644 --- a/consensus/types/src/test_utils/macros.rs +++ b/consensus/types/src/test_utils/macros.rs @@ -14,10 +14,8 @@ macro_rules! ssz_tests { #[test] pub fn test_ssz_round_trip() { use ssz::{Decode, ssz_encode}; - use $crate::test_utils::{SeedableRng, TestRandom, XorShiftRng}; - let mut rng = XorShiftRng::from_seed([42; 16]); - let original = <$type>::random_for_test(&mut rng); + let original: $type = $crate::test_utils::test_arbitrary_instance(); let bytes = ssz_encode(&original); let decoded = <$type>::from_ssz_bytes(&bytes).unwrap(); @@ -33,10 +31,8 @@ macro_rules! tree_hash_tests { #[test] pub fn test_tree_hash_root() { use tree_hash::TreeHash; - use $crate::test_utils::{SeedableRng, TestRandom, XorShiftRng}; - let mut rng = XorShiftRng::from_seed([42; 16]); - let original = <$type>::random_for_test(&mut rng); + let original: $type = $crate::test_utils::test_arbitrary_instance(); // Tree hashing should not panic. original.tree_hash_root(); diff --git a/consensus/types/src/test_utils/mod.rs b/consensus/types/src/test_utils/mod.rs index c4409b4392..5cf728be66 100644 --- a/consensus/types/src/test_utils/mod.rs +++ b/consensus/types/src/test_utils/mod.rs @@ -5,15 +5,36 @@ mod macros; mod generate_deterministic_keypairs; #[cfg(test)] mod generate_random_block_and_blobs; -mod test_random; pub use generate_deterministic_keypairs::generate_deterministic_keypair; pub use generate_deterministic_keypairs::generate_deterministic_keypairs; pub use generate_deterministic_keypairs::load_keypairs_from_yaml; -pub use test_random::{TestRandom, test_random_instance}; -pub use rand::{RngCore, SeedableRng}; -pub use rand_xorshift::XorShiftRng; +/// Deterministic 256 KiB seed. +#[cfg(feature = "arbitrary")] +static SEED: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + use rand::RngCore; + use rand::SeedableRng; + let mut bytes = vec![0u8; 256 * 1024]; + rand_xorshift::XorShiftRng::from_seed([0x42; 16]).fill_bytes(&mut bytes); + bytes +}); + +/// Generates an arbitrary instance of `T` from a deterministic seed. +/// Suitable for one-shot test instance creation. +#[cfg(feature = "arbitrary")] +pub fn test_arbitrary_instance<'a, T: arbitrary::Arbitrary<'a>>() -> T { + let mut u = arbitrary::Unstructured::new(&SEED); + T::arbitrary(&mut u).expect("sufficient bytes for arbitrary generation") +} + +/// Returns an `Unstructured` from a deterministic seed. +/// Use this when you need to pass an `Unstructured` to helpers like +/// `generate_rand_block_and_blobs`. +#[cfg(feature = "arbitrary")] +pub fn test_unstructured() -> arbitrary::Unstructured<'static> { + arbitrary::Unstructured::new(&SEED) +} use ssz::{Decode, Encode, ssz_encode}; use std::fmt::Debug; diff --git a/consensus/types/src/test_utils/test_random/address.rs b/consensus/types/src/test_utils/test_random/address.rs deleted file mode 100644 index 2f601cb91e..0000000000 --- a/consensus/types/src/test_utils/test_random/address.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::{core::Address, test_utils::TestRandom}; - -impl TestRandom for Address { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - let mut key_bytes = vec![0; 20]; - rng.fill_bytes(&mut key_bytes); - Address::from_slice(&key_bytes[..]) - } -} diff --git a/consensus/types/src/test_utils/test_random/aggregate_signature.rs b/consensus/types/src/test_utils/test_random/aggregate_signature.rs deleted file mode 100644 index f9f3dd9567..0000000000 --- a/consensus/types/src/test_utils/test_random/aggregate_signature.rs +++ /dev/null @@ -1,12 +0,0 @@ -use bls::{AggregateSignature, Signature}; - -use crate::test_utils::TestRandom; - -impl TestRandom for AggregateSignature { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - let signature = Signature::random_for_test(rng); - let mut aggregate_signature = AggregateSignature::infinity(); - aggregate_signature.add_assign(&signature); - aggregate_signature - } -} diff --git a/consensus/types/src/test_utils/test_random/bitfield.rs b/consensus/types/src/test_utils/test_random/bitfield.rs deleted file mode 100644 index 762f41eb34..0000000000 --- a/consensus/types/src/test_utils/test_random/bitfield.rs +++ /dev/null @@ -1,43 +0,0 @@ -use smallvec::smallvec; -use ssz_types::{BitList, BitVector}; -use typenum::Unsigned; - -use crate::test_utils::TestRandom; - -impl TestRandom for BitList { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - let initial_len = std::cmp::max(1, N::to_usize().div_ceil(8)); - let mut raw_bytes = smallvec![0; initial_len]; - rng.fill_bytes(&mut raw_bytes); - - let non_zero_bytes = raw_bytes - .iter() - .enumerate() - .rev() - .find_map(|(i, byte)| (*byte > 0).then_some(i + 1)) - .unwrap_or(0); - - if non_zero_bytes < initial_len { - raw_bytes.truncate(non_zero_bytes); - } - - Self::from_bytes(raw_bytes).expect("we generate a valid BitList") - } -} - -impl TestRandom for BitVector { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - let mut raw_bytes = smallvec![0; std::cmp::max(1, N::to_usize().div_ceil(8))]; - rng.fill_bytes(&mut raw_bytes); - // If N isn't divisible by 8 - // zero out bits greater than N - if let Some(last_byte) = raw_bytes.last_mut() { - let mut mask = 0; - for i in 0..N::to_usize() % 8 { - mask |= 1 << i; - } - *last_byte &= mask; - } - Self::from_bytes(raw_bytes).expect("we generate a valid BitVector") - } -} diff --git a/consensus/types/src/test_utils/test_random/hash256.rs b/consensus/types/src/test_utils/test_random/hash256.rs deleted file mode 100644 index 4d7570fb55..0000000000 --- a/consensus/types/src/test_utils/test_random/hash256.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::{core::Hash256, test_utils::TestRandom}; - -impl TestRandom for Hash256 { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - let mut key_bytes = vec![0; 32]; - rng.fill_bytes(&mut key_bytes); - Hash256::from_slice(&key_bytes[..]) - } -} diff --git a/consensus/types/src/test_utils/test_random/kzg_commitment.rs b/consensus/types/src/test_utils/test_random/kzg_commitment.rs deleted file mode 100644 index 31e316a198..0000000000 --- a/consensus/types/src/test_utils/test_random/kzg_commitment.rs +++ /dev/null @@ -1,9 +0,0 @@ -use kzg::KzgCommitment; - -use crate::test_utils::TestRandom; - -impl TestRandom for KzgCommitment { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - KzgCommitment(<[u8; 48] as TestRandom>::random_for_test(rng)) - } -} diff --git a/consensus/types/src/test_utils/test_random/kzg_proof.rs b/consensus/types/src/test_utils/test_random/kzg_proof.rs deleted file mode 100644 index 4465d5ab39..0000000000 --- a/consensus/types/src/test_utils/test_random/kzg_proof.rs +++ /dev/null @@ -1,11 +0,0 @@ -use kzg::{BYTES_PER_COMMITMENT, KzgProof}; - -use crate::test_utils::TestRandom; - -impl TestRandom for KzgProof { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - let mut bytes = [0; BYTES_PER_COMMITMENT]; - rng.fill_bytes(&mut bytes); - Self(bytes) - } -} diff --git a/consensus/types/src/test_utils/test_random/mod.rs b/consensus/types/src/test_utils/test_random/mod.rs deleted file mode 100644 index 41812593fa..0000000000 --- a/consensus/types/src/test_utils/test_random/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -mod address; -mod aggregate_signature; -mod bitfield; -mod hash256; -mod kzg_commitment; -mod kzg_proof; -mod public_key; -mod public_key_bytes; -mod secret_key; -mod signature; -mod signature_bytes; -mod test_random; -mod uint256; - -pub use test_random::{TestRandom, test_random_instance}; diff --git a/consensus/types/src/test_utils/test_random/public_key.rs b/consensus/types/src/test_utils/test_random/public_key.rs deleted file mode 100644 index 9d287c23d7..0000000000 --- a/consensus/types/src/test_utils/test_random/public_key.rs +++ /dev/null @@ -1,9 +0,0 @@ -use bls::{PublicKey, SecretKey}; - -use crate::test_utils::TestRandom; - -impl TestRandom for PublicKey { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - SecretKey::random_for_test(rng).public_key() - } -} diff --git a/consensus/types/src/test_utils/test_random/public_key_bytes.rs b/consensus/types/src/test_utils/test_random/public_key_bytes.rs deleted file mode 100644 index 587c3baf8f..0000000000 --- a/consensus/types/src/test_utils/test_random/public_key_bytes.rs +++ /dev/null @@ -1,17 +0,0 @@ -use bls::{PUBLIC_KEY_BYTES_LEN, PublicKey, PublicKeyBytes}; - -use crate::test_utils::TestRandom; - -impl TestRandom for PublicKeyBytes { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - //50-50 chance for signature to be "valid" or invalid - if bool::random_for_test(rng) { - //valid signature - PublicKeyBytes::from(PublicKey::random_for_test(rng)) - } else { - //invalid signature, just random bytes - PublicKeyBytes::deserialize(&<[u8; PUBLIC_KEY_BYTES_LEN]>::random_for_test(rng)) - .unwrap() - } - } -} diff --git a/consensus/types/src/test_utils/test_random/secret_key.rs b/consensus/types/src/test_utils/test_random/secret_key.rs deleted file mode 100644 index a8295d968a..0000000000 --- a/consensus/types/src/test_utils/test_random/secret_key.rs +++ /dev/null @@ -1,11 +0,0 @@ -use bls::SecretKey; - -use crate::test_utils::TestRandom; - -impl TestRandom for SecretKey { - fn random_for_test(_rng: &mut impl rand::RngCore) -> Self { - // TODO: Not deterministic generation. Using `SecretKey::deserialize` results in - // `BlstError(BLST_BAD_ENCODING)`, need to debug with blst source on what encoding expects. - SecretKey::random() - } -} diff --git a/consensus/types/src/test_utils/test_random/signature.rs b/consensus/types/src/test_utils/test_random/signature.rs deleted file mode 100644 index 006aba9650..0000000000 --- a/consensus/types/src/test_utils/test_random/signature.rs +++ /dev/null @@ -1,12 +0,0 @@ -use bls::Signature; - -use crate::test_utils::TestRandom; - -impl TestRandom for Signature { - fn random_for_test(_rng: &mut impl rand::RngCore) -> Self { - // TODO: `SecretKey::random_for_test` does not return a deterministic signature. Since this - // signature will not pass verification we could just return the generator point or the - // generator point multiplied by a random scalar if we want disctint signatures. - Signature::infinity().expect("infinity signature is valid") - } -} diff --git a/consensus/types/src/test_utils/test_random/signature_bytes.rs b/consensus/types/src/test_utils/test_random/signature_bytes.rs deleted file mode 100644 index 6992e57467..0000000000 --- a/consensus/types/src/test_utils/test_random/signature_bytes.rs +++ /dev/null @@ -1,16 +0,0 @@ -use bls::{SIGNATURE_BYTES_LEN, Signature, SignatureBytes}; - -use crate::test_utils::TestRandom; - -impl TestRandom for SignatureBytes { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - //50-50 chance for signature to be "valid" or invalid - if bool::random_for_test(rng) { - //valid signature - SignatureBytes::from(Signature::random_for_test(rng)) - } else { - //invalid signature, just random bytes - SignatureBytes::deserialize(&<[u8; SIGNATURE_BYTES_LEN]>::random_for_test(rng)).unwrap() - } - } -} diff --git a/consensus/types/src/test_utils/test_random/test_random.rs b/consensus/types/src/test_utils/test_random/test_random.rs deleted file mode 100644 index 101fbec51b..0000000000 --- a/consensus/types/src/test_utils/test_random/test_random.rs +++ /dev/null @@ -1,140 +0,0 @@ -use std::{marker::PhantomData, sync::Arc}; - -use rand::{RngCore, SeedableRng}; -use rand_xorshift::XorShiftRng; -use smallvec::{SmallVec, smallvec}; -use ssz_types::VariableList; -use typenum::Unsigned; - -pub fn test_random_instance() -> T { - let mut rng = XorShiftRng::from_seed([0x42; 16]); - T::random_for_test(&mut rng) -} - -pub trait TestRandom { - fn random_for_test(rng: &mut impl RngCore) -> Self; -} - -impl TestRandom for PhantomData { - fn random_for_test(_rng: &mut impl RngCore) -> Self { - PhantomData - } -} - -impl TestRandom for bool { - fn random_for_test(rng: &mut impl RngCore) -> Self { - (rng.next_u32() % 2) == 1 - } -} - -impl TestRandom for u64 { - fn random_for_test(rng: &mut impl RngCore) -> Self { - rng.next_u64() - } -} - -impl TestRandom for u32 { - fn random_for_test(rng: &mut impl RngCore) -> Self { - rng.next_u32() - } -} - -impl TestRandom for u8 { - fn random_for_test(rng: &mut impl RngCore) -> Self { - rng.next_u32().to_be_bytes()[0] - } -} - -impl TestRandom for usize { - fn random_for_test(rng: &mut impl RngCore) -> Self { - rng.next_u32() as usize - } -} - -impl TestRandom for Vec -where - U: TestRandom, -{ - fn random_for_test(rng: &mut impl RngCore) -> Self { - let mut output = vec![]; - - for _ in 0..(usize::random_for_test(rng) % 4) { - output.push(::random_for_test(rng)); - } - - output - } -} - -impl TestRandom for Arc -where - U: TestRandom, -{ - fn random_for_test(rng: &mut impl RngCore) -> Self { - Arc::new(U::random_for_test(rng)) - } -} - -impl TestRandom for ssz_types::FixedVector -where - T: TestRandom, -{ - fn random_for_test(rng: &mut impl RngCore) -> Self { - Self::new( - (0..N::to_usize()) - .map(|_| T::random_for_test(rng)) - .collect(), - ) - .expect("N items provided") - } -} - -impl TestRandom for VariableList -where - T: TestRandom, -{ - fn random_for_test(rng: &mut impl RngCore) -> Self { - let mut output = vec![]; - - if N::to_usize() != 0 { - for _ in 0..(usize::random_for_test(rng) % std::cmp::min(4, N::to_usize())) { - output.push(::random_for_test(rng)); - } - } - - output.try_into().unwrap() - } -} - -impl TestRandom for SmallVec<[U; N]> -where - U: TestRandom, -{ - fn random_for_test(rng: &mut impl RngCore) -> Self { - let mut output = smallvec![]; - - for _ in 0..(usize::random_for_test(rng) % 4) { - output.push(::random_for_test(rng)); - } - - output - } -} - -macro_rules! impl_test_random_for_u8_array { - ($len: expr) => { - impl TestRandom for [u8; $len] { - fn random_for_test(rng: &mut impl RngCore) -> Self { - let mut bytes = [0; $len]; - rng.fill_bytes(&mut bytes); - bytes - } - } - }; -} - -impl_test_random_for_u8_array!(3); -impl_test_random_for_u8_array!(4); -impl_test_random_for_u8_array!(32); -impl_test_random_for_u8_array!(48); -impl_test_random_for_u8_array!(96); diff --git a/consensus/types/src/test_utils/test_random/uint256.rs b/consensus/types/src/test_utils/test_random/uint256.rs deleted file mode 100644 index eccf476595..0000000000 --- a/consensus/types/src/test_utils/test_random/uint256.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::{core::Uint256, test_utils::TestRandom}; - -impl TestRandom for Uint256 { - fn random_for_test(rng: &mut impl rand::RngCore) -> Self { - let mut key_bytes = [0; 32]; - rng.fill_bytes(&mut key_bytes); - Self::from_le_slice(&key_bytes[..]) - } -} diff --git a/consensus/types/src/validator/validator.rs b/consensus/types/src/validator/validator.rs index 5c5bfc761f..a56093c0b5 100644 --- a/consensus/types/src/validator/validator.rs +++ b/consensus/types/src/validator/validator.rs @@ -3,7 +3,6 @@ use context_deserialize::context_deserialize; use fixed_bytes::FixedBytesExtended; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ @@ -11,16 +10,13 @@ use crate::{ core::{Address, ChainSpec, Epoch, EthSpec, Hash256}, fork::ForkName, state::BeaconState, - test_utils::TestRandom, }; /// Information about a `BeaconChain` validator. /// /// Spec v0.12.1 #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, TestRandom, TreeHash, -)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct Validator { pub pubkey: PublicKeyBytes, diff --git a/consensus/types/src/withdrawal/pending_partial_withdrawal.rs b/consensus/types/src/withdrawal/pending_partial_withdrawal.rs index cd866369a4..0b3842808d 100644 --- a/consensus/types/src/withdrawal/pending_partial_withdrawal.rs +++ b/consensus/types/src/withdrawal/pending_partial_withdrawal.rs @@ -1,15 +1,12 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{core::Epoch, fork::ForkName, test_utils::TestRandom}; +use crate::{core::Epoch, fork::ForkName}; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct PendingPartialWithdrawal { #[serde(with = "serde_utils::quoted_u64")] diff --git a/consensus/types/src/withdrawal/withdrawal.rs b/consensus/types/src/withdrawal/withdrawal.rs index d75bd4f501..da69227626 100644 --- a/consensus/types/src/withdrawal/withdrawal.rs +++ b/consensus/types/src/withdrawal/withdrawal.rs @@ -2,19 +2,15 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; use ssz_types::VariableList; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; use crate::{ core::{Address, EthSpec}, fork::ForkName, - test_utils::TestRandom, }; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct Withdrawal { #[serde(with = "serde_utils::quoted_u64")] diff --git a/consensus/types/src/withdrawal/withdrawal_request.rs b/consensus/types/src/withdrawal/withdrawal_request.rs index 98a40016f9..a89fe9b825 100644 --- a/consensus/types/src/withdrawal/withdrawal_request.rs +++ b/consensus/types/src/withdrawal/withdrawal_request.rs @@ -3,15 +3,12 @@ use context_deserialize::context_deserialize; use serde::{Deserialize, Serialize}; use ssz::Encode; use ssz_derive::{Decode, Encode}; -use test_random_derive::TestRandom; use tree_hash_derive::TreeHash; -use crate::{core::Address, fork::ForkName, test_utils::TestRandom}; +use crate::{core::Address, fork::ForkName}; #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] -#[derive( - Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash, TestRandom, -)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, Encode, Decode, TreeHash)] #[context_deserialize(ForkName)] pub struct WithdrawalRequest { #[serde(with = "serde_utils::address_hex")] diff --git a/consensus/types/tests/state.rs b/consensus/types/tests/state.rs index 5e223092cf..2168da9afc 100644 --- a/consensus/types/tests/state.rs +++ b/consensus/types/tests/state.rs @@ -2,15 +2,14 @@ use std::ops::Mul; use std::sync::LazyLock; +use arbitrary::Arbitrary; use beacon_chain::test_utils::{BeaconChainHarness, EphemeralHarnessType}; use bls::Keypair; use fixed_bytes::FixedBytesExtended; use milhouse::Vector; -use rand::SeedableRng; -use rand_xorshift::XorShiftRng; use ssz::Encode; use swap_or_not_shuffle::compute_shuffled_index; -use types::test_utils::{TestRandom, generate_deterministic_keypairs}; +use types::test_utils::generate_deterministic_keypairs; use types::*; pub const MAX_VALIDATOR_COUNT: usize = 129; @@ -315,7 +314,7 @@ fn decode_base_and_altair() { type E = MainnetEthSpec; let spec = E::default_spec(); - let rng = &mut XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let fork_epoch = spec.altair_fork_epoch.unwrap(); @@ -328,7 +327,7 @@ fn decode_base_and_altair() { { let good_base_state: BeaconState = BeaconState::Base(BeaconStateBase { slot: base_slot, - ..<_>::random_for_test(rng) + ..<_>::arbitrary(&mut u).unwrap() }); // It's invalid to have a base state with a slot higher than the fork slot. let bad_base_state = { @@ -351,7 +350,7 @@ fn decode_base_and_altair() { let good_altair_state: BeaconState = BeaconState::Altair(BeaconStateAltair { slot: altair_slot, - ..<_>::random_for_test(rng) + ..<_>::arbitrary(&mut u).unwrap() }); // It's invalid to have an Altair state with a slot lower than the fork slot. let bad_altair_state = { diff --git a/crypto/bls/src/macros.rs b/crypto/bls/src/macros.rs index 58b1ec7d6c..4f2be22dc3 100644 --- a/crypto/bls/src/macros.rs +++ b/crypto/bls/src/macros.rs @@ -165,13 +165,26 @@ macro_rules! impl_debug { /// Contains the functions required for an `Arbitrary` implementation. /// /// Does not include the `Impl` section since it gets very complicated when it comes to generics. +/// +/// For `GenericPublicKeyBytes` and `GenericSignatureBytes`, this implementation works correctly +/// without falling back to zeros. +/// +/// For `GenericPublicKey`, `GenericSignature` and `GenericAggregateSignature`, this implementation +/// will almost always fail and fallback to zeros. This matches the behavior of the previous +/// `TestRandom` impls. +/// +/// TODO: For proper fuzzing, this implementation needs more consideration on how to +/// arbitrarily construct valid types. #[cfg(feature = "arbitrary")] macro_rules! impl_arbitrary { ($byte_size: expr) => { fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result { let mut bytes = [0u8; $byte_size]; u.fill_buffer(&mut bytes)?; - Self::deserialize(&bytes).map_err(|_| arbitrary::Error::IncorrectFormat) + Ok(Self::deserialize(&bytes).unwrap_or_else(|_| { + // All-zeros is the "empty" encoding accepted by every BLS type. + Self::deserialize(&[0u8; $byte_size]).expect("all-zeros is a valid encoding") + })) } }; } diff --git a/validator_client/doppelganger_service/Cargo.toml b/validator_client/doppelganger_service/Cargo.toml index 66b27eb39d..a0c579e11f 100644 --- a/validator_client/doppelganger_service/Cargo.toml +++ b/validator_client/doppelganger_service/Cargo.toml @@ -19,5 +19,7 @@ types = { workspace = true } validator_store = { workspace = true } [dev-dependencies] +arbitrary = { workspace = true } futures = { workspace = true } logging = { workspace = true } +types = { workspace = true, features = ["arbitrary"] } diff --git a/validator_client/doppelganger_service/src/lib.rs b/validator_client/doppelganger_service/src/lib.rs index 600ae82c54..0842638bfa 100644 --- a/validator_client/doppelganger_service/src/lib.rs +++ b/validator_client/doppelganger_service/src/lib.rs @@ -598,14 +598,12 @@ impl DoppelgangerService { #[cfg(test)] mod test { use super::*; + use arbitrary::Arbitrary; use futures::executor::block_on; use slot_clock::TestingSlotClock; use std::future; use std::time::Duration; - use types::{ - MainnetEthSpec, - test_utils::{SeedableRng, TestRandom, XorShiftRng}, - }; + use types::MainnetEthSpec; use validator_store::DoppelgangerStatus; const DEFAULT_VALIDATORS: usize = 8; @@ -641,12 +639,12 @@ mod test { impl TestBuilder { fn build(self) -> TestScenario { - let mut rng = XorShiftRng::from_seed([42; 16]); + let mut u = types::test_utils::test_unstructured(); let slot_clock = TestingSlotClock::new(Slot::new(0), GENESIS_TIME, SLOT_DURATION); TestScenario { validators: (0..self.validator_count) - .map(|_| PublicKeyBytes::random_for_test(&mut rng)) + .map(|_| PublicKeyBytes::arbitrary(&mut u).unwrap()) .collect(), doppelganger: DoppelgangerService::default(), slot_clock, From 31e5f308c3acb86dabfe7da5979d56601a2c3b8d Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 6 May 2026 05:25:46 +0300 Subject: [PATCH 166/189] Generalise reconstruct_historic_states for ranged replay (#9222) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/store/src/reconstruct.rs | 143 ++++++++++++++++----------- 1 file changed, 85 insertions(+), 58 deletions(-) diff --git a/beacon_node/store/src/reconstruct.rs b/beacon_node/store/src/reconstruct.rs index 7aca692ef9..04a519af02 100644 --- a/beacon_node/store/src/reconstruct.rs +++ b/beacon_node/store/src/reconstruct.rs @@ -1,7 +1,8 @@ //! Implementation of historic state reconstruction (given complete block history). +use crate::forwards_iter::FrozenForwardsIterator; use crate::hot_cold_store::{HotColdDB, HotColdDBError}; use crate::metrics; -use crate::{Error, ItemStore}; +use crate::{DBColumn, Error, ItemStore}; use itertools::{Itertools, process_results}; use state_processing::{ BlockSignatureStrategy, ConsensusContext, VerifyBlockRoot, per_block_processing, @@ -9,7 +10,7 @@ use state_processing::{ }; use std::sync::Arc; use tracing::{debug, info}; -use types::EthSpec; +use types::{EthSpec, Slot}; impl HotColdDB where @@ -35,13 +36,6 @@ where }); } - debug!( - start_slot = %anchor.state_lower_limit, - "Starting state reconstruction batch" - ); - - let _t = metrics::start_timer(&metrics::STORE_BEACON_RECONSTRUCTION_TIME); - // Iterate blocks from the state lower limit to the upper limit. let split = self.get_split_info(); let lower_limit_slot = anchor.state_lower_limit; @@ -56,20 +50,86 @@ where // If `num_blocks` is not specified iterate all blocks. Add 1 so that we end on an epoch // boundary when `num_blocks` is a multiple of an epoch boundary. We want to be *inclusive* // of the state at slot `lower_limit_slot + num_blocks`. - let block_root_iter = self - .forwards_block_roots_iterator_until(lower_limit_slot, upper_limit_slot - 1, || { - Err(Error::StateShouldNotBeRequired(upper_limit_slot - 1)) - })? - .take(num_blocks.map_or(usize::MAX, |n| n + 1)); + let to_slot = num_blocks + .map(|n| std::cmp::min(lower_limit_slot + n as u64 + 1, upper_limit_slot)) + .unwrap_or(upper_limit_slot); + + let on_commit = |slot: Slot| -> Result<(), Error> { + info!( + %slot, + remaining = %(upper_limit_slot - 1 - slot), + "State reconstruction in progress" + ); + + // Update anchor. + let old_anchor = anchor.clone(); + let reconstruction_complete = slot + 1 == upper_limit_slot; + + if reconstruction_complete { + // The two limits have met in the middle! We're done! + let new_anchor = old_anchor.as_archive_anchor(); + self.compare_and_set_anchor_info_with_write(old_anchor, new_anchor)?; + } else { + // The lower limit has been raised, store it. + anchor.state_lower_limit = slot; + self.compare_and_set_anchor_info_with_write(old_anchor, anchor.clone())?; + } + + Ok(()) + }; + + self.reconstruct_historic_states_on_range(lower_limit_slot, to_slot, on_commit)?; + + // Check that the split point wasn't mutated during the state reconstruction process. + // It shouldn't have been, due to the serialization of requests through the store migrator, + // so this is just a paranoid check. + let latest_split = self.get_split_info(); + if split != latest_split { + return Err(Error::SplitPointModified(latest_split.slot, split.slot)); + } + + Ok(()) + } + + /// Reconstruct historic states for the slot range `(with_state_at_slot, to_slot)`. + /// + /// Loads the state at `with_state_at_slot` and replays blocks up to and including slot + /// `to_slot - 1`, writing all intermediate states to the freezer DB. + /// + /// The `BeaconBlockRoots` column must be populated for the range before this is called. + /// + /// `on_commit(slot)` is invoked after each atomic commit (whenever the hierarchy says to + /// commit, plus once at the final slot) so callers can update anchor metadata or log + /// progress. + pub fn reconstruct_historic_states_on_range( + self: &Arc, + with_state_at_slot: Slot, + to_slot: Slot, + mut on_commit: impl FnMut(Slot) -> Result<(), Error>, + ) -> Result<(), Error> { + debug!( + from_slot = %(with_state_at_slot + 1), + %to_slot, + "Starting state reconstruction batch" + ); + + let _t = metrics::start_timer(&metrics::STORE_BEACON_RECONSTRUCTION_TIME); + + // Iterate from `with_state_at_slot` so `tuple_windows` gives us the predecessor block + // root at each step for skip detection. + let block_root_iter = FrozenForwardsIterator::new( + self, + DBColumn::BeaconBlockRoots, + with_state_at_slot, + to_slot, + )?; // The state to be advanced. - let mut state = self.load_cold_state_by_slot(lower_limit_slot)?; - + let mut state = self.load_cold_state_by_slot(with_state_at_slot)?; state.build_caches(&self.spec)?; process_results(block_root_iter, |iter| -> Result<(), Error> { let mut io_batch = vec![]; - let mut prev_state_root = None; for ((prev_block_root, _), (block_root, slot)) in iter.tuple_windows() { @@ -114,32 +174,16 @@ where // Stage state for storage in freezer DB. self.store_cold_state(&state_root, &state, &mut io_batch)?; - let batch_complete = - num_blocks.is_some_and(|n_blocks| slot == lower_limit_slot + n_blocks as u64); - let reconstruction_complete = slot + 1 == upper_limit_slot; + let batch_complete = slot + 1 == to_slot; // Commit the I/O batch if: // // - The diff/snapshot for this slot is required for future slots, or - // - The reconstruction batch is complete (we are about to return), or - // - Reconstruction is complete. - if self.hierarchy.should_commit_immediately(slot)? - || batch_complete - || reconstruction_complete - { - info!( - %slot, - remaining = %(upper_limit_slot - 1 - slot), - "State reconstruction in progress" - ); - + // - The reconstruction batch is complete (we are about to return). + if self.hierarchy.should_commit_immediately(slot)? || batch_complete { self.cold_db.do_atomically(std::mem::take(&mut io_batch))?; - // Update anchor. - let old_anchor = anchor.clone(); - - if reconstruction_complete { - // The two limits have met in the middle! We're done! + if batch_complete { // Perform one last integrity check on the state reached. let computed_state_root = state.update_tree_hash_cache()?; if computed_state_root != state_root { @@ -149,23 +193,15 @@ where computed: computed_state_root, }); } - - let new_anchor = old_anchor.as_archive_anchor(); - self.compare_and_set_anchor_info_with_write(old_anchor, new_anchor)?; - - return Ok(()); - } else { - // The lower limit has been raised, store it. - anchor.state_lower_limit = slot; - - self.compare_and_set_anchor_info_with_write(old_anchor, anchor.clone())?; } + on_commit(slot)?; + // If this is the end of the batch, return Ok. The caller will run another // batch when there is idle capacity. if batch_complete { debug!( - start_slot = %lower_limit_slot, + start_slot = %(with_state_at_slot + 1), end_slot = %slot, "Finished state reconstruction batch" ); @@ -174,19 +210,10 @@ where } } - // Should always reach the `upper_limit_slot` or the end of the batch and return early - // above. + // Should always reach `to_slot` or the end of the batch and return early above. Err(Error::StateReconstructionLogicError) })??; - // Check that the split point wasn't mutated during the state reconstruction process. - // It shouldn't have been, due to the serialization of requests through the store migrator, - // so this is just a paranoid check. - let latest_split = self.get_split_info(); - if split != latest_split { - return Err(Error::SplitPointModified(latest_split.slot, split.slot)); - } - Ok(()) } } From 7148bfcdd1389ea6410193654758f843572e57ac Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 6 May 2026 20:41:01 -0600 Subject: [PATCH 167/189] Implement beacon_blocks_by_head (#9237) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/beacon_processor/src/lib.rs | 10 + .../src/scheduler/work_queue.rs | 5 + .../src/peer_manager/mod.rs | 3 + .../lighthouse_network/src/rpc/codec.rs | 51 +++- .../lighthouse_network/src/rpc/config.rs | 9 + .../lighthouse_network/src/rpc/methods.rs | 37 ++- .../lighthouse_network/src/rpc/protocol.rs | 32 +++ .../src/rpc/rate_limiter.rs | 15 + .../src/service/api_types.rs | 7 + .../lighthouse_network/src/service/mod.rs | 12 + .../src/network_beacon_processor/mod.rs | 24 +- .../network_beacon_processor/rpc_methods.rs | 264 +++++++++++++++++- .../src/network_beacon_processor/tests.rs | 164 ++++++++++- beacon_node/network/src/router.rs | 12 + 14 files changed, 637 insertions(+), 8 deletions(-) diff --git a/beacon_node/beacon_processor/src/lib.rs b/beacon_node/beacon_processor/src/lib.rs index ea87e9bc71..25944bcf8a 100644 --- a/beacon_node/beacon_processor/src/lib.rs +++ b/beacon_node/beacon_processor/src/lib.rs @@ -431,6 +431,7 @@ pub enum Work { Status(BlockingFn), BlocksByRangeRequest(AsyncFn), BlocksByRootsRequest(AsyncFn), + BlocksByHeadRequest(AsyncFn), PayloadEnvelopesByRangeRequest(AsyncFn), PayloadEnvelopesByRootRequest(AsyncFn), BlobsByRangeRequest(BlockingFn), @@ -491,6 +492,7 @@ pub enum WorkType { Status, BlocksByRangeRequest, BlocksByRootsRequest, + BlocksByHeadRequest, PayloadEnvelopesByRangeRequest, PayloadEnvelopesByRootRequest, BlobsByRangeRequest, @@ -553,6 +555,7 @@ impl Work { Work::Status(_) => WorkType::Status, Work::BlocksByRangeRequest(_) => WorkType::BlocksByRangeRequest, Work::BlocksByRootsRequest(_) => WorkType::BlocksByRootsRequest, + Work::BlocksByHeadRequest(_) => WorkType::BlocksByHeadRequest, Work::PayloadEnvelopesByRangeRequest(_) => WorkType::PayloadEnvelopesByRangeRequest, Work::PayloadEnvelopesByRootRequest(_) => WorkType::PayloadEnvelopesByRootRequest, Work::BlobsByRangeRequest(_) => WorkType::BlobsByRangeRequest, @@ -1000,6 +1003,8 @@ impl BeaconProcessor { Some(item) } else if let Some(item) = work_queues.block_broots_queue.pop() { Some(item) + } else if let Some(item) = work_queues.block_bhead_queue.pop() { + Some(item) } else if let Some(item) = work_queues.blob_brange_queue.pop() { Some(item) } else if let Some(item) = work_queues.blob_broots_queue.pop() { @@ -1206,6 +1211,9 @@ impl BeaconProcessor { Work::BlocksByRootsRequest { .. } => { work_queues.block_broots_queue.push(work, work_id) } + Work::BlocksByHeadRequest { .. } => { + work_queues.block_bhead_queue.push(work, work_id) + } Work::PayloadEnvelopesByRangeRequest { .. } => work_queues .payload_envelopes_brange_queue .push(work, work_id), @@ -1331,6 +1339,7 @@ impl BeaconProcessor { WorkType::Status => work_queues.status_queue.len(), WorkType::BlocksByRangeRequest => work_queues.block_brange_queue.len(), WorkType::BlocksByRootsRequest => work_queues.block_broots_queue.len(), + WorkType::BlocksByHeadRequest => work_queues.block_bhead_queue.len(), WorkType::PayloadEnvelopesByRangeRequest => { work_queues.payload_envelopes_brange_queue.len() } @@ -1531,6 +1540,7 @@ impl BeaconProcessor { } Work::BlocksByRangeRequest(work) | Work::BlocksByRootsRequest(work) + | Work::BlocksByHeadRequest(work) | Work::PayloadEnvelopesByRangeRequest(work) | Work::PayloadEnvelopesByRootRequest(work) => task_spawner.spawn_async(work), Work::ChainSegmentBackfill(process_fn) => { diff --git a/beacon_node/beacon_processor/src/scheduler/work_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_queue.rs index f7163d538b..eb57b97df2 100644 --- a/beacon_node/beacon_processor/src/scheduler/work_queue.rs +++ b/beacon_node/beacon_processor/src/scheduler/work_queue.rs @@ -132,6 +132,7 @@ pub struct BeaconProcessorQueueLengths { status_queue: usize, block_brange_queue: usize, block_broots_queue: usize, + block_bhead_queue: usize, blob_broots_queue: usize, blob_brange_queue: usize, dcbroots_queue: usize, @@ -206,6 +207,7 @@ impl BeaconProcessorQueueLengths { status_queue: 1024, block_brange_queue: 1024, block_broots_queue: 1024, + block_bhead_queue: 1024, blob_broots_queue: 1024, blob_brange_queue: 1024, dcbroots_queue: 1024, @@ -263,6 +265,7 @@ pub struct WorkQueues { pub status_queue: FifoQueue>, pub block_brange_queue: FifoQueue>, pub block_broots_queue: FifoQueue>, + pub block_bhead_queue: FifoQueue>, pub payload_envelopes_brange_queue: FifoQueue>, pub payload_envelopes_broots_queue: FifoQueue>, pub blob_broots_queue: FifoQueue>, @@ -334,6 +337,7 @@ impl WorkQueues { let status_queue = FifoQueue::new(queue_lengths.status_queue); let block_brange_queue = FifoQueue::new(queue_lengths.block_brange_queue); let block_broots_queue = FifoQueue::new(queue_lengths.block_broots_queue); + let block_bhead_queue = FifoQueue::new(queue_lengths.block_bhead_queue); let blob_broots_queue = FifoQueue::new(queue_lengths.blob_broots_queue); let blob_brange_queue = FifoQueue::new(queue_lengths.blob_brange_queue); let dcbroots_queue = FifoQueue::new(queue_lengths.dcbroots_queue); @@ -399,6 +403,7 @@ impl WorkQueues { status_queue, block_brange_queue, block_broots_queue, + block_bhead_queue, blob_broots_queue, blob_brange_queue, dcbroots_queue, diff --git a/beacon_node/lighthouse_network/src/peer_manager/mod.rs b/beacon_node/lighthouse_network/src/peer_manager/mod.rs index d7285c5c8e..6b5144fa6f 100644 --- a/beacon_node/lighthouse_network/src/peer_manager/mod.rs +++ b/beacon_node/lighthouse_network/src/peer_manager/mod.rs @@ -589,6 +589,7 @@ impl PeerManager { Protocol::Ping => PeerAction::MidToleranceError, Protocol::BlocksByRange => PeerAction::MidToleranceError, Protocol::BlocksByRoot => PeerAction::MidToleranceError, + Protocol::BlocksByHead => PeerAction::MidToleranceError, Protocol::BlobsByRange => PeerAction::MidToleranceError, Protocol::PayloadEnvelopesByRange => PeerAction::MidToleranceError, Protocol::PayloadEnvelopesByRoot => PeerAction::MidToleranceError, @@ -617,6 +618,7 @@ impl PeerManager { Protocol::Ping => PeerAction::Fatal, Protocol::BlocksByRange => return, Protocol::BlocksByRoot => return, + Protocol::BlocksByHead => return, Protocol::PayloadEnvelopesByRange => return, Protocol::PayloadEnvelopesByRoot => return, Protocol::BlobsByRange => return, @@ -642,6 +644,7 @@ impl PeerManager { Protocol::Ping => PeerAction::LowToleranceError, Protocol::BlocksByRange => PeerAction::MidToleranceError, Protocol::BlocksByRoot => PeerAction::MidToleranceError, + Protocol::BlocksByHead => PeerAction::MidToleranceError, Protocol::PayloadEnvelopesByRange => PeerAction::MidToleranceError, Protocol::PayloadEnvelopesByRoot => PeerAction::MidToleranceError, Protocol::BlobsByRange => PeerAction::MidToleranceError, diff --git a/beacon_node/lighthouse_network/src/rpc/codec.rs b/beacon_node/lighthouse_network/src/rpc/codec.rs index 75e035ae82..ba95fff5e8 100644 --- a/beacon_node/lighthouse_network/src/rpc/codec.rs +++ b/beacon_node/lighthouse_network/src/rpc/codec.rs @@ -18,7 +18,7 @@ use tokio_util::codec::{Decoder, Encoder}; use types::SignedExecutionPayloadEnvelope; use types::{ BlobSidecar, ChainSpec, DataColumnSidecar, DataColumnsByRootIdentifier, EthSpec, ForkContext, - ForkName, Hash256, LightClientBootstrap, LightClientFinalityUpdate, + ForkName, ForkVersionDecode, Hash256, LightClientBootstrap, LightClientFinalityUpdate, LightClientOptimisticUpdate, LightClientUpdate, SignedBeaconBlock, SignedBeaconBlockAltair, SignedBeaconBlockBase, SignedBeaconBlockBellatrix, SignedBeaconBlockCapella, SignedBeaconBlockDeneb, SignedBeaconBlockElectra, SignedBeaconBlockFulu, @@ -77,6 +77,7 @@ impl SSZSnappyInboundCodec { }, RpcSuccessResponse::BlocksByRange(res) => res.as_ssz_bytes(), RpcSuccessResponse::BlocksByRoot(res) => res.as_ssz_bytes(), + RpcSuccessResponse::BlocksByHead(res) => res.as_ssz_bytes(), RpcSuccessResponse::PayloadEnvelopesByRange(res) => res.as_ssz_bytes(), RpcSuccessResponse::PayloadEnvelopesByRoot(res) => res.as_ssz_bytes(), RpcSuccessResponse::BlobsByRange(res) => res.as_ssz_bytes(), @@ -359,6 +360,7 @@ impl Encoder> for SSZSnappyOutboundCodec { BlocksByRootRequest::V1(req) => req.block_roots.as_ssz_bytes(), BlocksByRootRequest::V2(req) => req.block_roots.as_ssz_bytes(), }, + RequestType::BlocksByHead(req) => req.as_ssz_bytes(), RequestType::PayloadEnvelopesByRange(req) => req.as_ssz_bytes(), RequestType::PayloadEnvelopesByRoot(req) => req.beacon_block_roots.as_ssz_bytes(), RequestType::BlobsByRange(req) => req.as_ssz_bytes(), @@ -553,6 +555,9 @@ fn handle_rpc_request( )?, }), ))), + SupportedProtocol::BlocksByHeadV1 => Ok(Some(RequestType::BlocksByHead( + BlocksByHeadRequest::from_ssz_bytes(decoded_buffer)?, + ))), SupportedProtocol::PayloadEnvelopesByRangeV1 => { Ok(Some(RequestType::PayloadEnvelopesByRange( PayloadEnvelopesByRangeRequest::from_ssz_bytes(decoded_buffer)?, @@ -943,6 +948,18 @@ fn handle_rpc_response( ), )), }, + SupportedProtocol::BlocksByHeadV1 => match fork_name { + Some(fork_name) => Ok(Some(RpcSuccessResponse::BlocksByHead(Arc::new( + SignedBeaconBlock::from_ssz_bytes_by_fork(decoded_buffer, fork_name)?, + )))), + None => Err(RPCError::ErrorResponse( + RpcErrorResponse::InvalidRequest, + format!( + "No context bytes provided for {:?} response", + versioned_protocol + ), + )), + }, } } @@ -1319,6 +1336,9 @@ mod tests { RequestType::BlocksByRoot(bbroot) => { assert_eq!(decoded, RequestType::BlocksByRoot(bbroot)) } + RequestType::BlocksByHead(bbhead) => { + assert_eq!(decoded, RequestType::BlocksByHead(bbhead)) + } RequestType::BlobsByRange(blbrange) => { assert_eq!(decoded, RequestType::BlobsByRange(blbrange)) } @@ -1867,6 +1887,31 @@ mod tests { ); } + // BlocksByHead is introduced in Fulu but the response is just `SignedBeaconBlock`, + // so the codec must accept blocks of any fork variant — the chain a Fulu peer walks + // back may straddle the Fulu boundary and include pre-Fulu canonical blocks. + #[test] + fn test_blocks_by_head_decodes_all_forks() { + let chain_spec = spec_with_all_forks_enabled(); + for (block, fork) in [ + (empty_base_block(&chain_spec), ForkName::Base), + (altair_block(&chain_spec), ForkName::Altair), + (bellatrix_block_small(&chain_spec), ForkName::Bellatrix), + ] { + let block_arc = Arc::new(block); + assert_eq!( + encode_then_decode_response( + SupportedProtocol::BlocksByHeadV1, + RpcResponse::Success(RpcSuccessResponse::BlocksByHead(block_arc.clone())), + fork, + &chain_spec, + ), + Ok(Some(RpcSuccessResponse::BlocksByHead(block_arc))), + "BlocksByHeadV1 must round-trip a {fork} block" + ); + } + } + // Test RPCResponse encoding/decoding for V2 messages #[test] fn test_context_bytes_v2() { @@ -2063,6 +2108,10 @@ mod tests { RequestType::BlobsByRange(blbrange_request()), RequestType::DataColumnsByRange(dcbrange_request()), RequestType::MetaData(MetadataRequest::new_v2()), + RequestType::BlocksByHead(BlocksByHeadRequest { + beacon_root: Hash256::zero(), + count: 32, + }), ]; for req in requests.iter() { for fork_name in ForkName::list_all() { diff --git a/beacon_node/lighthouse_network/src/rpc/config.rs b/beacon_node/lighthouse_network/src/rpc/config.rs index 9e1c6541ec..59f0b8e9a2 100644 --- a/beacon_node/lighthouse_network/src/rpc/config.rs +++ b/beacon_node/lighthouse_network/src/rpc/config.rs @@ -89,6 +89,7 @@ pub struct RateLimiterConfig { pub(super) goodbye_quota: Quota, pub(super) blocks_by_range_quota: Quota, pub(super) blocks_by_root_quota: Quota, + pub(super) blocks_by_head_quota: Quota, pub(super) payload_envelopes_by_range_quota: Quota, pub(super) payload_envelopes_by_root_quota: Quota, pub(super) blobs_by_range_quota: Quota, @@ -113,6 +114,8 @@ impl RateLimiterConfig { Quota::n_every(NonZeroU64::new(128).unwrap(), 10); pub const DEFAULT_BLOCKS_BY_ROOT_QUOTA: Quota = Quota::n_every(NonZeroU64::new(128).unwrap(), 10); + pub const DEFAULT_BLOCKS_BY_HEAD_QUOTA: Quota = + Quota::n_every(NonZeroU64::new(128).unwrap(), 10); pub const DEFAULT_PAYLOAD_ENVELOPES_BY_RANGE_QUOTA: Quota = Quota::n_every(NonZeroU64::new(128).unwrap(), 10); pub const DEFAULT_PAYLOAD_ENVELOPES_BY_ROOT_QUOTA: Quota = @@ -143,6 +146,7 @@ impl Default for RateLimiterConfig { goodbye_quota: Self::DEFAULT_GOODBYE_QUOTA, blocks_by_range_quota: Self::DEFAULT_BLOCKS_BY_RANGE_QUOTA, blocks_by_root_quota: Self::DEFAULT_BLOCKS_BY_ROOT_QUOTA, + blocks_by_head_quota: Self::DEFAULT_BLOCKS_BY_HEAD_QUOTA, payload_envelopes_by_range_quota: Self::DEFAULT_PAYLOAD_ENVELOPES_BY_RANGE_QUOTA, payload_envelopes_by_root_quota: Self::DEFAULT_PAYLOAD_ENVELOPES_BY_ROOT_QUOTA, blobs_by_range_quota: Self::DEFAULT_BLOBS_BY_RANGE_QUOTA, @@ -177,6 +181,7 @@ impl Debug for RateLimiterConfig { .field("goodbye", fmt_q!(&self.goodbye_quota)) .field("blocks_by_range", fmt_q!(&self.blocks_by_range_quota)) .field("blocks_by_root", fmt_q!(&self.blocks_by_root_quota)) + .field("blocks_by_head", fmt_q!(&self.blocks_by_head_quota)) .field( "payload_envelopes_by_range", fmt_q!(&self.payload_envelopes_by_range_quota), @@ -213,6 +218,7 @@ impl FromStr for RateLimiterConfig { let mut goodbye_quota = None; let mut blocks_by_range_quota = None; let mut blocks_by_root_quota = None; + let mut blocks_by_head_quota = None; let mut payload_envelopes_by_range_quota = None; let mut payload_envelopes_by_root_quota = None; let mut blobs_by_range_quota = None; @@ -232,6 +238,7 @@ impl FromStr for RateLimiterConfig { Protocol::Goodbye => goodbye_quota = goodbye_quota.or(quota), Protocol::BlocksByRange => blocks_by_range_quota = blocks_by_range_quota.or(quota), Protocol::BlocksByRoot => blocks_by_root_quota = blocks_by_root_quota.or(quota), + Protocol::BlocksByHead => blocks_by_head_quota = blocks_by_head_quota.or(quota), Protocol::PayloadEnvelopesByRange => { payload_envelopes_by_range_quota = payload_envelopes_by_range_quota.or(quota) } @@ -274,6 +281,8 @@ impl FromStr for RateLimiterConfig { .unwrap_or(Self::DEFAULT_BLOCKS_BY_RANGE_QUOTA), blocks_by_root_quota: blocks_by_root_quota .unwrap_or(Self::DEFAULT_BLOCKS_BY_ROOT_QUOTA), + blocks_by_head_quota: blocks_by_head_quota + .unwrap_or(Self::DEFAULT_BLOCKS_BY_HEAD_QUOTA), payload_envelopes_by_range_quota: payload_envelopes_by_range_quota .unwrap_or(Self::DEFAULT_PAYLOAD_ENVELOPES_BY_RANGE_QUOTA), payload_envelopes_by_root_quota: payload_envelopes_by_root_quota diff --git a/beacon_node/lighthouse_network/src/rpc/methods.rs b/beacon_node/lighthouse_network/src/rpc/methods.rs index baabf48683..f3f294d913 100644 --- a/beacon_node/lighthouse_network/src/rpc/methods.rs +++ b/beacon_node/lighthouse_network/src/rpc/methods.rs @@ -488,6 +488,18 @@ impl From for OldBlocksByRangeRequest { } } +/// Request a contiguous range of beacon blocks by walking the parent chain of `beacon_root`. +/// +/// New in Fulu (see consensus-specs PR 5181). The responder walks the parent chain of +/// `beacon_root` (inclusive) and emits up to `count` blocks in descending slot order. +#[derive(Encode, Decode, Clone, Debug, PartialEq)] +pub struct BlocksByHeadRequest { + /// The block root to start the parent walk from (inclusive). + pub beacon_root: Hash256, + /// The maximum number of blocks to return. + pub count: u64, +} + /// Request a number of beacon block bodies from a peer. #[superstruct(variants(V1, V2), variant_attributes(derive(Clone, Debug, PartialEq)))] #[derive(Clone, Debug, PartialEq)] @@ -622,6 +634,9 @@ pub enum RpcSuccessResponse { /// A response to a get BLOCKS_BY_ROOT request. BlocksByRoot(Arc>), + /// A response to a get BEACON_BLOCKS_BY_HEAD request. + BlocksByHead(Arc>), + /// A response to a get EXECUTION_PAYLOAD_ENVELOPES_BY_RANGE request. A None response signifies /// the end of the batch. PayloadEnvelopesByRange(Arc>), @@ -669,6 +684,9 @@ pub enum ResponseTermination { /// Blocks by root stream termination. BlocksByRoot, + /// Blocks by head stream termination. + BlocksByHead, + /// Execution payload envelopes by range stream termination. PayloadEnvelopesByRange, @@ -696,6 +714,7 @@ impl ResponseTermination { match self { ResponseTermination::BlocksByRange => Protocol::BlocksByRange, ResponseTermination::BlocksByRoot => Protocol::BlocksByRoot, + ResponseTermination::BlocksByHead => Protocol::BlocksByHead, ResponseTermination::PayloadEnvelopesByRange => Protocol::PayloadEnvelopesByRange, ResponseTermination::PayloadEnvelopesByRoot => Protocol::PayloadEnvelopesByRoot, ResponseTermination::BlobsByRange => Protocol::BlobsByRange, @@ -793,6 +812,7 @@ impl RpcSuccessResponse { RpcSuccessResponse::Status(_) => Protocol::Status, RpcSuccessResponse::BlocksByRange(_) => Protocol::BlocksByRange, RpcSuccessResponse::BlocksByRoot(_) => Protocol::BlocksByRoot, + RpcSuccessResponse::BlocksByHead(_) => Protocol::BlocksByHead, RpcSuccessResponse::PayloadEnvelopesByRange(_) => Protocol::PayloadEnvelopesByRange, RpcSuccessResponse::PayloadEnvelopesByRoot(_) => Protocol::PayloadEnvelopesByRoot, RpcSuccessResponse::BlobsByRange(_) => Protocol::BlobsByRange, @@ -812,7 +832,9 @@ impl RpcSuccessResponse { pub fn slot(&self) -> Option { match self { - Self::BlocksByRange(r) | Self::BlocksByRoot(r) => Some(r.slot()), + Self::BlocksByRange(r) | Self::BlocksByRoot(r) | Self::BlocksByHead(r) => { + Some(r.slot()) + } Self::PayloadEnvelopesByRoot(r) | Self::PayloadEnvelopesByRange(r) => Some(r.slot()), Self::BlobsByRange(r) | Self::BlobsByRoot(r) => Some(r.slot()), Self::DataColumnsByRange(r) | Self::DataColumnsByRoot(r) => Some(r.slot()), @@ -864,6 +886,9 @@ impl std::fmt::Display for RpcSuccessResponse { RpcSuccessResponse::BlocksByRoot(block) => { write!(f, "BlocksByRoot: Block slot: {}", block.slot()) } + RpcSuccessResponse::BlocksByHead(block) => { + write!(f, "BlocksByHead: Block slot: {}", block.slot()) + } RpcSuccessResponse::PayloadEnvelopesByRange(envelope) => { write!( f, @@ -975,6 +1000,16 @@ impl std::fmt::Display for OldBlocksByRangeRequest { } } +impl std::fmt::Display for BlocksByHeadRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "BlocksByHead: beacon_root: {}, count: {}", + self.beacon_root, self.count + ) + } +} + impl std::fmt::Display for BlobsByRootRequest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( diff --git a/beacon_node/lighthouse_network/src/rpc/protocol.rs b/beacon_node/lighthouse_network/src/rpc/protocol.rs index c949dfe17d..056ffc03b8 100644 --- a/beacon_node/lighthouse_network/src/rpc/protocol.rs +++ b/beacon_node/lighthouse_network/src/rpc/protocol.rs @@ -262,6 +262,9 @@ pub enum Protocol { /// The `BlocksByRoot` protocol name. #[strum(serialize = "beacon_blocks_by_root")] BlocksByRoot, + /// The `BlocksByHead` protocol name. + #[strum(serialize = "beacon_blocks_by_head")] + BlocksByHead, /// The `BlobsByRange` protocol name. #[strum(serialize = "blob_sidecars_by_range")] BlobsByRange, @@ -306,6 +309,7 @@ impl Protocol { Protocol::Goodbye => None, Protocol::BlocksByRange => Some(ResponseTermination::BlocksByRange), Protocol::BlocksByRoot => Some(ResponseTermination::BlocksByRoot), + Protocol::BlocksByHead => Some(ResponseTermination::BlocksByHead), Protocol::PayloadEnvelopesByRange => Some(ResponseTermination::PayloadEnvelopesByRange), Protocol::PayloadEnvelopesByRoot => Some(ResponseTermination::PayloadEnvelopesByRoot), Protocol::BlobsByRange => Some(ResponseTermination::BlobsByRange), @@ -338,6 +342,7 @@ pub enum SupportedProtocol { BlocksByRangeV2, BlocksByRootV1, BlocksByRootV2, + BlocksByHeadV1, PayloadEnvelopesByRangeV1, PayloadEnvelopesByRootV1, BlobsByRangeV1, @@ -366,6 +371,7 @@ impl SupportedProtocol { SupportedProtocol::PayloadEnvelopesByRootV1 => "1", SupportedProtocol::BlocksByRootV1 => "1", SupportedProtocol::BlocksByRootV2 => "2", + SupportedProtocol::BlocksByHeadV1 => "1", SupportedProtocol::BlobsByRangeV1 => "1", SupportedProtocol::BlobsByRootV1 => "1", SupportedProtocol::DataColumnsByRootV1 => "1", @@ -390,6 +396,7 @@ impl SupportedProtocol { SupportedProtocol::BlocksByRangeV2 => Protocol::BlocksByRange, SupportedProtocol::BlocksByRootV1 => Protocol::BlocksByRoot, SupportedProtocol::BlocksByRootV2 => Protocol::BlocksByRoot, + SupportedProtocol::BlocksByHeadV1 => Protocol::BlocksByHead, SupportedProtocol::PayloadEnvelopesByRangeV1 => Protocol::PayloadEnvelopesByRange, SupportedProtocol::PayloadEnvelopesByRootV1 => Protocol::PayloadEnvelopesByRoot, SupportedProtocol::BlobsByRangeV1 => Protocol::BlobsByRange, @@ -458,6 +465,13 @@ impl SupportedProtocol { ), ]); } + // BeaconBlocksByHead is new in Fulu (consensus-specs PR 5181). + if fork_context.fork_exists(ForkName::Fulu) { + supported.push(ProtocolId::new( + SupportedProtocol::BlocksByHeadV1, + Encoding::SSZSnappy, + )); + } supported } } @@ -564,6 +578,10 @@ impl ProtocolId { ::ssz_fixed_len(), ), Protocol::BlocksByRoot => RpcLimits::new(0, spec.max_blocks_by_root_request), + Protocol::BlocksByHead => RpcLimits::new( + ::ssz_fixed_len(), + ::ssz_fixed_len(), + ), Protocol::PayloadEnvelopesByRange => RpcLimits::new( ::ssz_fixed_len(), ::ssz_fixed_len(), @@ -609,6 +627,7 @@ impl ProtocolId { Protocol::Goodbye => RpcLimits::new(0, 0), // Goodbye request has no response Protocol::BlocksByRange => rpc_block_limits_by_fork(fork_context.current_fork_name()), Protocol::BlocksByRoot => rpc_block_limits_by_fork(fork_context.current_fork_name()), + Protocol::BlocksByHead => rpc_block_limits_by_fork(fork_context.current_fork_name()), Protocol::PayloadEnvelopesByRange => rpc_payload_limits(), Protocol::PayloadEnvelopesByRoot => rpc_payload_limits(), Protocol::BlobsByRange => rpc_blob_limits::(), @@ -648,6 +667,7 @@ impl ProtocolId { match self.versioned_protocol { SupportedProtocol::BlocksByRangeV2 | SupportedProtocol::BlocksByRootV2 + | SupportedProtocol::BlocksByHeadV1 | SupportedProtocol::PayloadEnvelopesByRangeV1 | SupportedProtocol::PayloadEnvelopesByRootV1 | SupportedProtocol::BlobsByRangeV1 @@ -801,6 +821,7 @@ pub enum RequestType { Goodbye(GoodbyeReason), BlocksByRange(OldBlocksByRangeRequest), BlocksByRoot(BlocksByRootRequest), + BlocksByHead(BlocksByHeadRequest), PayloadEnvelopesByRange(PayloadEnvelopesByRangeRequest), PayloadEnvelopesByRoot(PayloadEnvelopesByRootRequest), BlobsByRange(BlobsByRangeRequest), @@ -826,6 +847,7 @@ impl RequestType { RequestType::Goodbye(_) => 0, RequestType::BlocksByRange(req) => *req.count(), RequestType::BlocksByRoot(req) => req.block_roots().len() as u64, + RequestType::BlocksByHead(req) => req.count, RequestType::PayloadEnvelopesByRange(req) => req.count, RequestType::PayloadEnvelopesByRoot(req) => req.beacon_block_roots.len() as u64, RequestType::BlobsByRange(req) => req.max_blobs_requested(digest_epoch, spec), @@ -857,6 +879,7 @@ impl RequestType { BlocksByRootRequest::V1(_) => SupportedProtocol::BlocksByRootV1, BlocksByRootRequest::V2(_) => SupportedProtocol::BlocksByRootV2, }, + RequestType::BlocksByHead(_) => SupportedProtocol::BlocksByHeadV1, RequestType::PayloadEnvelopesByRange(_) => SupportedProtocol::PayloadEnvelopesByRangeV1, RequestType::PayloadEnvelopesByRoot(_) => SupportedProtocol::PayloadEnvelopesByRootV1, RequestType::BlobsByRange(_) => SupportedProtocol::BlobsByRangeV1, @@ -890,6 +913,7 @@ impl RequestType { // variants that have `multiple_responses()` can have values. RequestType::BlocksByRange(_) => ResponseTermination::BlocksByRange, RequestType::BlocksByRoot(_) => ResponseTermination::BlocksByRoot, + RequestType::BlocksByHead(_) => ResponseTermination::BlocksByHead, RequestType::PayloadEnvelopesByRange(_) => ResponseTermination::PayloadEnvelopesByRange, RequestType::PayloadEnvelopesByRoot(_) => ResponseTermination::PayloadEnvelopesByRoot, RequestType::BlobsByRange(_) => ResponseTermination::BlobsByRange, @@ -926,6 +950,10 @@ impl RequestType { ProtocolId::new(SupportedProtocol::BlocksByRootV2, Encoding::SSZSnappy), ProtocolId::new(SupportedProtocol::BlocksByRootV1, Encoding::SSZSnappy), ], + RequestType::BlocksByHead(_) => vec![ProtocolId::new( + SupportedProtocol::BlocksByHeadV1, + Encoding::SSZSnappy, + )], RequestType::PayloadEnvelopesByRange(_) => vec![ProtocolId::new( SupportedProtocol::PayloadEnvelopesByRangeV1, Encoding::SSZSnappy, @@ -984,6 +1012,7 @@ impl RequestType { RequestType::Goodbye(_) => false, RequestType::BlocksByRange(_) => false, RequestType::BlocksByRoot(_) => false, + RequestType::BlocksByHead(_) => false, RequestType::BlobsByRange(_) => false, RequestType::PayloadEnvelopesByRange(_) => false, RequestType::PayloadEnvelopesByRoot(_) => false, @@ -1097,6 +1126,7 @@ impl std::fmt::Display for RequestType { RequestType::Goodbye(reason) => write!(f, "Goodbye: {}", reason), RequestType::BlocksByRange(req) => write!(f, "Blocks by range: {}", req), RequestType::BlocksByRoot(req) => write!(f, "Blocks by root: {:?}", req), + RequestType::BlocksByHead(req) => write!(f, "Blocks by head: {}", req), RequestType::PayloadEnvelopesByRange(req) => { write!(f, "Payload envelopes by range: {:?}", req) } @@ -1171,6 +1201,8 @@ mod tests { fork_context.fork_exists(ForkName::Gloas) } + BlocksByHeadV1 => fork_context.fork_exists(ForkName::Fulu), + // Light client protocols are not in currently_supported() LightClientBootstrapV1 | LightClientOptimisticUpdateV1 diff --git a/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs b/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs index ebdca386d8..a5c98a4d30 100644 --- a/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs +++ b/beacon_node/lighthouse_network/src/rpc/rate_limiter.rs @@ -105,6 +105,8 @@ pub struct RPCRateLimiter { bbrange_rl: Limiter, /// BlocksByRoot rate limiter. bbroots_rl: Limiter, + /// BlocksByHead rate limiter. + bbhead_rl: Limiter, /// BlobsByRange rate limiter. blbrange_rl: Limiter, /// BlobsByRoot rate limiter. @@ -152,6 +154,8 @@ pub struct RPCRateLimiterBuilder { bbrange_quota: Option, /// Quota for the BlocksByRoot protocol. bbroots_quota: Option, + /// Quota for the BlocksByHead protocol. + bbhead_quota: Option, /// Quota for the ExecutionPayloadEnvelopesByRange protocol. perange_quota: Option, /// Quota for the ExecutionPayloadEnvelopesByRoot protocol. @@ -185,6 +189,7 @@ impl RPCRateLimiterBuilder { Protocol::Goodbye => self.goodbye_quota = q, Protocol::BlocksByRange => self.bbrange_quota = q, Protocol::BlocksByRoot => self.bbroots_quota = q, + Protocol::BlocksByHead => self.bbhead_quota = q, Protocol::PayloadEnvelopesByRange => self.perange_quota = q, Protocol::PayloadEnvelopesByRoot => self.peroots_quota = q, Protocol::BlobsByRange => self.blbrange_quota = q, @@ -211,6 +216,9 @@ impl RPCRateLimiterBuilder { let bbrange_quota = self .bbrange_quota .ok_or("BlocksByRange quota not specified")?; + let bbhead_quota = self + .bbhead_quota + .ok_or("BlocksByHead quota not specified")?; let perange_quota = self .perange_quota .ok_or("PayloadEnvelopesByRange quota not specified")?; @@ -252,6 +260,7 @@ impl RPCRateLimiterBuilder { let goodbye_rl = Limiter::from_quota(goodbye_quota)?; let bbroots_rl = Limiter::from_quota(bbroots_quota)?; let bbrange_rl = Limiter::from_quota(bbrange_quota)?; + let bbhead_rl = Limiter::from_quota(bbhead_quota)?; let envrange_rl = Limiter::from_quota(perange_quota)?; let envroots_rl = Limiter::from_quota(peroots_quota)?; let blbrange_rl = Limiter::from_quota(blbrange_quota)?; @@ -277,6 +286,7 @@ impl RPCRateLimiterBuilder { goodbye_rl, bbroots_rl, bbrange_rl, + bbhead_rl, envrange_rl, envroots_rl, blbrange_rl, @@ -332,6 +342,7 @@ impl RPCRateLimiter { goodbye_quota, blocks_by_range_quota, blocks_by_root_quota, + blocks_by_head_quota, payload_envelopes_by_range_quota, payload_envelopes_by_root_quota, blobs_by_range_quota, @@ -351,6 +362,7 @@ impl RPCRateLimiter { .set_quota(Protocol::Goodbye, goodbye_quota) .set_quota(Protocol::BlocksByRange, blocks_by_range_quota) .set_quota(Protocol::BlocksByRoot, blocks_by_root_quota) + .set_quota(Protocol::BlocksByHead, blocks_by_head_quota) .set_quota( Protocol::PayloadEnvelopesByRange, payload_envelopes_by_range_quota, @@ -406,6 +418,7 @@ impl RPCRateLimiter { Protocol::Goodbye => &mut self.goodbye_rl, Protocol::BlocksByRange => &mut self.bbrange_rl, Protocol::BlocksByRoot => &mut self.bbroots_rl, + Protocol::BlocksByHead => &mut self.bbhead_rl, Protocol::PayloadEnvelopesByRange => &mut self.envrange_rl, Protocol::PayloadEnvelopesByRoot => &mut self.envroots_rl, Protocol::BlobsByRange => &mut self.blbrange_rl, @@ -432,6 +445,7 @@ impl RPCRateLimiter { status_rl, bbrange_rl, bbroots_rl, + bbhead_rl, envrange_rl, envroots_rl, blbrange_rl, @@ -451,6 +465,7 @@ impl RPCRateLimiter { status_rl.prune(time_since_start); bbrange_rl.prune(time_since_start); bbroots_rl.prune(time_since_start); + bbhead_rl.prune(time_since_start); envrange_rl.prune(time_since_start); envroots_rl.prune(time_since_start); blbrange_rl.prune(time_since_start); diff --git a/beacon_node/lighthouse_network/src/service/api_types.rs b/beacon_node/lighthouse_network/src/service/api_types.rs index 486a443857..f598f59aee 100644 --- a/beacon_node/lighthouse_network/src/service/api_types.rs +++ b/beacon_node/lighthouse_network/src/service/api_types.rs @@ -161,6 +161,9 @@ pub enum Response { DataColumnsByRange(Option>>), /// A response to a get BLOCKS_BY_ROOT request. BlocksByRoot(Option>>), + /// A response to a get BEACON_BLOCKS_BY_HEAD request. A None response signals the end of the + /// batch. + BlocksByHead(Option>>), /// A response to a get `EXECUTION_PAYLOAD_ENVELOPES_BY_ROOT` request. PayloadEnvelopesByRoot(Option>>), /// A response to a get `EXECUTION_PAYLOAD_ENVELOPES_BY_RANGE` request. @@ -186,6 +189,10 @@ impl std::convert::From> for RpcResponse { Some(b) => RpcResponse::Success(RpcSuccessResponse::BlocksByRoot(b)), None => RpcResponse::StreamTermination(ResponseTermination::BlocksByRoot), }, + Response::BlocksByHead(r) => match r { + Some(b) => RpcResponse::Success(RpcSuccessResponse::BlocksByHead(b)), + None => RpcResponse::StreamTermination(ResponseTermination::BlocksByHead), + }, Response::BlocksByRange(r) => match r { Some(b) => RpcResponse::Success(RpcSuccessResponse::BlocksByRange(b)), None => RpcResponse::StreamTermination(ResponseTermination::BlocksByRange), diff --git a/beacon_node/lighthouse_network/src/service/mod.rs b/beacon_node/lighthouse_network/src/service/mod.rs index f0c1567cb0..41d937e324 100644 --- a/beacon_node/lighthouse_network/src/service/mod.rs +++ b/beacon_node/lighthouse_network/src/service/mod.rs @@ -1691,6 +1691,14 @@ impl Network { request_type, }) } + RequestType::BlocksByHead(_) => { + metrics::inc_counter_vec(&metrics::TOTAL_RPC_REQUESTS, &["blocks_by_head"]); + Some(NetworkEvent::RequestReceived { + peer_id, + inbound_request_id, + request_type, + }) + } RequestType::PayloadEnvelopesByRange(_) => { metrics::inc_counter_vec( &metrics::TOTAL_RPC_REQUESTS, @@ -1827,6 +1835,9 @@ impl Network { RpcSuccessResponse::BlocksByRoot(resp) => { self.build_response(id, peer_id, Response::BlocksByRoot(Some(resp))) } + RpcSuccessResponse::BlocksByHead(resp) => { + self.build_response(id, peer_id, Response::BlocksByHead(Some(resp))) + } RpcSuccessResponse::PayloadEnvelopesByRange(resp) => self.build_response( id, peer_id, @@ -1871,6 +1882,7 @@ impl Network { let response = match termination { ResponseTermination::BlocksByRange => Response::BlocksByRange(None), ResponseTermination::BlocksByRoot => Response::BlocksByRoot(None), + ResponseTermination::BlocksByHead => Response::BlocksByHead(None), ResponseTermination::PayloadEnvelopesByRange => { Response::PayloadEnvelopesByRange(None) } diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index e089159eb8..6a3ccbcd65 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -14,8 +14,8 @@ use beacon_processor::{ }; use lighthouse_network::rpc::InboundRequestId; use lighthouse_network::rpc::methods::{ - BlobsByRangeRequest, BlobsByRootRequest, DataColumnsByRangeRequest, DataColumnsByRootRequest, - LightClientUpdatesByRangeRequest, PayloadEnvelopesByRangeRequest, + BlobsByRangeRequest, BlobsByRootRequest, BlocksByHeadRequest, DataColumnsByRangeRequest, + DataColumnsByRootRequest, LightClientUpdatesByRangeRequest, PayloadEnvelopesByRangeRequest, PayloadEnvelopesByRootRequest, }; use lighthouse_network::service::api_types::CustodyBackfillBatchId; @@ -699,6 +699,26 @@ impl NetworkBeaconProcessor { }) } + /// Create a new work event to process `BlocksByHeadRequest`s from the RPC network. + pub fn send_blocks_by_head_request( + self: &Arc, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + request: BlocksByHeadRequest, + ) -> Result<(), Error> { + let processor = self.clone(); + let process_fn = async move { + processor + .handle_blocks_by_head_request(peer_id, inbound_request_id, request) + .await; + }; + + self.try_send(BeaconWorkEvent { + drop_during_sync: false, + work: Work::BlocksByHeadRequest(Box::pin(process_fn)), + }) + } + /// Create a new work event to process `BlocksByRootRequest`s from the RPC network. pub fn send_blocks_by_roots_request( self: &Arc, diff --git a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs index 8b31b67acb..37a6f3779a 100644 --- a/beacon_node/network/src/network_beacon_processor/rpc_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/rpc_methods.rs @@ -7,8 +7,8 @@ use beacon_chain::payload_envelope_streamer::EnvelopeRequestSource; use beacon_chain::{BeaconChainError, BeaconChainTypes, BlockProcessStatus, WhenSlotSkipped}; use itertools::{Itertools, process_results}; use lighthouse_network::rpc::methods::{ - BlobsByRangeRequest, BlobsByRootRequest, DataColumnsByRangeRequest, DataColumnsByRootRequest, - PayloadEnvelopesByRangeRequest, PayloadEnvelopesByRootRequest, + BlobsByRangeRequest, BlobsByRootRequest, BlocksByHeadRequest, DataColumnsByRangeRequest, + DataColumnsByRootRequest, PayloadEnvelopesByRangeRequest, PayloadEnvelopesByRootRequest, }; use lighthouse_network::rpc::*; use lighthouse_network::{PeerId, ReportSource, Response, SyncInfo}; @@ -256,6 +256,266 @@ impl NetworkBeaconProcessor { Ok(()) } + /// Handle a `BeaconBlocksByHead` request from the peer. + /// + /// Walks the parent chain of `request.beacon_root` (inclusive) and emits up to + /// `min(request.count, MAX_REQUEST_BLOCKS_DENEB)` blocks in descending slot order. + /// See consensus-specs PR 5181. + #[instrument( + name = "lh_handle_blocks_by_head_request", + parent = None, + level = "debug", + skip_all, + fields(peer_id = %peer_id, client = tracing::field::Empty) + )] + pub async fn handle_blocks_by_head_request( + self: Arc, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + request: BlocksByHeadRequest, + ) { + let client = self.network_globals.client(&peer_id); + Span::current().record("client", field::display(client.kind)); + + self.terminate_response_stream( + peer_id, + inbound_request_id, + self.clone() + .handle_blocks_by_head_request_inner(peer_id, inbound_request_id, request) + .await, + Response::BlocksByHead, + ); + } + + async fn handle_blocks_by_head_request_inner( + self: Arc, + peer_id: PeerId, + inbound_request_id: InboundRequestId, + request: BlocksByHeadRequest, + ) -> Result<(), (RpcErrorResponse, &'static str)> { + let spec = &self.chain.spec; + // Cap the response at MAX_REQUEST_BLOCKS_DENEB regardless of what the peer asked for, + // matching the spec. + let max_request_blocks = spec.max_request_blocks(types::ForkName::Deneb) as u64; + let cap = request.count.min(max_request_blocks); + let beacon_root = request.beacon_root; + + debug!( + %peer_id, + beacon_root = ?beacon_root, + count = request.count, + cap, + "Received BlocksByHead Request" + ); + + if cap == 0 { + return Ok(()); + } + + // Walk the parent chain on a blocking thread because `get_blinded_block` hits the store + // synchronously and we may walk up to MAX_REQUEST_BLOCKS_DENEB ancestors. + let network_beacon_processor = self.clone(); + let block_roots = self + .executor + .spawn_blocking_handle( + move || network_beacon_processor.get_block_roots_ancestor_of_head(beacon_root, cap), + "get_block_roots_ancestor_of_head", + ) + .ok_or((RpcErrorResponse::ServerError, "shutting down"))? + .await + .map_err(|_| (RpcErrorResponse::ServerError, "tokio join"))??; + + let requested_blocks = block_roots.len(); + + let log_results = |peer_id, blocks_sent| { + debug!( + %peer_id, + requested = requested_blocks, + returned = blocks_sent, + "BlocksByHead outgoing response processed" + ); + }; + + let mut block_stream = match self.chain.get_blocks(block_roots) { + Ok(block_stream) => block_stream, + Err(e) => { + error!(error = ?e, "Error getting block stream"); + return Err((RpcErrorResponse::ServerError, "Iterator error")); + } + }; + + // Fetching blocks is async because it may have to hit the execution layer for payloads. + let mut blocks_sent = 0; + while let Some((root, result)) = block_stream.next().await { + match result.as_ref() { + Ok(Some(block)) => { + blocks_sent += 1; + self.send_network_message(NetworkMessage::SendResponse { + peer_id, + inbound_request_id, + response: Response::BlocksByHead(Some(block.clone())), + }); + } + Ok(None) => { + error!( + %peer_id, + request_root = ?root, + "Block in the chain is not in the store" + ); + log_results(peer_id, blocks_sent); + return Err((RpcErrorResponse::ServerError, "Database inconsistency")); + } + Err(BeaconChainError::BlockHashMissingFromExecutionLayer(_)) => { + debug!( + block_root = ?root, + reason = "execution layer not synced", + "Failed to fetch execution payload for blocks by head request" + ); + log_results(peer_id, blocks_sent); + return Err(( + RpcErrorResponse::ResourceUnavailable, + "Execution layer not synced", + )); + } + Err(e) => { + if matches!( + e, + BeaconChainError::ExecutionLayerErrorPayloadReconstruction(_block_hash, boxed_error) + if matches!(**boxed_error, execution_layer::Error::EngineError(_)) + ) { + warn!( + info = "this may occur occasionally when the EE is busy", + block_root = ?root, + error = ?e, + "Error rebuilding payload for peer" + ); + } else { + error!( + block_root = ?root, + error = ?e, + "Error fetching block for peer" + ); + } + log_results(peer_id, blocks_sent); + return Err((RpcErrorResponse::ServerError, "Failed fetching blocks")); + } + } + } + + log_results(peer_id, blocks_sent); + Ok(()) + } + + /// Walks the parent chain of `head_root` (inclusive) and returns up to `count` block roots + /// in descending slot order. Synchronous so it can be run on a blocking thread. + /// + /// Two regimes are handled: + /// 1. Above finalization → fork-choice's in-memory proto-array supplies the roots + /// (zero DB reads). + /// 2. At or below finalization → the freezer DB's `BeaconBlockRoots` column (the + /// canonical slot→root index for finalized blocks, populated for + /// `[oldest_block_slot, split.slot)` with skip slots reusing the prior block's + /// root) supplies the roots. The head state is never consulted: its 8192-slot + /// `block_roots` bucket would silently truncate deep walks and is the wrong + /// source of truth for canonical history below finalization. + /// + /// Returns `ResourceUnavailable` if `head_root` is not known to the node. + fn get_block_roots_ancestor_of_head( + &self, + head_root: Hash256, + count: u64, + ) -> Result, (RpcErrorResponse, &'static str)> { + if count == 0 { + return Ok(vec![]); + } + + // 1. Walk ancestors in proto-array (in-memory, zero DB reads). Track the + // deepest slot we collected — that's where the freezer walk picks up. + let mut roots: Vec = Vec::with_capacity(count as usize); + let mut deepest_slot: Option = None; + { + let fork_choice = self.chain.canonical_head.fork_choice_read_lock(); + for (root, slot) in fork_choice + .proto_array() + .iter_block_roots(&head_root) + .take(count as usize) + { + roots.push(root); + deepest_slot = Some(slot); + } + } + + let store = &self.chain.store; + + // 2. Fallback: `head_root` is at or below finalization (proto-array doesn't + // track it). Look up its slot in the store, then verify it is the canonical + // block at that slot via the freezer index — a non-canonical hot-DB block at + // slot < split.slot can shadow the finalized chain. If the freezer + // disagrees (or doesn't have that slot), serve just the single block we + // found, satisfying the spec's "MUST return at least one block if you have + // it" clause. + let mut current_slot = if let Some(slot) = deepest_slot { + slot + } else { + let block = self + .chain + .get_blinded_block(&head_root) + .map_err(|e| { + error!(error = ?e, "Error reading blinded block for BlocksByHead beacon_root"); + (RpcErrorResponse::ServerError, "Database error") + })? + .ok_or((RpcErrorResponse::ResourceUnavailable, "Unknown beacon_root"))?; + let block_slot = block.slot(); + roots.push(head_root); + + match store.get_cold_block_root(block_slot) { + Ok(Some(r)) if r == head_root => {} // canonical, OK to walk back + Ok(_) => return Ok(roots), + Err(e) => { + error!(error = ?e, "Error reading freezer block_root for BlocksByHead"); + return Err((RpcErrorResponse::ServerError, "Database error")); + } + } + + block_slot + }; + + if (roots.len() as u64) >= count { + return Ok(roots); + } + + // 3. Spillover via the freezer DB's `BeaconBlockRoots` index (the canonical + // slot→root mapping for finalized blocks). Skip slots reuse the prior + // block's root; dedup on insert. + let oldest_block_slot = store.get_oldest_block_slot(); + let mut last_root = roots.last().copied(); + while (roots.len() as u64) < count && current_slot > oldest_block_slot { + current_slot = match current_slot.as_u64().checked_sub(1) { + Some(s) => Slot::from(s), + None => break, + }; + match store.get_cold_block_root(current_slot) { + Ok(Some(root)) => { + if Some(root) != last_root { + roots.push(root); + last_root = Some(root); + } + } + Ok(None) => { + // Hole in the freezer index (e.g. before `oldest_block_slot` on a + // checkpoint-synced node). Stop walking. + break; + } + Err(e) => { + error!(error = ?e, "Error walking freezer block_roots"); + return Err((RpcErrorResponse::ServerError, "Database error")); + } + } + } + + Ok(roots) + } + /// Handle a `ExecutionPayloadEnvelopesByRoot` request from the peer. #[instrument( name = "lh_handle_payload_envelopes_by_root_request", diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index c4e7f8f8d1..f13815f7b6 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -24,8 +24,8 @@ use itertools::Itertools; use libp2p::gossipsub::MessageAcceptance; use lighthouse_network::rpc::InboundRequestId; use lighthouse_network::rpc::methods::{ - BlobsByRangeRequest, BlobsByRootRequest, DataColumnsByRangeRequest, MetaDataV3, - PayloadEnvelopesByRangeRequest, PayloadEnvelopesByRootRequest, + BlobsByRangeRequest, BlobsByRootRequest, BlocksByHeadRequest, DataColumnsByRangeRequest, + MetaDataV3, PayloadEnvelopesByRangeRequest, PayloadEnvelopesByRootRequest, }; use lighthouse_network::{ Client, MessageId, NetworkConfig, NetworkGlobals, PeerId, Response, @@ -501,6 +501,16 @@ impl TestRig { .unwrap(); } + pub fn enqueue_blocks_by_head_request(&self, beacon_root: Hash256, count: u64) { + self.network_beacon_processor + .send_blocks_by_head_request( + PeerId::random(), + InboundRequestId::new_unchecked(42, 24), + BlocksByHeadRequest { beacon_root, count }, + ) + .unwrap(); + } + pub fn enqueue_blobs_by_root_request(&self, blob_ids: RuntimeVariableList) { self.network_beacon_processor .send_blobs_by_roots_request( @@ -2346,3 +2356,153 @@ async fn test_payload_envelopes_by_range_no_duplicates_with_skip_slots() { // 1. Gossip envelope arrives before its block → queued via UnknownBlockForEnvelope // 2. Block imported → envelope released and processed successfully // 3. Timeout path → envelope released and re-verified + +/// Drain `network_rx` collecting `Response::BlocksByHead(Some(_))` block roots until the +/// stream terminator (`None`) arrives. Panics on any other message type so tests fail +/// loudly if an error response sneaks in. +async fn drain_blocks_by_head_response(rig: &mut TestRig) -> Vec { + let mut roots = Vec::new(); + while let Some(msg) = rig.network_rx.recv().await { + match msg { + NetworkMessage::SendResponse { + response: Response::BlocksByHead(Some(block)), + .. + } => roots.push(block.canonical_root()), + NetworkMessage::SendResponse { + response: Response::BlocksByHead(None), + .. + } => return roots, + other => panic!("unexpected message: {:?}", other), + } + } + roots +} + +// `BlocksByHead` request that crosses the finalized boundary: proto-array supplies +// the unfinalized head + ancestors down to the finalized root, then the freezer's +// `BeaconBlockRoots` index supplies the rest. Verifies the spillover path +// `get_block_roots_ancestor_of_head` takes when count > proto-array depth. +#[tokio::test] +async fn test_blocks_by_head_spillover_into_freezer() { + // Long enough for finalization + state migration to populate the freezer. + let mut rig = TestRig::new(SLOTS_PER_EPOCH * 4).await; + + // Sanity-check the precondition: finalization advanced past genesis and the split + // slot is non-zero, so the freezer's `BeaconBlockRoots` column has entries. + assert!( + rig.chain + .canonical_head + .cached_head() + .finalized_checkpoint() + .epoch + > Epoch::new(0), + "test precondition: chain must have finalized past epoch 0", + ); + assert!( + rig.chain.store.get_split_slot() > Slot::new(0), + "test precondition: state migration must have populated the freezer", + ); + + let head_slot = rig.chain.canonical_head.cached_head().head_slot(); + let head_root = rig.chain.canonical_head.cached_head().head_block_root(); + + // Walk all the way back to slot 1: exercises both proto-array (above finalization) + // and freezer (at/below finalization). + let count = head_slot.as_u64(); + rig.enqueue_blocks_by_head_request(head_root, count); + let actual = drain_blocks_by_head_response(&mut rig).await; + + // Build the canonical descending root list independently. The harness has no skip + // slots so every slot in [1, head_slot] has a unique block, but we still dedup + // defensively to mirror the function under test. + let mut expected: Vec = Vec::new(); + let mut last: Option = None; + for offset in 0..count { + let slot = Slot::new(head_slot.as_u64() - offset); + if let Some(root) = rig + .chain + .block_root_at_slot(slot, WhenSlotSkipped::Prev) + .unwrap() + && Some(root) != last + { + expected.push(root); + last = Some(root); + } + } + + assert_eq!( + actual, expected, + "BlocksByHead must serve the full canonical parent chain across the finalized boundary", + ); + assert_eq!(actual.first(), Some(&head_root), "first root must be head"); +} + +// `BlocksByHead` with `beacon_root` set to a finalized block root (case-2 fallback in +// `get_block_roots_ancestor_of_head`): proto-array doesn't track it, so we +// `get_blinded_block` for its slot, verify canonicity via the freezer index, and walk +// back from there. +#[tokio::test] +async fn test_blocks_by_head_finalized_root() { + let mut rig = TestRig::new(SLOTS_PER_EPOCH * 4).await; + + let finalized_root = rig + .chain + .canonical_head + .cached_head() + .finalized_checkpoint() + .root; + let finalized_slot = rig + .chain + .get_blinded_block(&finalized_root) + .unwrap() + .expect("finalized block exists in store") + .slot(); + assert!( + finalized_slot > Slot::new(0), + "test precondition: finalized block must not be genesis", + ); + + let count = 8u64.min(finalized_slot.as_u64()); + rig.enqueue_blocks_by_head_request(finalized_root, count); + let actual = drain_blocks_by_head_response(&mut rig).await; + + let mut expected: Vec = Vec::new(); + let mut last: Option = None; + for offset in 0..count { + let slot = Slot::new(finalized_slot.as_u64() - offset); + if let Some(root) = rig + .chain + .block_root_at_slot(slot, WhenSlotSkipped::Prev) + .unwrap() + && Some(root) != last + { + expected.push(root); + last = Some(root); + } + } + + assert_eq!(actual, expected); + assert_eq!( + actual.first(), + Some(&finalized_root), + "first root must be the requested finalized root", + ); +} + +// `BlocksByHead` for a `beacon_root` we don't have. Spec says we MUST return an error +// (we map this to `ResourceUnavailable`). +#[tokio::test] +async fn test_blocks_by_head_unknown_root() { + let mut rig = TestRig::new(SLOTS_PER_EPOCH).await; + rig.enqueue_blocks_by_head_request(Hash256::repeat_byte(0xab), 4); + + match rig.network_rx.recv().await.expect("a network message") { + NetworkMessage::SendErrorResponse { error, .. } => { + assert_matches!( + error, + lighthouse_network::rpc::RpcErrorResponse::ResourceUnavailable + ); + } + other => panic!("expected SendErrorResponse, got {:?}", other), + } +} diff --git a/beacon_node/network/src/router.rs b/beacon_node/network/src/router.rs index 443fa51cc6..a718997e0a 100644 --- a/beacon_node/network/src/router.rs +++ b/beacon_node/network/src/router.rs @@ -243,6 +243,13 @@ impl Router { request, ), ), + RequestType::BlocksByHead(request) => self.handle_beacon_processor_send_result( + self.network_beacon_processor.send_blocks_by_head_request( + peer_id, + inbound_request_id, + request, + ), + ), RequestType::PayloadEnvelopesByRoot(request) => self .handle_beacon_processor_send_result( self.network_beacon_processor @@ -346,6 +353,11 @@ impl Router { Response::PayloadEnvelopesByRoot(_) | Response::PayloadEnvelopesByRange(_) => { debug!("Requesting envelopes by root and by range not supported yet"); } + // Lighthouse currently only serves BlocksByHead and does not issue it as a client, + // so receiving a response is unexpected. Drop it without crashing. + Response::BlocksByHead(_) => { + debug!("BlocksByHead response received but not requested by lighthouse"); + } // Light client responses should not be received Response::LightClientBootstrap(_) | Response::LightClientOptimisticUpdate(_) From 1b921a64e68e8b7878385041289d2cc4d1e5fb48 Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Fri, 8 May 2026 16:12:38 +0800 Subject: [PATCH 168/189] Fix execution integration test CI failure (#9277) Co-Authored-By: Tan Chee Keong --- testing/execution_engine_integration/src/nethermind.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/execution_engine_integration/src/nethermind.rs b/testing/execution_engine_integration/src/nethermind.rs index 6a336161bd..e606f5558c 100644 --- a/testing/execution_engine_integration/src/nethermind.rs +++ b/testing/execution_engine_integration/src/nethermind.rs @@ -20,6 +20,7 @@ fn build_result(repo_dir: &Path) -> Output { .arg("src/Nethermind/Nethermind.sln") .arg("-c") .arg("Release") + .arg("-p:TreatWarningsAsErrors=false") .current_dir(repo_dir) .output() .expect("failed to make nethermind") From 2208e179376b81744fedf1ded59729701a5b2e8f Mon Sep 17 00:00:00 2001 From: Shane K Moore <41407272+shane-moore@users.noreply.github.com> Date: Mon, 11 May 2026 08:27:41 -0700 Subject: [PATCH 169/189] chore: remove builder_index from produce_block_v4 (#9267) Part of #8828 for the stateful path and helps align gloas `produceBlockV4` with beacon-APIs [PR](https://github.com/ethereum/beacon-APIs/pull/580) - Plumb `include_payload` query through the handler. Ignored for now since stateless mode isn't wired up yet - Add `execution_payload_included` metadata field + `Eth-Execution-Payload-Included` header per spec. Both `false` until stateless lands - Drop the `{builder_index}` segment from the envelope GET URL since no longer included in spec Co-Authored-By: shane-moore Co-Authored-By: Eitan Seri-Levi --- beacon_node/http_api/src/produce_block.rs | 22 +++++++++++--- .../validator/execution_payload_envelope.rs | 11 +------ beacon_node/http_api/src/version.rs | 16 +++++++++- beacon_node/http_api/tests/tests.rs | 13 ++++---- common/eth2/src/lib.rs | 30 ++++++++++++++----- common/eth2/src/types.rs | 10 ++++++- .../validator_services/src/block_service.rs | 10 +++---- 7 files changed, 76 insertions(+), 36 deletions(-) diff --git a/beacon_node/http_api/src/produce_block.rs b/beacon_node/http_api/src/produce_block.rs index 7173eb698f..ed1ecb9456 100644 --- a/beacon_node/http_api/src/produce_block.rs +++ b/beacon_node/http_api/src/produce_block.rs @@ -2,8 +2,9 @@ use crate::{ build_block_contents, version::{ ResponseIncludesVersion, add_consensus_block_value_header, add_consensus_version_header, - add_execution_payload_blinded_header, add_execution_payload_value_header, - add_ssz_content_type_header, beacon_response, inconsistent_fork_rejection, + add_execution_payload_blinded_header, add_execution_payload_included_header, + add_execution_payload_value_header, add_ssz_content_type_header, beacon_response, + inconsistent_fork_rejection, }, }; use beacon_chain::graffiti_calculator::GraffitiSettings; @@ -83,7 +84,16 @@ pub async fn produce_block_v4( warp_utils::reject::custom_bad_request(format!("failed to fetch a block: {:?}", e)) })?; - build_response_v4::(block, consensus_block_value, accept_header, &chain.spec) + // TODO(gloas): wire up for stateless mode (#8828). + let execution_payload_included = false; + + build_response_v4::( + block, + consensus_block_value, + execution_payload_included, + accept_header, + &chain.spec, + ) } #[instrument( @@ -133,6 +143,7 @@ pub async fn produce_block_v3( pub fn build_response_v4( block: BeaconBlock>, consensus_block_value: u64, + execution_payload_included: bool, accept_header: Option, spec: &ChainSpec, ) -> Result, warp::Rejection> { @@ -146,6 +157,7 @@ pub fn build_response_v4( let metadata = ProduceBlockV4Metadata { consensus_version: fork_name, consensus_block_value: consensus_block_value_wei, + execution_payload_included, }; match accept_header { @@ -155,6 +167,7 @@ pub fn build_response_v4( .map(|res: Response| add_ssz_content_type_header(res)) .map(|res: Response| add_consensus_version_header(res, fork_name)) .map(|res| add_consensus_block_value_header(res, consensus_block_value_wei)) + .map(|res| add_execution_payload_included_header(res, execution_payload_included)) .map_err(|e| -> warp::Rejection { warp_utils::reject::custom_server_error(format!("failed to create response: {}", e)) }), @@ -165,7 +178,8 @@ pub fn build_response_v4( }) .into_response()) .map(|res| add_consensus_version_header(res, fork_name)) - .map(|res| add_consensus_block_value_header(res, consensus_block_value_wei)), + .map(|res| add_consensus_block_value_header(res, consensus_block_value_wei)) + .map(|res| add_execution_payload_included_header(res, execution_payload_included)), } } diff --git a/beacon_node/http_api/src/validator/execution_payload_envelope.rs b/beacon_node/http_api/src/validator/execution_payload_envelope.rs index c40b375e49..7a7a430414 100644 --- a/beacon_node/http_api/src/validator/execution_payload_envelope.rs +++ b/beacon_node/http_api/src/validator/execution_payload_envelope.rs @@ -12,7 +12,7 @@ use types::Slot; use warp::http::Response; use warp::{Filter, Rejection}; -// GET validator/execution_payload_envelope/{slot}/{builder_index} +// GET validator/execution_payload_envelope/{slot} pub fn get_validator_execution_payload_envelope( eth_v1: EthV1Filter, chain_filter: ChainFilter, @@ -27,11 +27,6 @@ pub fn get_validator_execution_payload_envelope( "Invalid slot".to_string(), )) })) - .and(warp::path::param::().or_else(|_| async { - Err(warp_utils::reject::custom_bad_request( - "Invalid builder_index".to_string(), - )) - })) .and(warp::path::end()) .and(warp::header::optional::("accept")) .and(not_while_syncing_filter) @@ -39,10 +34,6 @@ pub fn get_validator_execution_payload_envelope( .and(chain_filter) .then( |slot: Slot, - // TODO(gloas) we're only doing local building - // we'll need to implement builder index logic - // eventually. - _builder_index: u64, accept_header: Option, not_synced_filter: Result<(), Rejection>, task_spawner: TaskSpawner, diff --git a/beacon_node/http_api/src/version.rs b/beacon_node/http_api/src/version.rs index 371064c886..bba1641416 100644 --- a/beacon_node/http_api/src/version.rs +++ b/beacon_node/http_api/src/version.rs @@ -5,7 +5,8 @@ use eth2::beacon_response::{ }; use eth2::{ CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, CONTENT_TYPE_HEADER, - EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_VALUE_HEADER, SSZ_CONTENT_TYPE_HEADER, + EXECUTION_PAYLOAD_BLINDED_HEADER, EXECUTION_PAYLOAD_INCLUDED_HEADER, + EXECUTION_PAYLOAD_VALUE_HEADER, SSZ_CONTENT_TYPE_HEADER, }; use serde::Serialize; use types::{ForkName, InconsistentFork, Uint256}; @@ -88,6 +89,19 @@ pub fn add_execution_payload_blinded_header( .into_response() } +/// Add the `Eth-Execution-Payload-Included` header to a response. +pub fn add_execution_payload_included_header( + reply: T, + execution_payload_included: bool, +) -> Response { + reply::with_header( + reply, + EXECUTION_PAYLOAD_INCLUDED_HEADER, + execution_payload_included.to_string(), + ) + .into_response() +} + /// Add the `Eth-Execution-Payload-Value` header to a response. pub fn add_execution_payload_value_header( reply: T, diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index 0d6735ff61..a7fe34593a 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -4288,6 +4288,7 @@ impl ApiTester { ); // TODO(gloas): check why consensus block value is 0 // assert!(!metadata.consensus_block_value.is_zero()); + assert!(!metadata.execution_payload_included); let block_root = block.tree_hash_root(); let envelope = self @@ -4360,7 +4361,7 @@ impl ApiTester { let (response, metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, None, None) + .get_validator_blocks_v4::(slot, &randao_reveal, None, None, None, None) .await .unwrap(); let block = response.data; @@ -4369,7 +4370,7 @@ impl ApiTester { let envelope = self .client - .get_validator_execution_payload_envelope::(slot, BUILDER_INDEX_SELF_BUILD) + .get_validator_execution_payload_envelope::(slot) .await .unwrap() .data; @@ -4423,7 +4424,7 @@ impl ApiTester { let (block, metadata) = self .client - .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, None, None) + .get_validator_blocks_v4_ssz::(slot, &randao_reveal, None, None, None, None) .await .unwrap(); @@ -4431,7 +4432,7 @@ impl ApiTester { let envelope = self .client - .get_validator_execution_payload_envelope_ssz::(slot, BUILDER_INDEX_SELF_BUILD) + .get_validator_execution_payload_envelope_ssz::(slot) .await .unwrap(); @@ -4861,7 +4862,7 @@ impl ApiTester { // Produce and publish a block. let (response, _metadata) = self .client - .get_validator_blocks_v4::(slot, &randao_reveal, None, None, None) + .get_validator_blocks_v4::(slot, &randao_reveal, None, None, None, None) .await .unwrap(); let block = response.data; @@ -4878,7 +4879,7 @@ impl ApiTester { // Retrieve and publish the envelope. let envelope = self .client - .get_validator_execution_payload_envelope::(slot, BUILDER_INDEX_SELF_BUILD) + .get_validator_execution_payload_envelope::(slot) .await .unwrap() .data; diff --git a/common/eth2/src/lib.rs b/common/eth2/src/lib.rs index becbe550a6..e9fb44209b 100644 --- a/common/eth2/src/lib.rs +++ b/common/eth2/src/lib.rs @@ -56,6 +56,7 @@ pub const V4: EndpointVersion = EndpointVersion(4); pub const CONSENSUS_VERSION_HEADER: &str = "Eth-Consensus-Version"; pub const EXECUTION_PAYLOAD_BLINDED_HEADER: &str = "Eth-Execution-Payload-Blinded"; pub const EXECUTION_PAYLOAD_VALUE_HEADER: &str = "Eth-Execution-Payload-Value"; +pub const EXECUTION_PAYLOAD_INCLUDED_HEADER: &str = "Eth-Execution-Payload-Included"; pub const CONSENSUS_BLOCK_VALUE_HEADER: &str = "Eth-Consensus-Block-Value"; pub const CONTENT_TYPE_HEADER: &str = "Content-Type"; @@ -2554,12 +2555,14 @@ impl BeaconNodeHttpClient { } /// returns `GET v4/validator/blocks/{slot}` URL path + #[allow(clippy::too_many_arguments)] pub async fn get_validator_blocks_v4_path( &self, slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, + include_payload: Option, builder_booster_factor: Option, graffiti_policy: Option, ) -> Result { @@ -2584,6 +2587,11 @@ impl BeaconNodeHttpClient { .append_pair("skip_randao_verification", ""); } + if let Some(include_payload) = include_payload { + path.query_pairs_mut() + .append_pair("include_payload", &include_payload.to_string()); + } + if let Some(builder_booster_factor) = builder_booster_factor { path.query_pairs_mut() .append_pair("builder_boost_factor", &builder_booster_factor.to_string()); @@ -2603,6 +2611,7 @@ impl BeaconNodeHttpClient { slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, + include_payload: Option, builder_booster_factor: Option, graffiti_policy: Option, ) -> Result< @@ -2617,6 +2626,7 @@ impl BeaconNodeHttpClient { randao_reveal, graffiti, SkipRandaoVerification::No, + include_payload, builder_booster_factor, graffiti_policy, ) @@ -2624,12 +2634,14 @@ impl BeaconNodeHttpClient { } /// `GET v4/validator/blocks/{slot}` + #[allow(clippy::too_many_arguments)] pub async fn get_validator_blocks_v4_modular( &self, slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, + include_payload: Option, builder_booster_factor: Option, graffiti_policy: Option, ) -> Result< @@ -2645,6 +2657,7 @@ impl BeaconNodeHttpClient { randao_reveal, graffiti, skip_randao_verification, + include_payload, builder_booster_factor, graffiti_policy, ) @@ -2675,6 +2688,7 @@ impl BeaconNodeHttpClient { slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, + include_payload: Option, builder_booster_factor: Option, graffiti_policy: Option, ) -> Result<(BeaconBlock, ProduceBlockV4Metadata), Error> { @@ -2683,6 +2697,7 @@ impl BeaconNodeHttpClient { randao_reveal, graffiti, SkipRandaoVerification::No, + include_payload, builder_booster_factor, graffiti_policy, ) @@ -2690,12 +2705,14 @@ impl BeaconNodeHttpClient { } /// `GET v4/validator/blocks/{slot}` in ssz format + #[allow(clippy::too_many_arguments)] pub async fn get_validator_blocks_v4_modular_ssz( &self, slot: Slot, randao_reveal: &SignatureBytes, graffiti: Option<&Graffiti>, skip_randao_verification: SkipRandaoVerification, + include_payload: Option, builder_booster_factor: Option, graffiti_policy: Option, ) -> Result<(BeaconBlock, ProduceBlockV4Metadata), Error> { @@ -2705,6 +2722,7 @@ impl BeaconNodeHttpClient { randao_reveal, graffiti, skip_randao_verification, + include_payload, builder_booster_factor, graffiti_policy, ) @@ -2734,11 +2752,10 @@ impl BeaconNodeHttpClient { opt_response.ok_or(Error::StatusCode(StatusCode::NOT_FOUND)) } - /// `GET v1/validator/execution_payload_envelope/{slot}/{builder_index}` + /// `GET v1/validator/execution_payload_envelope/{slot}` pub async fn get_validator_execution_payload_envelope( &self, slot: Slot, - builder_index: u64, ) -> Result>, Error> { let mut path = self.eth_path(V1)?; @@ -2746,17 +2763,15 @@ impl BeaconNodeHttpClient { .map_err(|()| Error::InvalidUrl(self.server.clone()))? .push("validator") .push("execution_payload_envelope") - .push(&slot.to_string()) - .push(&builder_index.to_string()); + .push(&slot.to_string()); self.get(path).await } - /// `GET v1/validator/execution_payload_envelope/{slot}/{builder_index}` in SSZ format + /// `GET v1/validator/execution_payload_envelope/{slot}` in SSZ format pub async fn get_validator_execution_payload_envelope_ssz( &self, slot: Slot, - builder_index: u64, ) -> Result, Error> { let mut path = self.eth_path(V1)?; @@ -2764,8 +2779,7 @@ impl BeaconNodeHttpClient { .map_err(|()| Error::InvalidUrl(self.server.clone()))? .push("validator") .push("execution_payload_envelope") - .push(&slot.to_string()) - .push(&builder_index.to_string()); + .push(&slot.to_string()); let opt_response = self .get_bytes_opt_accept_header(path, Accept::Ssz, self.timeouts.get_validator_block) diff --git a/common/eth2/src/types.rs b/common/eth2/src/types.rs index dfa0fbd87d..449ea88685 100644 --- a/common/eth2/src/types.rs +++ b/common/eth2/src/types.rs @@ -5,7 +5,7 @@ pub use types::*; use crate::{ CONSENSUS_BLOCK_VALUE_HEADER, CONSENSUS_VERSION_HEADER, EXECUTION_PAYLOAD_BLINDED_HEADER, - EXECUTION_PAYLOAD_VALUE_HEADER, Error as ServerError, + EXECUTION_PAYLOAD_INCLUDED_HEADER, EXECUTION_PAYLOAD_VALUE_HEADER, Error as ServerError, }; use bls::{PublicKeyBytes, SecretKey, Signature, SignatureBytes}; use context_deserialize::ContextDeserialize; @@ -778,6 +778,7 @@ pub struct ValidatorBlocksQuery { pub randao_reveal: SignatureBytes, pub graffiti: Option, pub skip_randao_verification: SkipRandaoVerification, + pub include_payload: Option, pub builder_boost_factor: Option, pub graffiti_policy: Option, } @@ -1848,6 +1849,7 @@ pub struct ProduceBlockV4Metadata { pub consensus_version: ForkName, #[serde(with = "serde_utils::u256_dec")] pub consensus_block_value: Uint256, + pub execution_payload_included: bool, } impl FullBlockContents { @@ -2021,10 +2023,16 @@ impl TryFrom<&HeaderMap> for ProduceBlockV4Metadata { Uint256::from_str_radix(s, 10) .map_err(|e| format!("invalid {CONSENSUS_BLOCK_VALUE_HEADER}: {e:?}")) })?; + let execution_payload_included = + parse_required_header(headers, EXECUTION_PAYLOAD_INCLUDED_HEADER, |s| { + s.parse::() + .map_err(|e| format!("invalid {EXECUTION_PAYLOAD_INCLUDED_HEADER}: {e:?}")) + })?; Ok(ProduceBlockV4Metadata { consensus_version, consensus_block_value, + execution_payload_included, }) } } diff --git a/validator_client/validator_services/src/block_service.rs b/validator_client/validator_services/src/block_service.rs index 99e53b0100..1dd1878f4c 100644 --- a/validator_client/validator_services/src/block_service.rs +++ b/validator_client/validator_services/src/block_service.rs @@ -14,7 +14,6 @@ use std::time::Duration; use task_executor::TaskExecutor; use tokio::sync::mpsc; use tracing::{Instrument, debug, error, info, info_span, instrument, trace, warn}; -use types::consts::gloas::BUILDER_INDEX_SELF_BUILD; use types::{BlockType, ChainSpec, EthSpec, Graffiti, Slot}; use validator_store::{Error as ValidatorStoreError, SignedBlock, UnsignedBlock, ValidatorStore}; @@ -479,6 +478,7 @@ impl BlockService { slot, randao_reveal_ref, graffiti.as_ref(), + None, builder_boost_factor, self_ref.graffiti_policy, ) @@ -506,6 +506,7 @@ impl BlockService { slot, randao_reveal_ref, graffiti.as_ref(), + None, builder_boost_factor, self_ref.graffiti_policy, ) @@ -652,16 +653,13 @@ impl BlockService { ) -> Result<(), BlockError> { info!(slot = slot.as_u64(), "Fetching execution payload envelope"); - // Fetch the envelope from the beacon node. Use builder_index=BUILDER_INDEX_SELF_BUILD for local building. + // Fetch the envelope from the beacon node. // TODO(gloas): Use proposer_fallback once multi-BN is supported. let envelope = self .beacon_nodes .first_success(|beacon_node| async move { beacon_node - .get_validator_execution_payload_envelope_ssz::( - slot, - BUILDER_INDEX_SELF_BUILD, - ) + .get_validator_execution_payload_envelope_ssz::(slot) .await .map_err(|e| { BlockError::Recoverable(format!( From f968c7e5bbc9e55931a3fc1d1dc62138dca50622 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Tue, 12 May 2026 04:59:54 +0300 Subject: [PATCH 170/189] Dont penalize payload envelope peers after gossip verification (#9283) We got in a little bit of trouble in devnet-3. After gossip verifying an envelope and before importing it, we got the following error ``` May 07 08:04:24.383 WARN Execution payload envelope rejected reason: "EnvelopeProcessingError(WithdrawalsRootMismatch { state: 0x852d38aaecc9f4e2e309919f74020c7bbcf050fea4a20edf3304f171e44ee9d5, payload: ``` The envelope had already passed gossip verification checks and was correctly propagated to the network, we should not penalize peers for doing this. This caused our node to isolate itself from the rest of the network. This PR removes peer penalties for any envelope that passes gossip validation Co-Authored-By: Eitan Seri-Levi --- .../gossip_methods.rs | 45 +++---------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 0135d7f5dd..57871a2009 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -3977,44 +3977,13 @@ impl NetworkBeaconProcessor { // TODO(gloas) metrics // register_process_result_metrics(&result, metrics::BlockSource::Gossip, "envelope"); - match &result { - Ok(AvailabilityProcessingStatus::Imported(_)) - | Ok(AvailabilityProcessingStatus::MissingComponents(_, _)) => { - // Nothing to do - } - Err(e) => match e { - EnvelopeError::ExecutionPayloadError(epe) if !epe.penalize_peer() => {} - EnvelopeError::BadSignature - | EnvelopeError::BuilderIndexMismatch { .. } - | EnvelopeError::SlotMismatch { .. } - | EnvelopeError::BlockHashMismatch { .. } - | EnvelopeError::UnknownValidator { .. } - | EnvelopeError::IncorrectBlockProposer { .. } - | EnvelopeError::ExecutionPayloadError(_) => { - self.gossip_penalize_peer( - peer_id, - PeerAction::LowToleranceError, - "gossip_envelope_processing_low", - ); - } - - EnvelopeError::EnvelopeProcessingError(_) - | EnvelopeError::BlockError(_) - | EnvelopeError::BlockRootUnknown { .. } => { - self.gossip_penalize_peer( - peer_id, - PeerAction::LowToleranceError, - "gossip_envelope_processing_error", - ); - } - - EnvelopeError::PriorToFinalization { .. } - | EnvelopeError::OptimisticSyncNotSupported { .. } - | EnvelopeError::BeaconChainError(_) - | EnvelopeError::BeaconStateError(_) - | EnvelopeError::BlockProcessingError(_) - | EnvelopeError::InternalError(_) => {} - }, + if let Err(e) = &result { + debug!( + ?beacon_block_root, + %peer_id, + error = ?e, + "Execution payload envelope processing failed" + ); } } From 757873200ba78d7c84eea6f77963c5e4ca8b12e3 Mon Sep 17 00:00:00 2001 From: hopinheimer <48147533+hopinheimer@users.noreply.github.com> Date: Tue, 12 May 2026 02:24:18 -0400 Subject: [PATCH 171/189] Fix stale `beacon_state_root` in test helpers (#9289) Test helpers `add_attested_block_at_slot` and `add_attested_blocks_at_slot` accepted `state_root` argument which was computed before applying the block. Co-Authored-By: hopinheimer --- .../beacon_chain/src/shuffling_cache.rs | 2 +- beacon_node/beacon_chain/src/test_utils.rs | 57 +++--- beacon_node/beacon_chain/tests/rewards.rs | 4 +- beacon_node/beacon_chain/tests/store_tests.rs | 186 ++++++------------ .../tests/sync_committee_verification.rs | 6 +- .../beacon_chain/tests/validator_monitor.rs | 5 +- beacon_node/http_api/tests/fork_tests.rs | 28 +-- beacon_node/operation_pool/src/lib.rs | 46 ++--- .../src/per_block_processing/tests.rs | 2 - .../src/per_epoch_processing/tests.rs | 2 - consensus/types/tests/committee_cache.rs | 1 - consensus/types/tests/state.rs | 1 - testing/state_transition_vectors/src/main.rs | 4 +- 13 files changed, 118 insertions(+), 226 deletions(-) diff --git a/beacon_node/beacon_chain/src/shuffling_cache.rs b/beacon_node/beacon_chain/src/shuffling_cache.rs index 3d0fd80cf6..0377b553e3 100644 --- a/beacon_node/beacon_chain/src/shuffling_cache.rs +++ b/beacon_node/beacon_chain/src/shuffling_cache.rs @@ -325,7 +325,7 @@ mod test { .deterministic_keypairs(8) .fresh_ephemeral_store() .build(); - let (mut state, _) = harness.get_current_state_and_root(); + let mut state = harness.get_current_state(); state .build_committee_cache(RelativeEpoch::Current, &harness.chain.spec) .unwrap(); diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index ca55811a70..4378da8405 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -1570,6 +1570,15 @@ where mut state: Cow>, state_root: Hash256, ) -> Result, BeaconChainError> { + assert_eq!( + state.get_latest_block_root(state_root), + beacon_block_root, + "State must match beacon block root, state slot {:?} attestation slot {:?} state root {:?}", + state.latest_block_header().slot, + slot, + state_root, + ); + let epoch = slot.epoch(E::slots_per_epoch()); if state.slot() > slot { @@ -1594,6 +1603,13 @@ where *state.get_block_root(target_slot)? }; + let payload_present = state.fork_name_unchecked().gloas_enabled() + && state.latest_block_header().slot != slot + && self + .chain + .canonical_head + .block_has_canonical_payload(&beacon_block_root, &self.spec)?; + Ok(Attestation::empty_for_signing( index, committee_len, @@ -1604,7 +1620,7 @@ where epoch, root: target_root, }, - false, + payload_present, &self.spec, )?) } @@ -3111,13 +3127,11 @@ where &self, slot: Slot, state: BeaconState, - state_root: Hash256, validators: &[usize], ) -> Result<(SignedBeaconBlockHash, BeaconState), BlockError> { self.add_attested_block_at_slot_with_sync( slot, state, - state_root, validators, SyncCommitteeStrategy::NoValidators, ) @@ -3128,18 +3142,18 @@ where &self, slot: Slot, state: BeaconState, - state_root: Hash256, validators: &[usize], sync_committee_strategy: SyncCommitteeStrategy, ) -> Result<(SignedBeaconBlockHash, BeaconState), BlockError> { - let (block_hash, block, state) = self.add_block_at_slot(slot, state).await?; - self.attest_block(&state, state_root, block_hash, &block.0, validators); + let (block_hash, block, mut new_state) = self.add_block_at_slot(slot, state).await?; + let new_state_root = new_state.canonical_root().unwrap(); + self.attest_block(&new_state, new_state_root, block_hash, &block.0, validators); if sync_committee_strategy == SyncCommitteeStrategy::AllValidators - && state.current_sync_committee().is_ok() + && new_state.current_sync_committee().is_ok() { self.sync_committee_sign_block( - &state, + &new_state, block_hash.into(), slot, if (slot + 1).epoch(E::slots_per_epoch()) @@ -3153,19 +3167,17 @@ where ); } - Ok((block_hash, state)) + Ok((block_hash, new_state)) } pub async fn add_attested_blocks_at_slots( &self, state: BeaconState, - state_root: Hash256, slots: &[Slot], validators: &[usize], ) -> AddBlocksResult { self.add_attested_blocks_at_slots_with_sync( state, - state_root, slots, validators, SyncCommitteeStrategy::NoValidators, @@ -3176,7 +3188,6 @@ where pub async fn add_attested_blocks_at_slots_with_sync( &self, state: BeaconState, - state_root: Hash256, slots: &[Slot], validators: &[usize], sync_committee_strategy: SyncCommitteeStrategy, @@ -3184,7 +3195,6 @@ where assert!(!slots.is_empty()); self.add_attested_blocks_at_slots_given_lbh( state, - state_root, slots, validators, None, @@ -3241,7 +3251,6 @@ where pub async fn add_attested_blocks_at_slots_with_lc_data( &self, mut state: BeaconState, - state_root: Hash256, slots: &[Slot], validators: &[usize], mut latest_block_hash: Option, @@ -3255,7 +3264,6 @@ where .add_attested_block_at_slot_with_sync( *slot, state, - state_root, validators, sync_committee_strategy, ) @@ -3281,7 +3289,6 @@ where async fn add_attested_blocks_at_slots_given_lbh( &self, mut state: BeaconState, - state_root: Hash256, slots: &[Slot], validators: &[usize], mut latest_block_hash: Option, @@ -3295,7 +3302,6 @@ where let (block_hash, new_state) = Box::pin(self.add_attested_block_at_slot_with_sync( *slot, state, - state_root, validators, sync_committee_strategy, )) @@ -3359,14 +3365,8 @@ where for epoch in min_epoch.as_u64()..=max_epoch.as_u64() { let mut new_chains = vec![]; - for ( - mut head_state, - slots, - validators, - mut block_hashes, - mut state_hashes, - head_block, - ) in chains + for (head_state, slots, validators, mut block_hashes, mut state_hashes, head_block) in + chains { let epoch_slots = slots .iter() @@ -3374,11 +3374,9 @@ where .copied() .collect::>(); - let head_state_root = head_state.update_tree_hash_cache().unwrap(); let (new_block_hashes, new_state_hashes, new_head_block, new_head_state) = self .add_attested_blocks_at_slots_given_lbh( head_state, - head_state_root, &epoch_slots, &validators, Some(head_block), @@ -3540,7 +3538,7 @@ where sync_committee_strategy: SyncCommitteeStrategy, light_client_strategy: LightClientStrategy, ) -> Hash256 { - let (mut state, slots) = match block_strategy { + let (state, slots) = match block_strategy { BlockStrategy::OnCanonicalHead => { let current_slot: u64 = self.get_current_slot().into(); let slots: Vec = (current_slot..(current_slot + (num_blocks as u64))) @@ -3569,12 +3567,10 @@ where AttestationStrategy::SomeValidators(vals) => vals, }; - let state_root = state.update_tree_hash_cache().unwrap(); let (_, _, last_produced_block_hash, _) = match light_client_strategy { LightClientStrategy::Enabled => { self.add_attested_blocks_at_slots_with_lc_data( state, - state_root, &slots, &validators, None, @@ -3585,7 +3581,6 @@ where LightClientStrategy::Disabled => { self.add_attested_blocks_at_slots_with_sync( state, - state_root, &slots, &validators, sync_committee_strategy, diff --git a/beacon_node/beacon_chain/tests/rewards.rs b/beacon_node/beacon_chain/tests/rewards.rs index bc7c98041f..0c8815995e 100644 --- a/beacon_node/beacon_chain/tests/rewards.rs +++ b/beacon_node/beacon_chain/tests/rewards.rs @@ -105,11 +105,11 @@ async fn test_sync_committee_rewards() { // Add block let chain = &harness.chain; - let (head_state, head_state_root) = harness.get_current_state_and_root(); + let head_state = harness.get_current_state(); let target_slot = harness.get_current_slot() + 1; let (block_root, mut state) = harness - .add_attested_block_at_slot(target_slot, head_state, head_state_root, &[]) + .add_attested_block_at_slot(target_slot, head_state, &[]) .await .unwrap(); diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 1576092c81..cfdd54857a 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -198,11 +198,10 @@ async fn light_client_bootstrap_test() { let num_initial_slots = E::slots_per_epoch() * 7; let slots: Vec = (1..num_initial_slots).map(Slot::new).collect(); - let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); + let genesis_state = harness.get_current_state(); harness .add_attested_blocks_at_slots_with_lc_data( genesis_state.clone(), - genesis_state_root, &slots, &all_validators, None, @@ -258,14 +257,9 @@ async fn light_client_updates_test() { let num_initial_slots = E::slots_per_epoch() * 10; let slots: Vec = (1..num_initial_slots).map(Slot::new).collect(); - let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); + let genesis_state = harness.get_current_state(); harness - .add_attested_blocks_at_slots( - genesis_state.clone(), - genesis_state_root, - &slots, - &all_validators, - ) + .add_attested_blocks_at_slots(genesis_state.clone(), &slots, &all_validators) .await; harness.advance_slot(); @@ -639,7 +633,7 @@ async fn forwards_iter_block_and_state_roots_until() { for slot in (1..=num_blocks_produced).map(Slot::from) { let (block_root, mut state) = harness - .add_attested_block_at_slot(slot, head_state, head_state_root, all_validators) + .add_attested_block_at_slot(slot, head_state, all_validators) .await .unwrap(); head_state_root = state.update_tree_hash_cache().unwrap(); @@ -714,10 +708,10 @@ async fn block_replayer_hooks() { let max_slot = *block_slots.last().unwrap(); let all_slots = (0..=max_slot.as_u64()).map(Slot::new).collect::>(); - let (state, state_root) = harness.get_current_state_and_root(); + let state = harness.get_current_state(); let all_validators = harness.get_all_validators(); let (_, _, end_block_root, mut end_state) = harness - .add_attested_blocks_at_slots(state.clone(), state_root, &block_slots, &all_validators) + .add_attested_blocks_at_slots(state.clone(), &block_slots, &all_validators) .await; let blocks = store @@ -786,10 +780,10 @@ async fn delete_blocks_and_states() { // Finalize an initial portion of the chain. let initial_slots: Vec = (1..=unforked_blocks).map(Into::into).collect(); - let (state, state_root) = harness.get_current_state_and_root(); + let state = harness.get_current_state(); let all_validators = harness.get_all_validators(); harness - .add_attested_blocks_at_slots(state, state_root, &initial_slots, &all_validators) + .add_attested_blocks_at_slots(state, &initial_slots, &all_validators) .await; // Create a fork post-finalization. @@ -924,10 +918,10 @@ async fn multi_epoch_fork_valid_blocks_test( // Create the initial portion of the chain if initial_blocks > 0 { let initial_slots: Vec = (1..=initial_blocks).map(Into::into).collect(); - let (state, state_root) = harness.get_current_state_and_root(); + let state = harness.get_current_state(); let all_validators = harness.get_all_validators(); harness - .add_attested_blocks_at_slots(state, state_root, &initial_slots, &all_validators) + .add_attested_blocks_at_slots(state, &initial_slots, &all_validators) .await; } @@ -1269,17 +1263,17 @@ async fn proposer_shuffling_root_consistency_test( // Build chain out to parent block. let initial_slots: Vec = (1..=parent_slot).map(Into::into).collect(); - let (state, state_root) = harness.get_current_state_and_root(); + let state = harness.get_current_state(); let all_validators = harness.get_all_validators(); let (_, _, parent_root, _) = harness - .add_attested_blocks_at_slots(state, state_root, &initial_slots, &all_validators) + .add_attested_blocks_at_slots(state, &initial_slots, &all_validators) .await; // Add the child block. - let (state, state_root) = harness.get_current_state_and_root(); + let state = harness.get_current_state(); let all_validators = harness.get_all_validators(); let (_, _, child_root, child_block_state) = harness - .add_attested_blocks_at_slots(state, state_root, &[child_slot], &all_validators) + .add_attested_blocks_at_slots(state, &[child_slot], &all_validators) .await; let child_block_epoch = child_slot.epoch(E::slots_per_epoch()); @@ -1591,10 +1585,10 @@ async fn proposer_duties_from_head_fulu() { // Build chain out to parent block. let initial_slots: Vec = (1..=initial_blocks).map(Into::into).collect(); - let (state, state_root) = harness.get_current_state_and_root(); + let state = harness.get_current_state(); let all_validators = harness.get_all_validators(); let (_, _, head_block_root, head_state) = harness - .add_attested_blocks_at_slots(state, state_root, &initial_slots, &all_validators) + .add_attested_blocks_at_slots(state, &initial_slots, &all_validators) .await; // Compute the proposer duties at the next epoch from the head @@ -1642,10 +1636,10 @@ async fn proposer_lookahead_gloas_fork_epoch() { // Build chain out to parent block. let initial_slots: Vec = (1..=initial_blocks).map(Into::into).collect(); - let (state, state_root) = harness.get_current_state_and_root(); + let state = harness.get_current_state(); let all_validators = harness.get_all_validators(); let (_, _, head_block_root, mut head_state) = harness - .add_attested_blocks_at_slots(state, state_root, &initial_slots, &all_validators) + .add_attested_blocks_at_slots(state, &initial_slots, &all_validators) .await; let head_state_root = head_state.canonical_root().unwrap(); @@ -1681,7 +1675,7 @@ async fn proposer_lookahead_gloas_fork_epoch() { // Build a block in the Gloas fork epoch and assert that the shuffling does not change. let gloas_slots = vec![gloas_fork_epoch.start_slot(E::slots_per_epoch())]; let (_, _, _, _) = harness - .add_attested_blocks_at_slots(head_state, head_state_root, &gloas_slots, &all_validators) + .add_attested_blocks_at_slots(head_state, &gloas_slots, &all_validators) .await; let (no_lookahead_indices, no_lookahead_dependent_root, _, _, no_lookahead_fork) = @@ -1704,16 +1698,11 @@ async fn prunes_abandoned_fork_between_two_finalized_checkpoints() { let store = get_store(&db_path); let rig = get_harness(store.clone(), VALIDATOR_COUNT); let slots_per_epoch = rig.slots_per_epoch(); - let (mut state, state_root) = rig.get_current_state_and_root(); + let mut state = rig.get_current_state(); let canonical_chain_slots: Vec = (1..=rig.epoch_start_slot(1)).map(Slot::new).collect(); let (canonical_chain_blocks_pre_finalization, _, _, new_state) = rig - .add_attested_blocks_at_slots( - state, - state_root, - &canonical_chain_slots, - &honest_validators, - ) + .add_attested_blocks_at_slots(state, &canonical_chain_slots, &honest_validators) .await; state = new_state; let canonical_chain_slot: u64 = rig.get_current_slot().into(); @@ -1721,14 +1710,9 @@ async fn prunes_abandoned_fork_between_two_finalized_checkpoints() { let stray_slots: Vec = (canonical_chain_slot + 1..rig.epoch_start_slot(2)) .map(Slot::new) .collect(); - let (current_state, current_state_root) = rig.get_current_state_and_root(); + let current_state = rig.get_current_state(); let (stray_blocks, stray_states, stray_head, _) = rig - .add_attested_blocks_at_slots( - current_state, - current_state_root, - &stray_slots, - &adversarial_validators, - ) + .add_attested_blocks_at_slots(current_state, &stray_slots, &adversarial_validators) .await; // Precondition: Ensure all stray_blocks blocks are still known @@ -1758,9 +1742,8 @@ async fn prunes_abandoned_fork_between_two_finalized_checkpoints() { ..=(canonical_chain_slot + slots_per_epoch * 5)) .map(Slot::new) .collect(); - let state_root = state.update_tree_hash_cache().unwrap(); let (canonical_chain_blocks_post_finalization, _, _, _) = rig - .add_attested_blocks_at_slots(state, state_root, &finalization_slots, &honest_validators) + .add_attested_blocks_at_slots(state, &finalization_slots, &honest_validators) .await; // Postcondition: New blocks got finalized @@ -1815,15 +1798,14 @@ async fn pruning_does_not_touch_abandoned_block_shared_with_canonical_chain() { let store = get_store(&db_path); let rig = get_harness(store.clone(), VALIDATOR_COUNT); let slots_per_epoch = rig.slots_per_epoch(); - let (state, state_root) = rig.get_current_state_and_root(); + let state = rig.get_current_state(); // Fill up 0th epoch let canonical_chain_slots_zeroth_epoch: Vec = (1..rig.epoch_start_slot(1)).map(Slot::new).collect(); - let (_, _, _, mut state) = rig + let (_, _, _, state) = rig .add_attested_blocks_at_slots( state, - state_root, &canonical_chain_slots_zeroth_epoch, &honest_validators, ) @@ -1834,11 +1816,9 @@ async fn pruning_does_not_touch_abandoned_block_shared_with_canonical_chain() { ..=rig.epoch_start_slot(1) + 1) .map(Slot::new) .collect(); - let state_root = state.update_tree_hash_cache().unwrap(); - let (canonical_chain_blocks_first_epoch, _, shared_head, mut state) = rig + let (canonical_chain_blocks_first_epoch, _, shared_head, state) = rig .add_attested_blocks_at_slots( state.clone(), - state_root, &canonical_chain_slots_first_epoch, &honest_validators, ) @@ -1849,11 +1829,9 @@ async fn pruning_does_not_touch_abandoned_block_shared_with_canonical_chain() { ..=rig.epoch_start_slot(1) + 2) .map(Slot::new) .collect(); - let state_root = state.update_tree_hash_cache().unwrap(); let (stray_blocks, stray_states, stray_head, _) = rig .add_attested_blocks_at_slots( state.clone(), - state_root, &stray_chain_slots_first_epoch, &adversarial_validators, ) @@ -1890,9 +1868,8 @@ async fn pruning_does_not_touch_abandoned_block_shared_with_canonical_chain() { ..=(canonical_chain_slot + slots_per_epoch * 5)) .map(Slot::new) .collect(); - let state_root = state.update_tree_hash_cache().unwrap(); let (canonical_chain_blocks, _, _, _) = rig - .add_attested_blocks_at_slots(state, state_root, &finalization_slots, &honest_validators) + .add_attested_blocks_at_slots(state, &finalization_slots, &honest_validators) .await; // Postconditions @@ -1945,12 +1922,12 @@ async fn pruning_does_not_touch_blocks_prior_to_finalization() { let store = get_store(&db_path); let rig = get_harness(store.clone(), VALIDATOR_COUNT); let slots_per_epoch = rig.slots_per_epoch(); - let (mut state, state_root) = rig.get_current_state_and_root(); + let mut state = rig.get_current_state(); // Fill up 0th epoch with canonical chain blocks let zeroth_epoch_slots: Vec = (1..=rig.epoch_start_slot(1)).map(Slot::new).collect(); let (canonical_chain_blocks, _, _, new_state) = rig - .add_attested_blocks_at_slots(state, state_root, &zeroth_epoch_slots, &honest_validators) + .add_attested_blocks_at_slots(state, &zeroth_epoch_slots, &honest_validators) .await; state = new_state; let canonical_chain_slot: u64 = rig.get_current_slot().into(); @@ -1959,14 +1936,8 @@ async fn pruning_does_not_touch_blocks_prior_to_finalization() { let first_epoch_slots: Vec = ((rig.epoch_start_slot(1) + 1)..(rig.epoch_start_slot(2))) .map(Slot::new) .collect(); - let state_root = state.update_tree_hash_cache().unwrap(); let (stray_blocks, stray_states, stray_head, _) = rig - .add_attested_blocks_at_slots( - state.clone(), - state_root, - &first_epoch_slots, - &adversarial_validators, - ) + .add_attested_blocks_at_slots(state.clone(), &first_epoch_slots, &adversarial_validators) .await; // Preconditions @@ -1994,9 +1965,8 @@ async fn pruning_does_not_touch_blocks_prior_to_finalization() { ..=(canonical_chain_slot + slots_per_epoch * 4)) .map(Slot::new) .collect(); - let state_root = state.update_tree_hash_cache().unwrap(); let (_, _, _, _) = rig - .add_attested_blocks_at_slots(state, state_root, &slots, &honest_validators) + .add_attested_blocks_at_slots(state, &slots, &honest_validators) .await; // Postconditions @@ -2037,29 +2007,23 @@ async fn prunes_fork_growing_past_youngest_finalized_checkpoint() { let db_path = tempdir().unwrap(); let store = get_store(&db_path); let rig = get_harness(store.clone(), VALIDATOR_COUNT); - let (state, state_root) = rig.get_current_state_and_root(); + let state = rig.get_current_state(); // Fill up 0th epoch with canonical chain blocks let zeroth_epoch_slots: Vec = (1..=rig.epoch_start_slot(1)).map(Slot::new).collect(); - let (canonical_blocks_zeroth_epoch, _, _, mut state) = rig - .add_attested_blocks_at_slots(state, state_root, &zeroth_epoch_slots, &honest_validators) + let (canonical_blocks_zeroth_epoch, _, _, state) = rig + .add_attested_blocks_at_slots(state, &zeroth_epoch_slots, &honest_validators) .await; // Fill up 1st epoch. Contains a fork. let slots_first_epoch: Vec = (rig.epoch_start_slot(1) + 1..rig.epoch_start_slot(2)) .map(Into::into) .collect(); - let state_root = state.update_tree_hash_cache().unwrap(); - let (stray_blocks_first_epoch, stray_states_first_epoch, _, mut stray_state) = rig - .add_attested_blocks_at_slots( - state.clone(), - state_root, - &slots_first_epoch, - &adversarial_validators, - ) + let (stray_blocks_first_epoch, stray_states_first_epoch, _, stray_state) = rig + .add_attested_blocks_at_slots(state.clone(), &slots_first_epoch, &adversarial_validators) .await; - let (canonical_blocks_first_epoch, _, _, mut canonical_state) = rig - .add_attested_blocks_at_slots(state, state_root, &slots_first_epoch, &honest_validators) + let (canonical_blocks_first_epoch, _, _, canonical_state) = rig + .add_attested_blocks_at_slots(state, &slots_first_epoch, &honest_validators) .await; // Fill up 2nd epoch. Extends both the canonical chain and the fork. @@ -2067,11 +2031,9 @@ async fn prunes_fork_growing_past_youngest_finalized_checkpoint() { ..=rig.epoch_start_slot(2) + 1) .map(Into::into) .collect(); - let stray_state_root = stray_state.update_tree_hash_cache().unwrap(); let (stray_blocks_second_epoch, stray_states_second_epoch, stray_head, _) = rig .add_attested_blocks_at_slots( stray_state, - stray_state_root, &stray_slots_second_epoch, &adversarial_validators, ) @@ -2114,10 +2076,8 @@ async fn prunes_fork_growing_past_youngest_finalized_checkpoint() { let canonical_slots: Vec = (rig.epoch_start_slot(2)..=rig.epoch_start_slot(6)) .map(Into::into) .collect(); - let canonical_state_root = canonical_state.update_tree_hash_cache().unwrap(); let (canonical_blocks, _, _, _) = Box::pin(rig.add_attested_blocks_at_slots( canonical_state, - canonical_state_root, &canonical_slots, &honest_validators, )) @@ -2179,14 +2139,13 @@ async fn prunes_skipped_slots_states() { let db_path = tempdir().unwrap(); let store = get_store(&db_path); let rig = get_harness(store.clone(), VALIDATOR_COUNT); - let (state, state_root) = rig.get_current_state_and_root(); + let state = rig.get_current_state(); let canonical_slots_zeroth_epoch: Vec = (1..=rig.epoch_start_slot(1)).map(Into::into).collect(); - let (canonical_blocks_zeroth_epoch, _, _, mut canonical_state) = rig + let (canonical_blocks_zeroth_epoch, _, _, canonical_state) = rig .add_attested_blocks_at_slots( state.clone(), - state_root, &canonical_slots_zeroth_epoch, &honest_validators, ) @@ -2197,11 +2156,9 @@ async fn prunes_skipped_slots_states() { let stray_slots: Vec = ((skipped_slot + 1).into()..rig.epoch_start_slot(2)) .map(Into::into) .collect(); - let canonical_state_root = canonical_state.update_tree_hash_cache().unwrap(); let (stray_blocks, stray_states, _, stray_state) = rig .add_attested_blocks_at_slots( canonical_state.clone(), - canonical_state_root, &stray_slots, &adversarial_validators, ) @@ -2242,14 +2199,8 @@ async fn prunes_skipped_slots_states() { let canonical_slots: Vec = ((skipped_slot + 1).into()..rig.epoch_start_slot(7)) .map(Into::into) .collect(); - let canonical_state_root = canonical_state.update_tree_hash_cache().unwrap(); let (canonical_blocks_post_finalization, _, _, _) = rig - .add_attested_blocks_at_slots( - canonical_state, - canonical_state_root, - &canonical_slots, - &honest_validators, - ) + .add_attested_blocks_at_slots(canonical_state, &canonical_slots, &honest_validators) .await; // Postconditions @@ -2304,14 +2255,13 @@ async fn finalizes_non_epoch_start_slot() { let db_path = tempdir().unwrap(); let store = get_store(&db_path); let rig = get_harness(store.clone(), VALIDATOR_COUNT); - let (state, state_root) = rig.get_current_state_and_root(); + let state = rig.get_current_state(); let canonical_slots_zeroth_epoch: Vec = (1..rig.epoch_start_slot(1)).map(Into::into).collect(); - let (canonical_blocks_zeroth_epoch, _, _, mut canonical_state) = rig + let (canonical_blocks_zeroth_epoch, _, _, canonical_state) = rig .add_attested_blocks_at_slots( state.clone(), - state_root, &canonical_slots_zeroth_epoch, &honest_validators, ) @@ -2322,11 +2272,9 @@ async fn finalizes_non_epoch_start_slot() { let stray_slots: Vec = ((skipped_slot + 1).into()..rig.epoch_start_slot(2)) .map(Into::into) .collect(); - let canonical_state_root = canonical_state.update_tree_hash_cache().unwrap(); let (stray_blocks, stray_states, _, stray_state) = rig .add_attested_blocks_at_slots( canonical_state.clone(), - canonical_state_root, &stray_slots, &adversarial_validators, ) @@ -2367,14 +2315,8 @@ async fn finalizes_non_epoch_start_slot() { let canonical_slots: Vec = ((skipped_slot + 1).into()..rig.epoch_start_slot(7)) .map(Into::into) .collect(); - let canonical_state_root = canonical_state.update_tree_hash_cache().unwrap(); let (canonical_blocks_post_finalization, _, _, _) = rig - .add_attested_blocks_at_slots( - canonical_state, - canonical_state_root, - &canonical_slots, - &honest_validators, - ) + .add_attested_blocks_at_slots(canonical_state, &canonical_slots, &honest_validators) .await; // Postconditions @@ -2597,11 +2539,10 @@ async fn pruning_test( let start_slot = Slot::new(1); let divergence_slot = start_slot + num_initial_blocks; - let (state, state_root) = harness.get_current_state_and_root(); + let state = harness.get_current_state(); let (_, _, _, divergence_state) = harness .add_attested_blocks_at_slots( state, - state_root, &slots(start_slot, num_initial_blocks)[..], &honest_validators, ) @@ -2626,7 +2567,7 @@ async fn pruning_test( ), ]) .await; - let (_, _, _, mut canonical_state) = chains.remove(0); + let (_, _, _, canonical_state) = chains.remove(0); let (stray_blocks, stray_states, _, stray_head_state) = chains.remove(0); let stray_head_slot = divergence_slot + num_fork_skips + num_fork_blocks - 1; @@ -2650,11 +2591,9 @@ async fn pruning_test( // Trigger finalization let num_finalization_blocks = 4 * E::slots_per_epoch(); let canonical_slot = divergence_slot + num_canonical_skips + num_canonical_middle_blocks; - let canonical_state_root = canonical_state.update_tree_hash_cache().unwrap(); harness .add_attested_blocks_at_slots( canonical_state, - canonical_state_root, &slots(canonical_slot, num_finalization_blocks), &honest_validators, ) @@ -2862,14 +2801,9 @@ async fn reproduction_unaligned_checkpoint_sync_pruned_payload() { let harness = get_harness_import_all_data_columns(full_store.clone(), LOW_VALIDATOR_COUNT); let all_validators = (0..LOW_VALIDATOR_COUNT).collect::>(); - let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); + let genesis_state = harness.get_current_state(); harness - .add_attested_blocks_at_slots( - genesis_state.clone(), - genesis_state_root, - &slots, - &all_validators, - ) + .add_attested_blocks_at_slots(genesis_state.clone(), &slots, &all_validators) .await; // Extract snapshot data from the harness. @@ -3016,14 +2950,9 @@ async fn weak_subjectivity_sync_test( let all_validators = (0..LOW_VALIDATOR_COUNT).collect::>(); - let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); + let genesis_state = harness.get_current_state(); harness - .add_attested_blocks_at_slots( - genesis_state.clone(), - genesis_state_root, - &slots, - &all_validators, - ) + .add_attested_blocks_at_slots(genesis_state.clone(), &slots, &all_validators) .await; let wss_block_root = harness @@ -3831,14 +3760,9 @@ async fn process_blocks_and_attestations_for_unaligned_checkpoint() { .map(Slot::new) .collect::>(); - let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); + let genesis_state = harness.get_current_state(); harness - .add_attested_blocks_at_slots( - genesis_state.clone(), - genesis_state_root, - &slots, - &all_validators, - ) + .add_attested_blocks_at_slots(genesis_state.clone(), &slots, &all_validators) .await; // Before the split slot becomes finalized, create two forking blocks that build on the split @@ -5706,7 +5630,7 @@ async fn test_gloas_block_replay_with_envelopes() { let harness = get_harness(store.clone(), LOW_VALIDATOR_COUNT); let num_blocks = 16u64; - let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); + let genesis_state = harness.get_current_state(); let mut state = genesis_state.clone(); let mut last_block_root = Hash256::zero(); @@ -5782,7 +5706,7 @@ async fn test_gloas_hot_state_hierarchy() { let num_blocks = E::slots_per_epoch() * 5; let all_validators = (0..LOW_VALIDATOR_COUNT).collect::>(); - let (genesis_state, _genesis_state_root) = harness.get_current_state_and_root(); + let genesis_state = harness.get_current_state(); // Use manual block building with envelopes for the first few blocks, // then use the standard attested-blocks path once we've verified envelope handling. diff --git a/beacon_node/beacon_chain/tests/sync_committee_verification.rs b/beacon_node/beacon_chain/tests/sync_committee_verification.rs index d2124c6641..b01084c6aa 100644 --- a/beacon_node/beacon_chain/tests/sync_committee_verification.rs +++ b/beacon_node/beacon_chain/tests/sync_committee_verification.rs @@ -185,7 +185,6 @@ async fn aggregated_gossip_verification() { harness .add_attested_blocks_at_slots( state, - Hash256::zero(), &[Slot::new(1), Slot::new(2)], (0..VALIDATOR_COUNT).collect::>().as_slice(), ) @@ -495,7 +494,7 @@ async fn aggregated_gossip_verification() { ); harness - .add_attested_block_at_slot(target_slot, state, Hash256::zero(), &[]) + .add_attested_block_at_slot(target_slot, state, &[]) .await .expect("should add block"); @@ -519,7 +518,6 @@ async fn unaggregated_gossip_verification() { harness .add_attested_blocks_at_slots( state, - Hash256::zero(), &[Slot::new(1), Slot::new(2)], (0..VALIDATOR_COUNT).collect::>().as_slice(), ) @@ -801,7 +799,7 @@ async fn unaggregated_gossip_verification() { ); harness - .add_attested_block_at_slot(target_slot, state, Hash256::zero(), &[]) + .add_attested_block_at_slot(target_slot, state, &[]) .await .expect("should add block"); diff --git a/beacon_node/beacon_chain/tests/validator_monitor.rs b/beacon_node/beacon_chain/tests/validator_monitor.rs index a37ab6458f..9e3973d0d1 100644 --- a/beacon_node/beacon_chain/tests/validator_monitor.rs +++ b/beacon_node/beacon_chain/tests/validator_monitor.rs @@ -46,8 +46,7 @@ async fn missed_blocks_across_epochs() { let harness = get_harness(VALIDATOR_COUNT, vec![]); let validator_monitor = &harness.chain.validator_monitor; - let mut genesis_state = harness.get_current_state(); - let genesis_state_root = genesis_state.update_tree_hash_cache().unwrap(); + let genesis_state = harness.get_current_state(); let genesis_block_root = harness.head_block_root(); // Skip a slot in the first epoch (to prime the cache inside the missed block function) and then @@ -64,7 +63,7 @@ async fn missed_blocks_across_epochs() { .collect::>(); let (block_roots_by_slot, state_roots_by_slot, _, head_state) = harness - .add_attested_blocks_at_slots(genesis_state, genesis_state_root, &slots, &all_validators) + .add_attested_blocks_at_slots(genesis_state, &slots, &all_validators) .await; // Prime the proposer shuffling cache. diff --git a/beacon_node/http_api/tests/fork_tests.rs b/beacon_node/http_api/tests/fork_tests.rs index 4ba35c238c..0ff8ebc452 100644 --- a/beacon_node/http_api/tests/fork_tests.rs +++ b/beacon_node/http_api/tests/fork_tests.rs @@ -57,14 +57,9 @@ async fn sync_committee_duties_across_fork() { // If there's a skip slot at the fork slot, the endpoint should return duties, even // though the head state hasn't transitioned yet. let fork_slot = fork_epoch.start_slot(E::slots_per_epoch()); - let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); - let (_, mut state) = harness - .add_attested_block_at_slot( - fork_slot - 1, - genesis_state, - genesis_state_root, - &all_validators, - ) + let genesis_state = harness.get_current_state(); + let (_, state) = harness + .add_attested_block_at_slot(fork_slot - 1, genesis_state, &all_validators) .await .unwrap(); @@ -79,9 +74,8 @@ async fn sync_committee_duties_across_fork() { assert_eq!(sync_duties.len(), E::sync_committee_size()); // After applying a block at the fork slot the duties should remain unchanged. - let state_root = state.canonical_root().unwrap(); harness - .add_attested_block_at_slot(fork_slot, state, state_root, &all_validators) + .add_attested_block_at_slot(fork_slot, state, &all_validators) .await .unwrap(); @@ -295,14 +289,9 @@ async fn sync_committee_indices_across_fork() { // If there's a skip slot at the fork slot, the endpoint will return a 400 until a block is // applied. let fork_slot = fork_epoch.start_slot(E::slots_per_epoch()); - let (genesis_state, genesis_state_root) = harness.get_current_state_and_root(); - let (_, mut state) = harness - .add_attested_block_at_slot( - fork_slot - 1, - genesis_state, - genesis_state_root, - &all_validators, - ) + let genesis_state = harness.get_current_state(); + let (_, state) = harness + .add_attested_block_at_slot(fork_slot - 1, genesis_state, &all_validators) .await .unwrap(); @@ -334,9 +323,8 @@ async fn sync_committee_indices_across_fork() { // Once the head is updated it should be useable for requests, including in the next sync // committee period. - let state_root = state.canonical_root().unwrap(); harness - .add_attested_block_at_slot(fork_slot + 1, state, state_root, &all_validators) + .add_attested_block_at_slot(fork_slot + 1, state, &all_validators) .await .unwrap(); diff --git a/beacon_node/operation_pool/src/lib.rs b/beacon_node/operation_pool/src/lib.rs index de5fe9a098..a1789e3b19 100644 --- a/beacon_node/operation_pool/src/lib.rs +++ b/beacon_node/operation_pool/src/lib.rs @@ -897,7 +897,6 @@ mod release_tests { BeaconChainHarness, EphemeralHarnessType, RelativeSyncCommittee, test_spec, }; use bls::Keypair; - use fixed_bytes::FixedBytesExtended; use maplit::hashset; use state_processing::epoch_cache::initialize_epoch_cache; use state_processing::{VerifyOperation, common::get_attesting_indices_from_state}; @@ -944,10 +943,10 @@ mod release_tests { fn get_current_state_initialize_epoch_cache( harness: &BeaconChainHarness>, spec: &ChainSpec, - ) -> BeaconState { - let mut state = harness.get_current_state(); + ) -> (BeaconState, Hash256) { + let (mut state, state_root) = harness.get_current_state_and_root(); initialize_epoch_cache(&mut state, spec).unwrap(); - state + (state, state_root) } /// Test state for sync contribution-related tests. @@ -965,7 +964,6 @@ mod release_tests { harness .add_attested_blocks_at_slots( state, - Hash256::zero(), &[Slot::new(1)], (0..num_validators).collect::>().as_slice(), ) @@ -983,7 +981,7 @@ mod release_tests { return; } - let mut state = get_current_state_initialize_epoch_cache(&harness, spec); + let (mut state, state_root) = get_current_state_initialize_epoch_cache(&harness, spec); let slot = state.slot(); let committees = state .get_beacon_committees_at_slot(slot) @@ -998,8 +996,8 @@ mod release_tests { let attestations = harness.make_attestations( (0..num_validators).collect::>().as_slice(), &state, - Hash256::zero(), - SignedBeaconBlockHash::from(Hash256::zero()), + state_root, + harness.head_block_root().into(), slot, ); @@ -1065,7 +1063,7 @@ mod release_tests { let (harness, ref spec) = attestation_test_state::(1); let op_pool = OperationPool::::new(); - let mut state = get_current_state_initialize_epoch_cache(&harness, spec); + let (mut state, state_root) = get_current_state_initialize_epoch_cache(&harness, spec); let slot = state.slot(); let committees = state @@ -1087,8 +1085,8 @@ mod release_tests { let attestations = harness.make_attestations( (0..num_validators).collect::>().as_slice(), &state, - Hash256::zero(), - SignedBeaconBlockHash::from(Hash256::zero()), + state_root, + harness.head_block_root().into(), slot, ); @@ -1141,7 +1139,7 @@ mod release_tests { fn attestation_duplicate() { let (harness, ref spec) = attestation_test_state::(1); - let state = get_current_state_initialize_epoch_cache(&harness, spec); + let (state, state_root) = get_current_state_initialize_epoch_cache(&harness, spec); let op_pool = OperationPool::::new(); @@ -1158,8 +1156,8 @@ mod release_tests { let attestations = harness.make_attestations( (0..num_validators).collect::>().as_slice(), &state, - Hash256::zero(), - SignedBeaconBlockHash::from(Hash256::zero()), + state_root, + harness.head_block_root().into(), slot, ); @@ -1184,7 +1182,7 @@ mod release_tests { fn attestation_pairwise_overlapping() { let (harness, ref spec) = attestation_test_state::(1); - let state = get_current_state_initialize_epoch_cache(&harness, spec); + let (state, state_root) = get_current_state_initialize_epoch_cache(&harness, spec); let op_pool = OperationPool::::new(); @@ -1202,8 +1200,8 @@ mod release_tests { let attestations = harness.make_attestations( (0..num_validators).collect::>().as_slice(), &state, - Hash256::zero(), - SignedBeaconBlockHash::from(Hash256::zero()), + state_root, + harness.head_block_root().into(), slot, ); @@ -1279,7 +1277,7 @@ mod release_tests { let (harness, ref spec) = attestation_test_state::(num_committees); - let mut state = get_current_state_initialize_epoch_cache(&harness, spec); + let (mut state, state_root) = get_current_state_initialize_epoch_cache(&harness, spec); let op_pool = OperationPool::::new(); @@ -1300,8 +1298,8 @@ mod release_tests { let attestations = harness.make_attestations( (0..num_validators).collect::>().as_slice(), &state, - Hash256::zero(), - SignedBeaconBlockHash::from(Hash256::zero()), + state_root, + harness.head_block_root().into(), slot, ); @@ -1385,7 +1383,7 @@ mod release_tests { let (harness, ref spec) = attestation_test_state::(num_committees); - let mut state = get_current_state_initialize_epoch_cache(&harness, spec); + let (mut state, state_root) = get_current_state_initialize_epoch_cache(&harness, spec); let op_pool = OperationPool::::new(); let slot = state.slot(); @@ -1411,8 +1409,8 @@ mod release_tests { let attestations = harness.make_attestations( (0..num_validators).collect::>().as_slice(), &state, - Hash256::zero(), - SignedBeaconBlockHash::from(Hash256::zero()), + state_root, + harness.head_block_root().into(), slot, ); @@ -2275,7 +2273,6 @@ mod release_tests { harness .add_attested_blocks_at_slots( harness.get_current_state(), - Hash256::zero(), &[Slot::new(1)], (0..num_validators).collect::>().as_slice(), ) @@ -2332,7 +2329,6 @@ mod release_tests { harness .add_attested_blocks_at_slots( harness.get_current_state(), - Hash256::zero(), &[Slot::new(1)], (0..num_validators).collect::>().as_slice(), ) diff --git a/consensus/state_processing/src/per_block_processing/tests.rs b/consensus/state_processing/src/per_block_processing/tests.rs index 96610c2010..593a2557e8 100644 --- a/consensus/state_processing/src/per_block_processing/tests.rs +++ b/consensus/state_processing/src/per_block_processing/tests.rs @@ -12,7 +12,6 @@ use crate::{ }; use beacon_chain::test_utils::{BeaconChainHarness, EphemeralHarnessType}; use bls::{AggregateSignature, Keypair, PublicKeyBytes, Signature, SignatureBytes}; -use fixed_bytes::FixedBytesExtended; use ssz_types::Bitfield; use ssz_types::VariableList; use std::sync::{Arc, LazyLock}; @@ -52,7 +51,6 @@ async fn get_harness( harness .add_attested_blocks_at_slots( state, - Hash256::zero(), (1..last_slot_of_epoch.as_u64()) .map(Slot::new) .collect::>() diff --git a/consensus/state_processing/src/per_epoch_processing/tests.rs b/consensus/state_processing/src/per_epoch_processing/tests.rs index c04b7f843d..29716866b5 100644 --- a/consensus/state_processing/src/per_epoch_processing/tests.rs +++ b/consensus/state_processing/src/per_epoch_processing/tests.rs @@ -2,7 +2,6 @@ use crate::per_epoch_processing::process_epoch; use beacon_chain::test_utils::BeaconChainHarness; use beacon_chain::types::{EthSpec, MinimalEthSpec}; -use bls::{FixedBytesExtended, Hash256}; use types::Slot; #[tokio::test] @@ -22,7 +21,6 @@ async fn runs_without_error() { harness .add_attested_blocks_at_slots( state, - Hash256::zero(), (1..target_slot.as_u64()) .map(Slot::new) .collect::>() diff --git a/consensus/types/tests/committee_cache.rs b/consensus/types/tests/committee_cache.rs index 5c1962276f..5205446c71 100644 --- a/consensus/types/tests/committee_cache.rs +++ b/consensus/types/tests/committee_cache.rs @@ -47,7 +47,6 @@ async fn new_state(validator_count: usize, slot: Slot) -> BeaconStat harness .add_attested_blocks_at_slots( head_state, - Hash256::zero(), (1..=slot.as_u64()) .map(Slot::new) .collect::>() diff --git a/consensus/types/tests/state.rs b/consensus/types/tests/state.rs index 2168da9afc..8e05b8ecb1 100644 --- a/consensus/types/tests/state.rs +++ b/consensus/types/tests/state.rs @@ -39,7 +39,6 @@ async fn get_harness( harness .add_attested_blocks_at_slots( state, - Hash256::zero(), slots.as_slice(), (0..validator_count).collect::>().as_slice(), ) diff --git a/testing/state_transition_vectors/src/main.rs b/testing/state_transition_vectors/src/main.rs index 6a212f034d..68c686649a 100644 --- a/testing/state_transition_vectors/src/main.rs +++ b/testing/state_transition_vectors/src/main.rs @@ -4,7 +4,6 @@ mod exit; use beacon_chain::test_utils::{BeaconChainHarness, EphemeralHarnessType}; use bls::Keypair; -use fixed_bytes::FixedBytesExtended; use ssz::Encode; use std::env; use std::fs::{self, File}; @@ -13,7 +12,7 @@ use std::path::{Path, PathBuf}; use std::process::exit; use std::sync::LazyLock; use types::{BeaconState, EthSpec, SignedBeaconBlock, test_utils::generate_deterministic_keypairs}; -use types::{Hash256, MainnetEthSpec, Slot}; +use types::{MainnetEthSpec, Slot}; type E = MainnetEthSpec; @@ -65,7 +64,6 @@ async fn get_harness( harness .add_attested_blocks_at_slots( state, - Hash256::zero(), (skip_to_slot.as_u64()..slot.as_u64()) .map(Slot::new) .collect::>() From 9101ddc69dad2c04d8bc5e7a58a6c2bea8680efa Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Wed, 13 May 2026 07:20:08 +0300 Subject: [PATCH 172/189] `ignore-ws-check` flag doesnt allow the node to start outside the weak subjectivity period (#9290) Using the `ignore-ws-check` doesn't actually let you start up a node thats outside the weak subjectivity period Co-Authored-By: Eitan Seri-Levi --- beacon_node/beacon_chain/src/builder.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index d70561db9b..13dac087ef 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -849,12 +849,13 @@ where It is highly recommended to purge your db and checkpoint sync. For more information please \ read this blog post: https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity" ) + } else { + return Err( + "The current head state is outside the weak subjectivity period. A node in this state is susceptible to long range attacks. You should purge your db and \ + checkpoint sync. For more information please read this blog post: https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity \ + If you understand the risks, it is possible to ignore this error with the --ignore-ws-check flag.".to_string() + ); } - return Err( - "The current head state is outside the weak subjectivity period. A node in this state is susceptible to long range attacks. You should purge your db and \ - checkpoint sync. For more information please read this blog post: https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity \ - If you understand the risks, it is possible to ignore this error with the --ignore-ws-check flag.".to_string() - ); } let validator_pubkey_cache = self From 1a686311803c7a8123c4fa576f2ea7c0283005dc Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Wed, 13 May 2026 09:03:34 +0200 Subject: [PATCH 173/189] Gloas payload cache (#9209) In Gloas, beacon blocks are imported into fork choice immediately - the payload envelope and data columns arrive separately. KZG commitments moved from the column sidecar into the execution payload bid, so the existing `DataAvailabilityChecker` (which assumes block and data are coupled) can't be used for Gloas. * Introduced `PendingPayloadCache` to keep track of payload and data columns per block root. * Added gossip column verification * Added support for Gloas data column reconstruction * Payload envelope verification simplified: removed `MaybeAvailableEnvelope`, `ExecutedEnvelope`, `EnvelopeImportData` Not yet implemented (tracked with TODOs): - Proper lookup sync for Gloas columns arriving before blocks - Partial column merging for Gloas - Moving `load_gloas_payload_bid` disk reads off the async runtime - Backfill/range sync for Gloas Based on @eserilev's PR and work in progress. See also #9202 for verification. Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Eitan Seri- Levi Co-Authored-By: Daniel Knopik Co-Authored-By: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> Co-Authored-By: Jimmy Chen --- beacon_node/beacon_chain/benches/benches.rs | 8 + beacon_node/beacon_chain/src/beacon_chain.rs | 418 ++++++---- .../beacon_chain/src/block_verification.rs | 7 +- beacon_node/beacon_chain/src/builder.rs | 16 +- .../beacon_chain/src/canonical_head.rs | 19 + .../src/data_availability_checker.rs | 75 +- .../src/data_availability_checker/error.rs | 6 +- .../overflow_lru_cache.rs | 2 +- .../src/data_column_verification.rs | 388 +++++++-- .../fetch_blobs/fetch_blobs_beacon_adapter.rs | 10 +- .../beacon_chain/src/fetch_blobs/mod.rs | 2 +- .../beacon_chain/src/fetch_blobs/tests.rs | 2 +- beacon_node/beacon_chain/src/kzg_utils.rs | 195 ++++- beacon_node/beacon_chain/src/lib.rs | 1 + beacon_node/beacon_chain/src/metrics.rs | 10 + .../execution_pending_envelope.rs | 26 +- .../payload_envelope_verification/import.rs | 95 ++- .../src/payload_envelope_verification/mod.rs | 114 +-- .../payload_notifier.rs | 3 +- .../src/pending_payload_cache/mod.rs | 781 ++++++++++++++++++ .../pending_payload_cache/pending_column.rs | 63 ++ .../pending_components.rs | 180 ++++ beacon_node/beacon_chain/src/test_utils.rs | 67 +- .../beacon_chain/tests/block_verification.rs | 51 +- beacon_node/beacon_chain/tests/events.rs | 12 +- beacon_node/beacon_chain/tests/store_tests.rs | 110 ++- .../src/beacon/execution_payload_envelope.rs | 5 +- beacon_node/http_api/src/publish_blocks.rs | 2 +- .../http_api/tests/interactive_tests.rs | 1 + beacon_node/network/src/metrics.rs | 4 +- .../gossip_methods.rs | 122 ++- .../src/network_beacon_processor/mod.rs | 5 +- .../network_beacon_processor/sync_methods.rs | 2 + .../src/network_beacon_processor/tests.rs | 10 +- .../src/sync/block_sidecar_coupling.rs | 15 +- beacon_node/network/src/sync/manager.rs | 12 +- .../network/src/sync/network_context.rs | 16 +- beacon_node/network/src/sync/tests/lookups.rs | 10 +- consensus/fork_choice/src/fork_choice.rs | 8 + .../types/src/block/signed_beacon_block.rs | 6 + .../execution/signed_execution_payload_bid.rs | 8 + 41 files changed, 2351 insertions(+), 536 deletions(-) create mode 100644 beacon_node/beacon_chain/src/pending_payload_cache/mod.rs create mode 100644 beacon_node/beacon_chain/src/pending_payload_cache/pending_column.rs create mode 100644 beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs diff --git a/beacon_node/beacon_chain/benches/benches.rs b/beacon_node/beacon_chain/benches/benches.rs index e71a19d8c1..de0bd05a1f 100644 --- a/beacon_node/beacon_chain/benches/benches.rs +++ b/beacon_node/beacon_chain/benches/benches.rs @@ -53,6 +53,13 @@ fn all_benches(c: &mut Criterion) { ) .unwrap(); + let kzg_commitments = signed_block + .message() + .body() + .blob_kzg_commitments() + .unwrap() + .clone(); + let spec = spec.clone(); c.bench_function(&format!("reconstruct_{}", blob_count), |b| { @@ -60,6 +67,7 @@ fn all_benches(c: &mut Criterion) { black_box(reconstruct_data_columns( &kzg, column_sidecars.iter().as_slice()[0..column_sidecars.len() / 2].to_vec(), + &kzg_commitments, spec.as_ref(), )) }) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index f618cf6321..af8cd477d6 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -19,9 +19,11 @@ pub use crate::canonical_head::CanonicalHead; use crate::chain_config::ChainConfig; use crate::custody_context::CustodyContextSsz; use crate::data_availability_checker::{ - Availability, AvailabilityCheckError, AvailableBlock, AvailableBlockData, - DataAvailabilityChecker, DataColumnReconstructionResult, + Availability as BlockAvailability, AvailabilityCheckError, AvailableBlock, AvailableBlockData, + DataColumnReconstructionResult as DataColumnReconstructionResultV1, }; + +use crate::data_availability_checker::DataAvailabilityChecker; use crate::data_column_verification::{ GossipDataColumnError, GossipPartialDataColumnError, GossipVerifiedDataColumn, GossipVerifiedPartialDataColumnHeader, KzgVerifiedCustodyPartialDataColumn, @@ -36,7 +38,6 @@ use crate::execution_payload::{NotifyExecutionLayer, PreparePayloadHandle, get_e use crate::fetch_blobs::EngineGetBlobsOutput; use crate::fork_choice_signal::{ForkChoiceSignalRx, ForkChoiceSignalTx}; use crate::graffiti_calculator::{GraffitiCalculator, GraffitiSettings}; -use crate::kzg_utils::reconstruct_blobs; use crate::light_client_finality_update_verification::{ Error as LightClientFinalityUpdateError, VerifiedLightClientFinalityUpdate, }; @@ -65,6 +66,11 @@ use crate::payload_attestation_verification::VerifiedPayloadAttestationMessage; use crate::payload_bid_verification::payload_bid_cache::GossipVerifiedPayloadBidCache; #[cfg(not(test))] use crate::payload_envelope_streamer::{EnvelopeRequestSource, launch_payload_envelope_stream}; +use crate::pending_payload_cache::PendingPayloadCache; +use crate::pending_payload_cache::{ + Availability as PayloadAvailability, + DataColumnReconstructionResult as DataColumnReconstructionResultGloas, +}; use crate::pending_payload_envelopes::PendingPayloadEnvelopes; use crate::persisted_beacon_chain::PersistedBeaconChain; use crate::persisted_custody::persist_custody_context; @@ -498,9 +504,10 @@ pub struct BeaconChain { pub validator_monitor: RwLock>, /// The slot at which blocks are downloaded back to. pub genesis_backfill_slot: Slot, - /// Provides a KZG verification and temporary storage for blocks and blobs as - /// they are collected and combined. + /// Provides KZG verification and temporary storage for pre-Gloas blocks and blobs. pub data_availability_checker: Arc>, + /// Provides KZG verification and temporary storage for post-Gloas payload envelopes. + pub pending_payload_cache: Arc>, /// The KZG trusted setup used by this chain. pub kzg: Arc, /// RNG instance used by the chain. Currently used for shuffling column sidecars in block publishing. @@ -1180,6 +1187,7 @@ impl BeaconChain { let all_cached_columns_opt = self .data_availability_checker .get_data_columns(block_root) + .or_else(|| self.pending_payload_cache.get_data_columns(block_root)) .or_else(|| self.early_attester_cache.get_data_columns(block_root)); if let Some(mut all_cached_columns) = all_cached_columns_opt { @@ -1198,6 +1206,24 @@ impl BeaconChain { } } + pub fn cached_data_column_indexes( + &self, + block_root: &Hash256, + slot: Slot, + ) -> Option> { + if self + .spec + .fork_name_at_slot::(slot) + .gloas_enabled() + { + self.pending_payload_cache + .cached_data_column_indexes(block_root) + } else { + self.data_availability_checker + .cached_data_column_indexes(block_root) + } + } + /// Returns the block at the given root, if any. /// /// ## Errors @@ -1286,45 +1312,6 @@ impl BeaconChain { .map_err(Error::from) } - /// Returns the blobs at the given root, if any. - /// - /// Uses the `block.epoch()` to determine whether to retrieve blobs or columns from the store. - /// - /// If at least 50% of columns are retrieved, blobs will be reconstructed and returned, - /// otherwise an error `InsufficientColumnsToReconstructBlobs` is returned. - /// - /// ## Errors - /// May return a database error. - pub fn get_or_reconstruct_blobs( - &self, - block_root: &Hash256, - ) -> Result>, Error> { - let Some(block) = self.store.get_blinded_block(block_root)? else { - return Ok(None); - }; - - if self.spec.is_peer_das_enabled_for_epoch(block.epoch()) { - let fork_name = self.spec.fork_name_at_epoch(block.epoch()); - if let Some(columns) = self.store.get_data_columns(block_root, fork_name)? { - let num_required_columns = T::EthSpec::number_of_columns() / 2; - let reconstruction_possible = columns.len() >= num_required_columns; - if reconstruction_possible { - reconstruct_blobs(&self.kzg, columns, None, &block, &self.spec) - .map(Some) - .map_err(Error::FailedToReconstructBlobs) - } else { - Err(Error::InsufficientColumnsToReconstructBlobs { - columns_found: columns.len(), - }) - } - } else { - Ok(None) - } - } else { - Ok(self.get_blobs(block_root)?.blobs()) - } - } - /// Returns the data columns at the given root, if any. /// /// ## Errors @@ -3306,13 +3293,7 @@ impl BeaconChain { )); }; - // If this block has already been imported to forkchoice it must have been available, so - // we don't need to process its samples again. - if self - .canonical_head - .fork_choice_read_lock() - .contains_block(&block_root) - { + if self.is_block_data_imported(block_root, slot) { return Err(BlockError::DuplicateFullyImported(block_root)); } @@ -3357,12 +3338,7 @@ impl BeaconChain { return Ok(None); }; - // If this block has already been imported to forkchoice it must have been available - if self - .canonical_head - .fork_choice_read_lock() - .contains_block(&block_root) - { + if self.is_block_data_imported(block_root, slot) { return Err(BlockError::DuplicateFullyImported(block_root)); } @@ -3401,15 +3377,28 @@ impl BeaconChain { .map(|column| column.as_data_column()), ); - let availability = self - .data_availability_checker - .put_kzg_verified_custody_data_columns( - block_root, - merge_result.full_columns.clone(), - )?; - - self.process_availability(slot, availability, || Ok(())) - .await? + if self + .spec + .fork_name_at_slot::(slot) + .gloas_enabled() + { + let availability = self + .pending_payload_cache + .put_kzg_verified_custody_data_columns(block_root, &merge_result.full_columns) + .map_err(BlockError::from)?; + self.process_payload_envelope_availability(slot, availability, || Ok(())) + .await? + } else { + let availability = self + .data_availability_checker + .put_kzg_verified_custody_data_columns( + block_root, + merge_result.full_columns.clone(), + ) + .map_err(BlockError::from)?; + self.process_availability(slot, availability, || Ok(())) + .await? + } } else { AvailabilityProcessingStatus::MissingComponents(slot, block_root) }; @@ -3426,13 +3415,7 @@ impl BeaconChain { block_root: Hash256, blobs: FixedBlobSidecarList, ) -> Result { - // If this block has already been imported to forkchoice it must have been available, so - // we don't need to process its blobs again. - if self - .canonical_head - .fork_choice_read_lock() - .contains_block(&block_root) - { + if self.is_block_data_imported(block_root, slot) { return Err(BlockError::DuplicateFullyImported(block_root)); } @@ -3521,9 +3504,12 @@ impl BeaconChain { if let Some(event_handler) = self.event_handler.as_ref() && event_handler.has_data_column_sidecar_subscribers() { + let mut data_columns_iter = data_columns_iter.peekable(); + let Some(slot) = data_columns_iter.peek().map(|col| col.slot()) else { + return; + }; let imported_data_columns = self - .data_availability_checker - .cached_data_column_indexes(block_root) + .cached_data_column_indexes(block_root, slot) .unwrap_or_default(); let new_data_columns = data_columns_iter.filter(|b| !imported_data_columns.contains(b.index())); @@ -3554,15 +3540,7 @@ impl BeaconChain { )); }; - // If this block has already been imported to forkchoice it must have been available, so - // we don't need to process its columns again. - // TODO(gloas) the block will be available in fork choice for gloas. This does not indicate availability - // anymore. - if self - .canonical_head - .fork_choice_read_lock() - .contains_block(&block_root) - { + if self.is_block_data_imported(block_root, slot) { return Err(BlockError::DuplicateFullyImported(block_root)); } @@ -3596,6 +3574,7 @@ impl BeaconChain { pub async fn reconstruct_data_columns( self: &Arc, + slot: Slot, block_root: Hash256, ) -> Result< Option<( @@ -3604,48 +3583,84 @@ impl BeaconChain { )>, BlockError, > { - // As of now we only reconstruct data columns on supernodes, so if the block is already - // available on a supernode, there's no need to reconstruct as the node must already have - // all columns. - if self - .canonical_head - .fork_choice_read_lock() - .contains_block(&block_root) - { + // As of now we only reconstruct data columns on supernodes, so if all availability data + // for the block is already imported, there's nothing left to reconstruct. + if self.is_block_data_imported(block_root, slot) { return Ok(None); } - let data_availability_checker = self.data_availability_checker.clone(); + let is_gloas = self + .spec + .fork_name_at_slot::(slot) + .gloas_enabled(); - let result = self - .task_executor - .spawn_blocking_with_rayon_async(RayonPoolType::HighPriority, move || { - data_availability_checker.reconstruct_data_columns(&block_root) - }) - .await - .map_err(|_| BeaconChainError::RuntimeShutdown)??; + if is_gloas { + let pending_payload_cache = self.pending_payload_cache.clone(); + let result = self + .task_executor + .spawn_blocking_with_rayon_async(RayonPoolType::HighPriority, move || { + pending_payload_cache.reconstruct_data_columns(&block_root) + }) + .await + .map_err(|_| BlockError::from(BeaconChainError::RuntimeShutdown))? + .map_err(BlockError::from)?; - match result { - DataColumnReconstructionResult::Success((availability, data_columns_to_publish)) => { - let Some(slot) = data_columns_to_publish.first().map(|d| d.slot()) else { - // This should be unreachable because empty result would return `RecoveredColumnsNotImported` instead of success. - return Ok(None); - }; + match result { + DataColumnReconstructionResultGloas::Success(( + availability, + data_columns_to_publish, + )) => { + let Some(slot) = data_columns_to_publish.first().map(|d| d.slot()) else { + return Ok(None); + }; - self.process_availability(slot, availability, || Ok(())) - .await - .map(|availability_processing_status| { - Some((availability_processing_status, data_columns_to_publish)) - }) + Ok(self + .process_payload_envelope_availability(slot, availability, || Ok(())) + .await + .map(|status| Some((status, data_columns_to_publish)))?) + } + DataColumnReconstructionResultGloas::NotStarted(reason) + | DataColumnReconstructionResultGloas::RecoveredColumnsNotImported(reason) => { + metrics::inc_counter_vec( + &metrics::KZG_DATA_COLUMN_RECONSTRUCTION_INCOMPLETE_TOTAL, + &[reason], + ); + Ok(None) + } } - DataColumnReconstructionResult::NotStarted(reason) - | DataColumnReconstructionResult::RecoveredColumnsNotImported(reason) => { - // We use metric here because logging this would be *very* noisy. - metrics::inc_counter_vec( - &metrics::KZG_DATA_COLUMN_RECONSTRUCTION_INCOMPLETE_TOTAL, - &[reason], - ); - Ok(None) + } else { + let data_availability_checker = self.data_availability_checker.clone(); + let result = self + .task_executor + .spawn_blocking_with_rayon_async(RayonPoolType::HighPriority, move || { + data_availability_checker.reconstruct_data_columns(&block_root) + }) + .await + .map_err(|_| BlockError::from(BeaconChainError::RuntimeShutdown))? + .map_err(BlockError::from)?; + + match result { + DataColumnReconstructionResultV1::Success(( + availability, + data_columns_to_publish, + )) => { + let Some(slot) = data_columns_to_publish.first().map(|d| d.slot()) else { + return Ok(None); + }; + + Ok(self + .process_availability(slot, availability, || Ok(())) + .await + .map(|status| Some((status, data_columns_to_publish)))?) + } + DataColumnReconstructionResultV1::NotStarted(reason) + | DataColumnReconstructionResultV1::RecoveredColumnsNotImported(reason) => { + metrics::inc_counter_vec( + &metrics::KZG_DATA_COLUMN_RECONSTRUCTION_INCOMPLETE_TOTAL, + &[reason], + ); + Ok(None) + } } } } @@ -3659,6 +3674,32 @@ impl BeaconChain { } } + /// Returns true when no further availability data for `block_root` should be processed. + /// + /// Pre-Gloas: + /// - true once the block is fully imported into fork choice. + /// + /// Gloas: + /// - true only once the payload envelope and required data columns are fully imported. + /// The beacon block itself may already be present in fork choice before this is true. + fn is_block_data_imported(&self, block_root: Hash256, slot: Slot) -> bool { + let is_gloas = self + .spec + .fork_name_at_slot::(slot) + .gloas_enabled(); + + let fork_choice = self.canonical_head.fork_choice_read_lock(); + if !fork_choice.contains_block(&block_root) { + return false; + } + + if !is_gloas { + return true; + } + + fork_choice.is_payload_received(&block_root) + } + /// Returns `Ok(block_root)` if the given `unverified_block` was successfully verified and /// imported into the chain. /// @@ -3723,6 +3764,19 @@ impl BeaconChain { &chain, notify_execution_layer, )?; + + let block = execution_pending.block.block_cloned(); + if block.fork_name_unchecked().gloas_enabled() { + let bid = Arc::new( + block + .message() + .body() + .signed_execution_payload_bid()? + .clone(), + ); + chain.pending_payload_cache.insert_bid(block_root, bid); + } + publish_fn()?; // Record the time it took to complete consensus verification. @@ -3891,12 +3945,25 @@ impl BeaconChain { } } - let availability = self - .data_availability_checker - .put_gossip_verified_data_columns(block_root, slot, data_columns)?; - - self.process_availability(slot, availability, publish_fn) - .await + if self + .spec + .fork_name_at_slot::(slot) + .gloas_enabled() + { + let availability = self + .pending_payload_cache + .put_gossip_verified_data_columns(block_root, data_columns)?; + Ok(self + .process_payload_envelope_availability(slot, availability, publish_fn) + .await?) + } else { + let availability = self + .data_availability_checker + .put_gossip_verified_data_columns(block_root, slot, data_columns)?; + Ok(self + .process_availability(slot, availability, publish_fn) + .await?) + } } fn check_blob_header_signature_and_slashability<'a>( @@ -3943,7 +4010,8 @@ impl BeaconChain { )?; let availability = self .data_availability_checker - .put_rpc_blobs(block_root, blobs)?; + .put_rpc_blobs(block_root, blobs) + .map_err(BlockError::from)?; self.process_availability(slot, availability, || Ok(())) .await @@ -3955,14 +4023,20 @@ impl BeaconChain { block_root: Hash256, engine_get_blobs_output: EngineGetBlobsOutput, ) -> Result { - let availability = match engine_get_blobs_output { + match engine_get_blobs_output { EngineGetBlobsOutput::Blobs(blobs) => { self.check_blob_header_signature_and_slashability( block_root, blobs.iter().map(|b| b.as_blob()), )?; - self.data_availability_checker - .put_kzg_verified_blobs(block_root, blobs)? + let availability = self + .data_availability_checker + .put_kzg_verified_blobs(block_root, blobs) + .map_err(BlockError::from)?; + + Ok(self + .process_availability(slot, availability, || Ok(())) + .await?) } EngineGetBlobsOutput::CustodyColumns(data_columns) => { // TODO(gloas) verify that this check is no longer relevant for gloas @@ -3975,13 +4049,29 @@ impl BeaconChain { _ => None, }), )?; - self.data_availability_checker - .put_kzg_verified_custody_data_columns(block_root, data_columns)? + if self + .spec + .fork_name_at_slot::(slot) + .gloas_enabled() + { + let availability = self + .pending_payload_cache + .put_kzg_verified_custody_data_columns(block_root, &data_columns) + .map_err(BlockError::from)?; + Ok(self + .process_payload_envelope_availability(slot, availability, || Ok(())) + .await?) + } else { + let availability = self + .data_availability_checker + .put_kzg_verified_custody_data_columns(block_root, data_columns) + .map_err(BlockError::from)?; + Ok(self + .process_availability(slot, availability, || Ok(())) + .await?) + } } - }; - - self.process_availability(slot, availability, || Ok(())) - .await + } } /// Checks if the provided columns can make any cached blocks available, and imports immediately @@ -4001,16 +4091,27 @@ impl BeaconChain { }), )?; - // This slot value is purely informative for the consumers of - // `AvailabilityProcessingStatus::MissingComponents` to log an error with a slot. - let availability = self.data_availability_checker.put_rpc_custody_columns( - block_root, - slot, - custody_columns, - )?; - - self.process_availability(slot, availability, || Ok(())) - .await + if self + .spec + .fork_name_at_slot::(slot) + .gloas_enabled() + { + let availability = self + .pending_payload_cache + .put_rpc_custody_columns(block_root, custody_columns) + .map_err(BlockError::from)?; + Ok(self + .process_payload_envelope_availability(slot, availability, || Ok(())) + .await?) + } else { + let availability = self + .data_availability_checker + .put_rpc_custody_columns(block_root, slot, custody_columns) + .map_err(BlockError::from)?; + Ok(self + .process_availability(slot, availability, || Ok(())) + .await?) + } } fn check_data_column_sidecar_header_signature_and_slashability<'a>( @@ -4053,16 +4154,33 @@ impl BeaconChain { async fn process_availability( self: &Arc, slot: Slot, - availability: Availability, + availability: BlockAvailability, publish_fn: impl FnOnce() -> Result<(), BlockError>, ) -> Result { match availability { - Availability::Available(block) => { + BlockAvailability::Available(block) => { publish_fn()?; - // Block is fully available, import into fork choice self.import_available_block(block).await } - Availability::MissingComponents(block_root) => Ok( + BlockAvailability::MissingComponents(block_root) => Ok( + AvailabilityProcessingStatus::MissingComponents(slot, block_root), + ), + } + } + + pub(crate) async fn process_payload_envelope_availability( + self: &Arc, + slot: Slot, + availability: PayloadAvailability, + publish_fn: impl FnOnce() -> Result<(), BlockError>, + ) -> Result { + match availability { + PayloadAvailability::Available(available_envelope) => { + publish_fn()?; + self.import_available_execution_payload_envelope(available_envelope) + .await + } + PayloadAvailability::MissingComponents(block_root) => Ok( AvailabilityProcessingStatus::MissingComponents(slot, block_root), ), } @@ -7572,7 +7690,7 @@ impl BeaconChain { ) } - pub(crate) fn get_blobs_or_columns_store_op( + pub fn get_blobs_or_columns_store_op( &self, block_root: Hash256, block_slot: Slot, diff --git a/beacon_node/beacon_chain/src/block_verification.rs b/beacon_node/beacon_chain/src/block_verification.rs index 9a43147233..24f971f736 100644 --- a/beacon_node/beacon_chain/src/block_verification.rs +++ b/beacon_node/beacon_chain/src/block_verification.rs @@ -286,6 +286,10 @@ pub enum BlockError { /// TODO: We may need to penalize the peer that gave us a potentially invalid rpc blob. /// https://github.com/sigp/lighthouse/issues/4546 AvailabilityCheck(AvailabilityCheckError), + /// The payload envelope's block root is unknown. + EnvelopeBlockRootUnknown(Hash256), + /// Optimistic sync is not supported for Gloas payload envelopes. + OptimisticSyncNotSupported { block_root: Hash256 }, /// A Blob with a slot after PeerDAS is received and is not required to be imported. /// This can happen because we stay subscribed to the blob subnet after 2 epochs, as we could /// still receive valid blobs from a Deneb epoch after PeerDAS is activated. @@ -624,7 +628,8 @@ pub fn signature_verify_chain_segment( consensus_context, }); } - + // TODO(gloas) When implementing range and backfill sync for gloas + // we need a batch verify kzg function in the new da checker as well. chain .data_availability_checker .batch_verify_kzg_for_available_blocks(&available_blocks)?; diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 13dac087ef..e668bef7c0 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -12,6 +12,7 @@ use crate::kzg_utils::{build_data_column_sidecars_fulu, build_data_column_sideca use crate::light_client_server_cache::LightClientServerCache; use crate::migrate::{BackgroundMigrator, MigratorConfig}; use crate::observed_data_sidecars::ObservedDataSidecars; +use crate::pending_payload_cache::PendingPayloadCache; use crate::persisted_beacon_chain::PersistedBeaconChain; use crate::persisted_custody::load_custody_context; use crate::shuffling_cache::{BlockShufflingIds, ShufflingCache}; @@ -987,6 +988,7 @@ where ) }; debug!(?custody_context, "Loaded persisted custody context"); + let custody_context = Arc::new(custody_context); let beacon_chain = BeaconChain { spec: self.spec.clone(), @@ -1062,14 +1064,22 @@ where data_availability_checker: Arc::new( DataAvailabilityChecker::new( complete_blob_backfill, - slot_clock, + slot_clock.clone(), self.kzg.clone(), - Arc::new(custody_context), - self.spec, + custody_context.clone(), + self.spec.clone(), enable_partial_columns, ) .map_err(|e| format!("Error initializing DataAvailabilityChecker: {:?}", e))?, ), + pending_payload_cache: Arc::new( + PendingPayloadCache::new( + self.kzg.clone(), + custody_context.clone(), + self.spec.clone(), + ) + .map_err(|e| format!("Error initializing PendingPayloadCache: {:?}", e))?, + ), kzg: self.kzg.clone(), rng: Arc::new(Mutex::new(rng)), gossip_verified_payload_bid_cache: <_>::default(), diff --git a/beacon_node/beacon_chain/src/canonical_head.rs b/beacon_node/beacon_chain/src/canonical_head.rs index 0e6515ebbd..b3ab2e6975 100644 --- a/beacon_node/beacon_chain/src/canonical_head.rs +++ b/beacon_node/beacon_chain/src/canonical_head.rs @@ -988,6 +988,25 @@ impl BeaconChain { .start_slot(T::EthSpec::slots_per_epoch()), ); + // Prune the Gloas pending-payload cache. Anything older than the data-availability + // boundary cannot still be in flight; finalised entries are also safe to drop. + if self.spec.gloas_fork_epoch.is_some() { + let finalized_epoch = new_view.finalized_checkpoint.epoch; + let current_epoch = new_snapshot + .beacon_state + .slot() + .epoch(T::EthSpec::slots_per_epoch()); + if let Some(min_epochs_for_blobs) = self + .spec + .min_epoch_data_availability_boundary(current_epoch) + { + let cutoff_epoch = std::cmp::max(finalized_epoch + 1, min_epochs_for_blobs); + if let Err(e) = self.pending_payload_cache.do_maintenance(cutoff_epoch) { + error!(error = ?e, "Failed to prune pending payload cache on finalization"); + } + } + } + if let Some(event_handler) = self.event_handler.as_ref() && event_handler.has_finalized_subscribers() { diff --git a/beacon_node/beacon_chain/src/data_availability_checker.rs b/beacon_node/beacon_chain/src/data_availability_checker.rs index f0fa9c7794..cfd8ee7d34 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker.rs @@ -33,6 +33,7 @@ use crate::data_column_verification::{ GossipVerifiedDataColumn, KzgVerifiedCustodyDataColumn, KzgVerifiedDataColumn, verify_kzg_for_data_column_list, }; +use crate::kzg_utils::validate_data_columns_with_commitments; use crate::metrics::{ KZG_DATA_COLUMN_RECONSTRUCTION_ATTEMPTS, KZG_DATA_COLUMN_RECONSTRUCTION_FAILURES, }; @@ -490,8 +491,7 @@ impl DataAvailabilityChecker { AvailableBlockData::Blobs(blobs) => verify_kzg_for_blob_list(blobs.iter(), &self.kzg) .map_err(AvailabilityCheckError::InvalidBlobs), AvailableBlockData::DataColumns(columns) => { - verify_kzg_for_data_column_list(columns.iter(), &self.kzg) - .map_err(AvailabilityCheckError::InvalidColumn) + verify_columns_against_block(&self.kzg, available_block.block(), columns) } } } @@ -504,13 +504,17 @@ impl DataAvailabilityChecker { available_blocks: &[AvailableBlock], ) -> Result<(), AvailabilityCheckError> { let mut all_blobs = Vec::new(); - let mut all_data_columns = Vec::new(); for available_block in available_blocks { - match available_block.data().to_owned() { + match available_block.data() { AvailableBlockData::NoData => {} - AvailableBlockData::Blobs(blobs) => all_blobs.extend(blobs), - AvailableBlockData::DataColumns(columns) => all_data_columns.extend(columns), + AvailableBlockData::Blobs(blobs) => all_blobs.extend(blobs.iter().cloned()), + AvailableBlockData::DataColumns(columns) => { + // Each block has its own commitments. For Gloas they live in the bid; for + // Fulu they live inline on the column. Verify per block and let the helper + // pick the right path. + verify_columns_against_block(&self.kzg, available_block.block(), columns)?; + } } } @@ -519,11 +523,6 @@ impl DataAvailabilityChecker { .map_err(AvailabilityCheckError::InvalidBlobs)?; } - if !all_data_columns.is_empty() { - verify_kzg_for_data_column_list(all_data_columns.iter(), &self.kzg) - .map_err(AvailabilityCheckError::InvalidColumn)?; - } - Ok(()) } @@ -605,9 +604,21 @@ impl DataAvailabilityChecker { metrics::inc_counter(&KZG_DATA_COLUMN_RECONSTRUCTION_ATTEMPTS); let timer = metrics::start_timer(&metrics::DATA_AVAILABILITY_RECONSTRUCTION_TIME); + let columns: Vec<_> = verified_data_columns + .into_iter() + .map(|c| c.into_inner()) + .collect(); + // Fulu columns carry their commitments; reconstruction needs the count to drive the + // per-blob recovery loop. + let kzg_commitments = columns + .first() + .and_then(|c| c.kzg_commitments().ok().cloned()) + .ok_or(AvailabilityCheckError::InvalidVariant)?; + let all_data_columns = KzgVerifiedCustodyDataColumn::reconstruct_columns( &self.kzg, - &verified_data_columns, + columns, + &kzg_commitments, &self.spec, ) .map_err(|e| { @@ -676,6 +687,35 @@ impl DataAvailabilityChecker { } } +/// Verify a batch of data columns belonging to a single block, picking the right commitment +/// source for the block's fork (Fulu: inline on column; Gloas: from the embedded payload bid). +fn verify_columns_against_block( + kzg: &Kzg, + block: &SignedBeaconBlock, + columns: &[Arc>], +) -> Result<(), AvailabilityCheckError> { + if columns.is_empty() { + return Ok(()); + } + if block.fork_name_unchecked().gloas_enabled() { + let commitments = block + .message() + .body() + .signed_execution_payload_bid() + .map(|bid| bid.message.blob_kzg_commitments.clone()) + .map_err(|_| { + AvailabilityCheckError::Unexpected( + "Gloas block missing signed_execution_payload_bid".to_string(), + ) + })?; + validate_data_columns_with_commitments(kzg, columns.iter(), commitments.as_ref()) + .map_err(AvailabilityCheckError::InvalidColumn) + } else { + verify_kzg_for_data_column_list(columns.iter(), kzg) + .map_err(AvailabilityCheckError::InvalidColumn) + } +} + /// Helper struct to group data availability checker metrics. pub struct DataAvailabilityCheckerMetrics { pub block_cache_size: usize, @@ -874,10 +914,13 @@ impl AvailableBlock { match &block_data { AvailableBlockData::NoData => { - if columns_required { - return Err(AvailabilityCheckError::MissingCustodyColumns); - } else if blobs_required { - return Err(AvailabilityCheckError::MissingBlobs); + // For Gloas, DA is checked for the PayloadEnvelope, not for the block. + if !block.fork_name_unchecked().gloas_enabled() { + if columns_required { + return Err(AvailabilityCheckError::MissingCustodyColumns); + } else if blobs_required { + return Err(AvailabilityCheckError::MissingBlobs); + } } } AvailableBlockData::Blobs(blobs) => { diff --git a/beacon_node/beacon_chain/src/data_availability_checker/error.rs b/beacon_node/beacon_chain/src/data_availability_checker/error.rs index af3cb72c03..ab69a62985 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/error.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/error.rs @@ -4,6 +4,7 @@ use types::{BeaconStateError, ColumnIndex, Hash256}; #[derive(Debug)] pub enum Error { InvalidBlobs(KzgError), + MissingBid(Hash256), InvalidColumn((Option, KzgError)), ReconstructColumnsError(KzgError), KzgCommitmentMismatch { @@ -23,6 +24,7 @@ pub enum Error { RebuildingStateCaches(BeaconStateError), SlotClockError, InvalidAvailableBlockData, + InvalidVariant, } #[derive(PartialEq, Eq)] @@ -38,6 +40,7 @@ impl Error { match self { Error::SszTypes(_) | Error::MissingBlobs + | Error::MissingBid(_) | Error::MissingCustodyColumns | Error::StoreError(_) | Error::DecodeError(_) @@ -46,7 +49,8 @@ impl Error { | Error::BlockReplayError(_) | Error::RebuildingStateCaches(_) | Error::SlotClockError - | Error::InvalidAvailableBlockData => ErrorCategory::Internal, + | Error::InvalidAvailableBlockData + | Error::InvalidVariant => ErrorCategory::Internal, Error::InvalidBlobs { .. } | Error::InvalidColumn { .. } | Error::ReconstructColumnsError { .. } diff --git a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs index 7d1bba2de9..3034e196b9 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs @@ -109,7 +109,7 @@ impl PendingComponents { .unwrap_or(false) } - /// Returns the indices of cached custody columns + /// Returns the indices of cached sampling columns pub fn get_cached_data_columns_indices(&self) -> Vec { self.verified_data_columns .iter() diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index 8ea3c792f4..71562b376b 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -3,7 +3,8 @@ use crate::block_verification::{ }; use crate::data_availability_checker::MissingCellsError; use crate::kzg_utils::{ - reconstruct_data_columns, validate_full_data_columns, validate_partial_data_columns, + reconstruct_data_columns, validate_data_columns_with_commitments, validate_full_data_columns, + validate_partial_data_columns, }; use crate::observed_data_sidecars::{ Error as ObservedDataSidecarsError, ObservationKey, ObservationStrategy, Observe, @@ -20,6 +21,7 @@ use std::iter; use std::marker::PhantomData; use std::sync::Arc; use std::time::Duration; +use store::DatabaseBlock; use tracing::{debug, instrument}; use tree_hash::TreeHash; use types::data::{ @@ -27,13 +29,16 @@ use types::data::{ PartialDataColumnSidecarError, }; use types::{ - BeaconStateError, ChainSpec, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSubnetId, - EthSpec, Hash256, PartialDataColumnSidecarRef, SignedBeaconBlockHeader, Slot, + BeaconStateError, ChainSpec, DataColumnSidecar, DataColumnSubnetId, EthSpec, Hash256, + KzgCommitment, PartialDataColumnSidecarRef, SignedBeaconBlockHeader, SignedExecutionPayloadBid, + Slot, }; /// An error occurred while validating a gossip data column. #[derive(Debug)] pub enum GossipDataColumnError { + /// Internal logic error: the column sidecar variant does not match the expected fork. + /// This is not a peer fault and should not be used to penalize peers. InvalidVariant, /// There was an error whilst processing the data column. It is not known if it is /// valid or invalid. @@ -82,10 +87,7 @@ pub enum GossipDataColumnError { /// ## Peer scoring /// /// The column is invalid or the peer is faulty. - InvalidSubnetId { - received: u64, - expected: u64, - }, + InvalidSubnetId { received: u64, expected: u64 }, /// The column sidecar is from a slot that is later than the current slot (with respect to the /// gossip clock disparity). /// @@ -118,18 +120,27 @@ pub enum GossipDataColumnError { /// ## Peer scoring /// /// The column is invalid and the peer is faulty. - ProposerIndexMismatch { - sidecar: usize, - local: usize, - }, + ProposerIndexMismatch { sidecar: usize, local: usize }, /// The provided columns's parent block is unknown. /// /// ## Peer scoring /// /// We cannot process the columns without validating its parent, the peer isn't necessarily faulty. - ParentUnknown { - parent_root: Hash256, - slot: Slot, + ParentUnknown { parent_root: Hash256, slot: Slot }, + /// The block referenced by the data column is unknown. + /// + /// ## Peer scoring + /// + /// We cannot process the column without the referenced block, the peer isn't necessarily faulty. + BlockRootUnknown { block_root: Hash256, slot: Slot }, + /// The data column slot does not match its referenced block slot. + /// + /// ## Peer scoring + /// + /// The column sidecar is invalid and the peer is faulty. + BlockSlotMismatch { + block_slot: Slot, + data_column_slot: Slot, }, /// The column conflicts with finalization, no need to propagate. /// @@ -137,9 +148,7 @@ pub enum GossipDataColumnError { /// /// It's unclear if this column is valid, but it conflicts with finality and shouldn't be /// imported. - NotFinalizedDescendant { - block_parent_root: Hash256, - }, + NotFinalizedDescendant { block_parent_root: Hash256 }, /// Invalid kzg commitment inclusion proof /// /// ## Peer scoring @@ -187,10 +196,7 @@ pub enum GossipDataColumnError { /// ## Peer scoring /// /// The column sidecar is invalid and the peer is faulty - InconsistentProofsLength { - cells_len: usize, - proofs_len: usize, - }, + InconsistentProofsLength { cells_len: usize, proofs_len: usize }, /// The number of KZG commitments exceeds the maximum number of blobs allowed for the fork. The /// sidecar is invalid. /// @@ -200,6 +206,12 @@ pub enum GossipDataColumnError { max_blobs_per_block: usize, commitments_len: usize, }, + + /// An internal error occurred. + /// + /// ## Peer scoring + /// This is an internal issue, the peer isn't at fault. + InternalError(String), } impl From for GossipDataColumnError { @@ -302,26 +314,35 @@ impl GossipVerifiedDataColumn subnet_id: DataColumnSubnetId, chain: &BeaconChain, ) -> Result { - match column_sidecar.as_ref() { - DataColumnSidecar::Fulu(c) => { - let header = c.signed_block_header.clone(); + let data_column = match column_sidecar.as_ref() { + DataColumnSidecar::Fulu(column_sidecar_fulu) => { + let header = &column_sidecar_fulu.signed_block_header; // We only process slashing info if the gossip verification failed // since we do not process the data column any further in that case. validate_data_column_sidecar_for_gossip_fulu::( - column_sidecar, + column_sidecar.clone(), subnet_id, chain, ) .map_err(|e| { process_block_slash_info::<_, GossipDataColumnError>( chain, - BlockSlashInfo::from_early_error_data_column(header, e), + BlockSlashInfo::from_early_error_data_column(header.clone(), e), ) - }) + })? } - // TODO(gloas) support gloas data column variant - DataColumnSidecar::Gloas(_) => Err(GossipDataColumnError::InvalidVariant), - } + DataColumnSidecar::Gloas(_) => validate_data_column_sidecar_for_gossip_gloas::( + column_sidecar.clone(), + subnet_id, + chain, + )?, + }; + + Ok(GossipVerifiedDataColumn { + block_root: column_sidecar.block_root(), + data_column, + _phantom: PhantomData, + }) } /// Create a `GossipVerifiedDataColumn` from `DataColumnSidecar` for block production ONLY. @@ -331,7 +352,28 @@ impl GossipVerifiedDataColumn column_sidecar: Arc>, chain: &BeaconChain, ) -> Result { - verify_data_column_sidecar(&column_sidecar, &chain.spec)?; + match column_sidecar.as_ref() { + DataColumnSidecar::Fulu(data_column_fulu) => { + verify_data_column_sidecar_with_commitments_len( + &column_sidecar, + data_column_fulu.kzg_commitments.len(), + &chain.spec, + )?; + } + DataColumnSidecar::Gloas(_) => { + let bid = load_gloas_payload_bid(column_sidecar.block_root(), chain)?.ok_or( + GossipDataColumnError::BlockRootUnknown { + block_root: column_sidecar.block_root(), + slot: column_sidecar.slot(), + }, + )?; + verify_data_column_sidecar_with_commitments_len( + &column_sidecar, + bid.message.blob_kzg_commitments.len(), + &chain.spec, + )?; + } + } // Check if the data column is already in the DA checker cache. This happens when data columns // are made available through the `engine_getBlobs` method. If it exists in the cache, we know @@ -340,28 +382,20 @@ impl GossipVerifiedDataColumn // In this case, we should accept it for gossip propagation. verify_is_unknown_sidecar(chain, &column_sidecar)?; - match chain - .data_availability_checker - .missing_cells_for_column_sidecar(&column_sidecar) - { - Ok(Some(_)) => Ok(Self { + // Check if this column contains any cells not already in the cache. If all cells are + // already cached, reject as `PriorKnownUnpublished` to avoid redundant processing. + match missing_cells_for_column_sidecar(chain, &column_sidecar)? { + Some(_) => Ok(Self { block_root: column_sidecar.block_root(), data_column: KzgVerifiedDataColumn::from_execution_verified(column_sidecar), _phantom: Default::default(), }), - Ok(None) => { - // Observe this data column so we don't process it again. + None => { if O::observe() { observe_gossip_data_column(&column_sidecar, chain)?; } Err(GossipDataColumnError::PriorKnownUnpublished) } - Err(MissingCellsError::MismatchesCachedColumn) => { - Err(GossipDataColumnError::MismatchesCachedColumn) - } - Err(MissingCellsError::UnexpectedError(_)) => { - todo!("handle unexpected error") - } } } @@ -430,12 +464,30 @@ impl KzgVerifiedDataColumn { data_columns: Vec>>, kzg: &Kzg, ) -> Result, (Option, KzgError)> { + let seen_timestamp = timestamp_now(); verify_kzg_for_data_column_list(data_columns.iter(), kzg)?; Ok(data_columns .into_iter() .map(|column| Self { data: column, - seen_timestamp: timestamp_now(), + seen_timestamp, + }) + .collect()) + } + + pub fn from_batch_with_scoring_and_commitments( + data_columns: Vec>>, + kzg_commitments: &[KzgCommitment], + kzg: &Kzg, + ) -> Result, (Option, KzgError)> { + let _timer = metrics::start_timer(&metrics::KZG_VERIFICATION_DATA_COLUMN_BATCH_TIMES); + let seen_timestamp = timestamp_now(); + validate_data_columns_with_commitments(kzg, data_columns.iter(), kzg_commitments)?; + Ok(data_columns + .into_iter() + .map(|column| Self { + data: column, + seen_timestamp, }) .collect()) } @@ -635,17 +687,12 @@ impl KzgVerifiedCustodyDataColumn { pub fn reconstruct_columns( kzg: &Kzg, - partial_set_of_columns: &[Self], + partial_set_of_columns: Vec>>, + kzg_commitments: &[KzgCommitment], spec: &ChainSpec, ) -> Result>, KzgError> { - let all_data_columns = reconstruct_data_columns( - kzg, - partial_set_of_columns - .iter() - .map(|d| d.clone_arc()) - .collect::>(), - spec, - )?; + let all_data_columns = + reconstruct_data_columns(kzg, partial_set_of_columns, kzg_commitments, spec)?; let seen_timestamp = timestamp_now(); @@ -860,6 +907,26 @@ pub fn verify_kzg_for_data_column( }) } +#[instrument(skip_all, level = "debug")] +pub fn verify_kzg_for_data_column_with_commitments( + data_column: Arc>, + cells_to_verify: PartialDataColumnSidecarRef, + kzg_commitments: &[KzgCommitment], + kzg: &Kzg, + seen_timestamp: Duration, +) -> Result, (Option, KzgError)> { + let _timer = metrics::start_timer(&metrics::KZG_VERIFICATION_DATA_COLUMN_SINGLE_TIMES); + validate_partial_data_columns( + kzg, + iter::once((*data_column.index(), cells_to_verify)), + kzg_commitments, + )?; + Ok(KzgVerifiedDataColumn { + data: data_column, + seen_timestamp, + }) +} + /// Complete kzg verification for a `VerifiablePartialDataColumn`. /// /// Returns an error if the kzg verification check fails. @@ -910,13 +977,18 @@ pub fn validate_data_column_sidecar_for_gossip_fulu>, subnet: DataColumnSubnetId, chain: &BeaconChain, -) -> Result, GossipDataColumnError> { +) -> Result, GossipDataColumnError> { let DataColumnSidecar::Fulu(data_column_fulu) = data_column.as_ref() else { return Err(GossipDataColumnError::InvalidVariant); }; let column_slot = data_column.slot(); - verify_data_column_sidecar(&data_column, &chain.spec)?; + + verify_data_column_sidecar_with_commitments_len( + &data_column, + data_column_fulu.kzg_commitments.len(), + &chain.spec, + )?; verify_index_matches_subnet(&data_column, subnet, &chain.spec)?; verify_sidecar_not_from_future_slot(chain, column_slot)?; verify_slot_greater_than_latest_finalized_slot(chain, column_slot)?; @@ -935,7 +1007,10 @@ pub fn validate_data_column_sidecar_for_gossip_fulu { GossipDataColumnError::MismatchesCachedColumn } - MissingCellsError::UnexpectedError(_) => todo!("handle unexpected error"), + MissingCellsError::UnexpectedError(e) => GossipDataColumnError::InternalError(format!( + "An unexpected error occurred while validating fulu data columns. {:?}", + e + )), })? else { // Observe this data column so we don't process it again. @@ -945,7 +1020,7 @@ pub fn validate_data_column_sidecar_for_gossip_fulu( + data_column: Arc>, + subnet: DataColumnSubnetId, + chain: &BeaconChain, +) -> Result, GossipDataColumnError> { + let DataColumnSidecar::Gloas(_) = data_column.as_ref() else { + return Err(GossipDataColumnError::InvalidVariant); + }; + + let column_slot = data_column.slot(); + + if *data_column.index() >= T::EthSpec::number_of_columns() as u64 { + return Err(GossipDataColumnError::InvalidColumnIndex( + *data_column.index(), + )); + } + verify_index_matches_subnet(&data_column, subnet, &chain.spec)?; + verify_sidecar_not_from_future_slot(chain, column_slot)?; + verify_slot_greater_than_latest_finalized_slot(chain, column_slot)?; + verify_is_unknown_sidecar(chain, &data_column)?; + + let bid = load_gloas_payload_bid(data_column.block_root(), chain)?.ok_or( + GossipDataColumnError::BlockRootUnknown { + block_root: data_column.block_root(), + slot: column_slot, + }, + )?; + if bid.message.slot != column_slot { + return Err(GossipDataColumnError::BlockSlotMismatch { + block_slot: bid.message.slot, + data_column_slot: column_slot, + }); + } + let kzg_commitments = &bid.message.blob_kzg_commitments; + verify_data_column_sidecar_with_commitments_len( + &data_column, + kzg_commitments.len(), + &chain.spec, + )?; + + let Some(cells_to_kzg_verify) = missing_cells_for_column_sidecar(chain, &data_column)? else { + // Observe this data column so we don't process it again. + if O::observe() { + observe_gossip_data_column(&data_column, chain)?; + } + return Err(GossipDataColumnError::PriorKnownUnpublished); + }; + + let kzg = &chain.kzg; + let seen_timestamp = chain.slot_clock.now_duration().unwrap_or_default(); + let kzg_verified = verify_kzg_for_data_column_with_commitments( + data_column.clone(), + cells_to_kzg_verify, + kzg_commitments.as_ref(), + kzg, + seen_timestamp, + ) + .map_err(|(_, e)| GossipDataColumnError::InvalidKzgProof(e))?; + + if O::observe() { + observe_gossip_data_column(&data_column, chain)?; + } + + Ok(kzg_verified) } #[instrument(skip_all, level = "debug")] @@ -1115,9 +1259,9 @@ pub enum PartialColumnVerificationResult { Err(GossipPartialDataColumnError), } -/// Verify if the data column sidecar is valid. -fn verify_data_column_sidecar( +fn verify_data_column_sidecar_with_commitments_len( data_column: &DataColumnSidecar, + commitments_len: usize, spec: &ChainSpec, ) -> Result<(), GossipDataColumnError> { if *data_column.index() >= E::number_of_columns() as u64 { @@ -1126,12 +1270,6 @@ fn verify_data_column_sidecar( )); } - // TODO(gloas): implement Gloas verification that takes kzg_commitments from block as parameter - let commitments_len = match data_column { - DataColumnSidecar::Fulu(dc) => dc.kzg_commitments.len(), - DataColumnSidecar::Gloas(_) => return Err(GossipDataColumnError::InvalidVariant), - }; - if commitments_len == 0 { return Err(GossipDataColumnError::UnexpectedDataColumn); } @@ -1164,6 +1302,93 @@ fn verify_data_column_sidecar( Ok(()) } +/// Loads the Gloas payload bid for `block_root` from the `pending_payload_cache`, the +/// `early_attester_cache`, or the on-disk store (in that order). +/// +/// TODO(gloas): the store fallback is a synchronous disk read and several callers run inside +/// `async` gossip / RPC validation paths. Move the disk path off the async runtime (e.g. behind +/// `spawn_blocking`) — or restructure callers to fetch the bid before entering async — once the +/// gossip pipeline is reworked for Gloas. The cache and early-attester paths are short +/// in-memory locks and acceptable as-is. +pub(crate) fn load_gloas_payload_bid( + block_root: Hash256, + chain: &BeaconChain, +) -> Result>>, BeaconChainError> { + if let Some(bid) = chain.pending_payload_cache.get_bid(&block_root) { + return Ok(Some(bid)); + } + + let bid = if let Some(block) = chain.early_attester_cache.get_block(block_root) { + Arc::new( + block + .message() + .body() + .signed_execution_payload_bid() + .map_err(BeaconChainError::BeaconStateError)? + .clone(), + ) + } else { + match chain + .store + .try_get_full_block(&block_root) + .map_err(BeaconChainError::DBError)? + { + Some(DatabaseBlock::Full(block)) => Arc::new( + block + .message() + .body() + .signed_execution_payload_bid() + .map_err(BeaconChainError::BeaconStateError)? + .clone(), + ), + Some(DatabaseBlock::Blinded(block)) => Arc::new( + block + .message() + .body() + .signed_execution_payload_bid() + .map_err(BeaconChainError::BeaconStateError)? + .clone(), + ), + None => { + return Ok(None); + } + } + }; + + chain + .pending_payload_cache + .insert_bid(block_root, bid.clone()); + + Ok(Some(bid)) +} + +fn missing_cells_for_column_sidecar<'a, T: BeaconChainTypes>( + chain: &'_ BeaconChain, + data_column: &'a DataColumnSidecar, +) -> Result>, GossipDataColumnError> { + let result = if chain + .spec + .fork_name_at_slot::(data_column.slot()) + .gloas_enabled() + { + chain + .pending_payload_cache + .missing_cells_for_column_sidecar(data_column) + } else { + chain + .data_availability_checker + .missing_cells_for_column_sidecar(data_column) + }; + + result.map_err(|err| match err { + MissingCellsError::MismatchesCachedColumn => GossipDataColumnError::MismatchesCachedColumn, + MissingCellsError::UnexpectedError(e) => GossipDataColumnError::InternalError(format!( + "An unexpected error occurred while calculating missing partial cells {:?}", + e + )), + }) +} + /// Verify that `column_sidecar` is not yet known, i.e. this is the first time `column_sidecar` has been received for the tuple: /// `(block_header.slot, block_header.proposer_index, column_sidecar.index)` fn verify_is_unknown_sidecar( @@ -1187,10 +1412,15 @@ fn verify_is_unknown_sidecar( } fn verify_column_inclusion_proof( - data_column: &DataColumnSidecarFulu, + data_column: &DataColumnSidecar, ) -> Result<(), GossipDataColumnError> { let _timer = metrics::start_timer(&metrics::DATA_COLUMN_SIDECAR_INCLUSION_PROOF_VERIFICATION); - if !data_column.verify_inclusion_proof() { + + let DataColumnSidecar::Fulu(data_column_fulu) = data_column else { + return Err(GossipDataColumnError::InvalidVariant); + }; + + if !data_column_fulu.verify_inclusion_proof() { return Err(GossipDataColumnError::InvalidInclusionProof); } @@ -1447,7 +1677,7 @@ mod test { let verify_fn = |column_sidecar: DataColumnSidecar| { let col_index = *column_sidecar.index(); validate_data_column_sidecar_for_gossip_fulu::<_, Observe>( - column_sidecar.into(), + Arc::new(column_sidecar), DataColumnSubnetId::from_column_index(col_index, &harness.spec), &harness.chain, ) diff --git a/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs b/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs index c94fb036f8..f5ba647fce 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/fetch_blobs_beacon_adapter.rs @@ -119,10 +119,12 @@ impl FetchBlobsBeaconAdapter { .cached_blob_indexes(block_root) } - pub(crate) fn cached_data_column_indexes(&self, block_root: &Hash256) -> Option> { - self.chain - .data_availability_checker - .cached_data_column_indexes(block_root) + pub(crate) fn cached_data_column_indexes( + &self, + block_root: &Hash256, + slot: Slot, + ) -> Option> { + self.chain.cached_data_column_indexes(block_root, slot) } pub(crate) async fn process_engine_blobs( diff --git a/beacon_node/beacon_chain/src/fetch_blobs/mod.rs b/beacon_node/beacon_chain/src/fetch_blobs/mod.rs index f7b4b8a29e..351e35666a 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/mod.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/mod.rs @@ -445,7 +445,7 @@ async fn compute_custody_columns_to_import( // Only consider columns that are not already known to data availability. if let Some(known_columns) = - chain_adapter_cloned.cached_data_column_indexes(&block_root) + chain_adapter_cloned.cached_data_column_indexes(&block_root, header.slot()) { custody_columns.retain(|col| !known_columns.contains(&col.index())); if custody_columns.is_empty() { diff --git a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs index ef282a3eaa..37d40f3a27 100644 --- a/beacon_node/beacon_chain/src/fetch_blobs/tests.rs +++ b/beacon_node/beacon_chain/src/fetch_blobs/tests.rs @@ -199,7 +199,7 @@ mod get_blobs_v2 { .returning(|_| None); mock_adapter .expect_cached_data_column_indexes() - .returning(|_| None); + .returning(|_, _| None); mock_process_engine_blobs_result( &mut mock_adapter, Ok(AvailabilityProcessingStatus::Imported(block_root)), diff --git a/beacon_node/beacon_chain/src/kzg_utils.rs b/beacon_node/beacon_chain/src/kzg_utils.rs index b05a896777..bc803efe93 100644 --- a/beacon_node/beacon_chain/src/kzg_utils.rs +++ b/beacon_node/beacon_chain/src/kzg_utils.rs @@ -111,6 +111,57 @@ pub fn validate_full_data_columns<'a, E: EthSpec>( kzg.verify_cell_proof_batch(&cells, &proofs, column_indices, &commitments) } +/// Validate a batch of full `DataColumnSidecar`s against commitments supplied out-of-band. +/// +/// Gloas sidecars do not carry commitments. Their commitments come from the block's +/// `ExecutionPayloadBid`. +pub fn validate_data_columns_with_commitments<'a, E: EthSpec>( + kzg: &Kzg, + data_column_iter: impl Iterator>>, + kzg_commitments: &[KzgCommitment], +) -> Result<(), (Option, KzgError)> { + let mut cells = Vec::new(); + let mut proofs = Vec::new(); + let mut column_indices = Vec::new(); + let mut commitments = Vec::new(); + + for data_column in data_column_iter { + let col_index = *data_column.index(); + + if data_column.column().is_empty() { + return Err((Some(col_index), KzgError::KzgVerificationFailed)); + } + + for cell in data_column.column() { + cells.push(ssz_cell_to_crypto_cell::(cell).map_err(|e| (Some(col_index), e))?); + column_indices.push(col_index); + } + + for &proof in data_column.kzg_proofs() { + proofs.push(proof.0); + } + + for &commitment in kzg_commitments { + commitments.push(commitment.0); + } + + let expected_len = column_indices.len(); + + // We make this check at each iteration so that the error is attributable to a specific column. + if cells.len() != expected_len + || proofs.len() != expected_len + || commitments.len() != expected_len + { + return Err(( + Some(col_index), + KzgError::InconsistentArrayLength("Invalid data column".to_string()), + )); + } + } + + kzg.verify_cell_proof_batch(&cells, &proofs, column_indices, &commitments) +} + /// Validate a batch of partial `VerifiablePartialDataColumn`s. /// /// Partial columns may have missing cells, indicated by a bitmap. We only verify present cells. @@ -618,19 +669,17 @@ pub fn reconstruct_blobs( // Sort data columns by index to ensure ascending order for KZG operations data_columns.sort_unstable_by_key(|dc| *dc.index()); - let first_data_column = data_columns - .first() - .ok_or("data_columns should have at least one element".to_string())?; + if data_columns.is_empty() { + return Err("data_columns should have at least one element".to_string()); + } let blob_indices: Vec = match blob_indices_opt { Some(indices) => indices.into_iter().map(|i| i as usize).collect(), None => { - // TODO(gloas): support blob reconstruction for Gloas - // https://github.com/sigp/lighthouse/issues/7413 - let num_of_blobs = first_data_column - .kzg_commitments() - .map_err(|_| "Gloas blob reconstruction not yet supported".to_string())? - .len(); + let num_of_blobs = signed_block + .message() + .blob_kzg_commitments_len() + .ok_or_else(|| "Block does not have blob KZG commitments".to_string())?; (0..num_of_blobs).collect() } }; @@ -689,9 +738,14 @@ pub fn reconstruct_blobs( } /// Reconstruct all data columns from a subset of data column sidecars (requires at least 50%). +/// +/// `kzg_commitments` are the commitments for the underlying blobs. For Fulu they live in the +/// column itself; for Gloas they live in the bid. We take them as a parameter so this function +/// works for both forks (mirroring `validate_data_columns_with_commitments`). pub fn reconstruct_data_columns( kzg: &Kzg, mut data_columns: Vec>>, + kzg_commitments: &[KzgCommitment], spec: &ChainSpec, ) -> Result, KzgError> { // Sort data columns by index to ensure ascending order for KZG operations @@ -703,16 +757,7 @@ pub fn reconstruct_data_columns( "data_columns should have at least one element".to_string(), ))?; - // TODO(gloas): support data column reconstruction for Gloas - // https://github.com/sigp/lighthouse/issues/7413 - let num_of_blobs = first_data_column - .kzg_commitments() - .map_err(|_| { - KzgError::InconsistentArrayLength( - "Gloas data column reconstruction not yet supported".to_string(), - ) - })? - .len(); + let num_of_blobs = kzg_commitments.len(); let blob_cells_and_proofs_vec = (0..num_of_blobs) .into_par_iter() @@ -757,8 +802,9 @@ pub fn reconstruct_data_columns( #[cfg(test)] mod test { use crate::kzg_utils::{ - blobs_to_data_column_sidecars, blobs_to_data_column_sidecars_gloas, reconstruct_blobs, - reconstruct_data_columns, validate_full_data_columns, + blob_to_kzg_commitment, blobs_to_data_column_sidecars, blobs_to_data_column_sidecars_gloas, + reconstruct_blobs, reconstruct_data_columns, validate_data_columns_with_commitments, + validate_full_data_columns, }; use bls::Signature; use eth2::types::BlobsBundle; @@ -787,9 +833,13 @@ mod test { test_reconstruct_blobs_from_data_columns_unordered(&kzg, &fulu_spec); test_validate_data_columns(&kzg, &fulu_spec); + test_validate_data_columns_with_commitments(&kzg, &fulu_spec); + let gloas_spec = ForkName::Gloas.make_genesis_spec(E::default_spec()); test_build_data_columns_gloas(&kzg, &gloas_spec); test_build_data_columns_gloas_empty(&kzg, &gloas_spec); + test_reconstruct_data_columns_gloas(&kzg, &gloas_spec); + test_validate_data_columns_with_commitments_gloas(&kzg, &gloas_spec); } #[track_caller] @@ -806,6 +856,63 @@ mod test { assert!(result.is_ok()); } + #[track_caller] + fn test_validate_data_columns_with_commitments(kzg: &Kzg, spec: &ChainSpec) { + let num_of_blobs = 2; + let (signed_block, blobs, proofs) = + create_test_fulu_block_and_blobs::(num_of_blobs, spec); + let blob_refs = blobs.iter().collect::>(); + let column_sidecars = + blobs_to_data_column_sidecars(&blob_refs, proofs.to_vec(), &signed_block, kzg, spec) + .unwrap(); + + let commitments = signed_block + .message() + .body() + .blob_kzg_commitments() + .unwrap(); + + let result = + validate_data_columns_with_commitments(kzg, column_sidecars.iter(), commitments); + assert!(result.is_ok()); + + // Verify that wrong commitments cause a failure + let bad_commitments = vec![KzgCommitment::empty_for_testing(); num_of_blobs]; + let result = + validate_data_columns_with_commitments(kzg, column_sidecars.iter(), &bad_commitments); + assert!(result.is_err()); + } + + #[track_caller] + fn test_validate_data_columns_with_commitments_gloas(kzg: &Kzg, spec: &ChainSpec) { + let num_of_blobs = 2; + let (blobs, _proofs) = create_test_gloas_blobs::(num_of_blobs); + let blob_refs: Vec<_> = blobs.iter().collect(); + let column_sidecars = blobs_to_data_column_sidecars_gloas::( + &blob_refs, + Hash256::random(), + Slot::new(0), + kzg, + spec, + ) + .unwrap(); + + let commitments: Vec = blobs + .iter() + .map(|blob| blob_to_kzg_commitment::(kzg, blob).unwrap()) + .collect(); + + let result = + validate_data_columns_with_commitments(kzg, column_sidecars.iter(), &commitments); + assert!(result.is_ok()); + + // Verify that wrong commitments cause a failure + let bad_commitments = vec![KzgCommitment::empty_for_testing(); num_of_blobs]; + let result = + validate_data_columns_with_commitments(kzg, column_sidecars.iter(), &bad_commitments); + assert!(result.is_err()); + } + #[track_caller] fn test_build_data_columns_empty(kzg: &Kzg, spec: &ChainSpec) { let num_of_blobs = 0; @@ -918,11 +1025,18 @@ mod test { let column_sidecars = blobs_to_data_column_sidecars(&blob_refs, proofs.to_vec(), &signed_block, kzg, spec) .unwrap(); + let commitments = signed_block + .message() + .body() + .blob_kzg_commitments() + .unwrap() + .clone(); // Now reconstruct let reconstructed_columns = reconstruct_data_columns( kzg, column_sidecars.iter().as_slice()[0..column_sidecars.len() / 2].to_vec(), + &commitments, spec, ) .unwrap(); @@ -942,12 +1056,49 @@ mod test { let column_sidecars = blobs_to_data_column_sidecars(&blob_refs, proofs.to_vec(), &signed_block, kzg, spec) .unwrap(); + let commitments = signed_block + .message() + .body() + .blob_kzg_commitments() + .unwrap() + .clone(); // Test reconstruction with columns in reverse order (non-ascending) let mut subset_columns: Vec<_> = column_sidecars.iter().as_slice()[0..column_sidecars.len() / 2].to_vec(); subset_columns.reverse(); // This would fail without proper sorting in reconstruct_data_columns - let reconstructed_columns = reconstruct_data_columns(kzg, subset_columns, spec).unwrap(); + let reconstructed_columns = + reconstruct_data_columns(kzg, subset_columns, &commitments, spec).unwrap(); + + for i in 0..E::number_of_columns() { + assert_eq!(reconstructed_columns.get(i), column_sidecars.get(i), "{i}"); + } + } + + /// Reconstruct a full Gloas column set from a 50% subset and assert the recovered sidecars + /// match the originals. Commitments come from the bid (here mocked via the same + /// `KzgCommitments` used to build the columns) since Gloas columns don't carry them. + #[track_caller] + fn test_reconstruct_data_columns_gloas(kzg: &Kzg, spec: &ChainSpec) { + let num_of_blobs = 2; + let (blobs, _proofs) = create_test_gloas_blobs::(num_of_blobs); + let blob_refs: Vec<_> = blobs.iter().collect(); + let column_sidecars = blobs_to_data_column_sidecars_gloas::( + &blob_refs, + Hash256::random(), + Slot::new(0), + kzg, + spec, + ) + .unwrap(); + + let commitments = + KzgCommitments::::new(vec![KzgCommitment::empty_for_testing(); num_of_blobs]) + .unwrap(); + + let subset = column_sidecars[..column_sidecars.len() / 2].to_vec(); + let reconstructed_columns = + reconstruct_data_columns(kzg, subset, &commitments, spec).unwrap(); for i in 0..E::number_of_columns() { assert_eq!(reconstructed_columns.get(i), column_sidecars.get(i), "{i}"); diff --git a/beacon_node/beacon_chain/src/lib.rs b/beacon_node/beacon_chain/src/lib.rs index d70fc1b3ec..804268a613 100644 --- a/beacon_node/beacon_chain/src/lib.rs +++ b/beacon_node/beacon_chain/src/lib.rs @@ -48,6 +48,7 @@ pub mod payload_attestation_verification; pub mod payload_bid_verification; pub mod payload_envelope_streamer; pub mod payload_envelope_verification; +pub mod pending_payload_cache; pub mod pending_payload_envelopes; pub mod persisted_beacon_chain; pub mod persisted_custody; diff --git a/beacon_node/beacon_chain/src/metrics.rs b/beacon_node/beacon_chain/src/metrics.rs index 43c3337bc9..df1b005820 100644 --- a/beacon_node/beacon_chain/src/metrics.rs +++ b/beacon_node/beacon_chain/src/metrics.rs @@ -2043,6 +2043,12 @@ pub static DATA_AVAILABILITY_OVERFLOW_MEMORY_BLOCK_CACHE_SIZE: LazyLock> = LazyLock::new(|| { + try_create_int_gauge( + "pending_payload_cache_size", + "Number of entries in the pending payload availability cache.", + ) +}); pub static DATA_AVAILABILITY_RECONSTRUCTION_TIME: LazyLock> = LazyLock::new(|| { try_create_histogram( @@ -2150,6 +2156,10 @@ pub fn scrape_for_metrics(beacon_chain: &BeaconChain) { &DATA_AVAILABILITY_OVERFLOW_MEMORY_BLOCK_CACHE_SIZE, da_checker_metrics.block_cache_size, ); + set_gauge_by_usize( + &PENDING_PAYLOAD_CACHE_SIZE, + beacon_chain.pending_payload_cache.cache_size(), + ); if let Some((size, num_lookups)) = beacon_chain.pre_finalization_block_cache.metrics() { set_gauge_by_usize(&PRE_FINALIZATION_BLOCK_CACHE_SIZE, size); diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs index 4b8e7347cc..b678bdbaea 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/execution_pending_envelope.rs @@ -1,23 +1,22 @@ -use std::sync::Arc; - +use bls::Hash256; use slot_clock::SlotClock; use state_processing::{VerifySignatures, envelope_processing::verify_execution_payload_envelope}; -use types::EthSpec; +use std::sync::Arc; +use types::{EthSpec, SignedExecutionPayloadEnvelope}; use crate::{ BeaconChain, BeaconChainError, BeaconChainTypes, NotifyExecutionLayer, PayloadVerificationOutcome, block_verification::PayloadVerificationHandle, payload_envelope_verification::{ - EnvelopeError, EnvelopeImportData, MaybeAvailableEnvelope, - gossip_verified_envelope::GossipVerifiedEnvelope, load_snapshot_from_state_root, - payload_notifier::PayloadNotifier, + EnvelopeError, gossip_verified_envelope::GossipVerifiedEnvelope, + load_snapshot_from_state_root, payload_notifier::PayloadNotifier, }, }; pub struct ExecutionPendingEnvelope { - pub signed_envelope: MaybeAvailableEnvelope, - pub import_data: EnvelopeImportData, + pub signed_envelope: Arc>, + pub block_root: Hash256, pub payload_verification_handle: PayloadVerificationHandle, } @@ -29,7 +28,6 @@ impl GossipVerifiedEnvelope { ) -> Result, EnvelopeError> { let signed_envelope = self.signed_envelope; let envelope = &signed_envelope.message; - let payload = &envelope.payload; // Define a future that will verify the execution payload with an execution engine. // @@ -87,14 +85,8 @@ impl GossipVerifiedEnvelope { )?; Ok(ExecutionPendingEnvelope { - signed_envelope: MaybeAvailableEnvelope::AvailabilityPending { - block_hash: payload.block_hash, - envelope: signed_envelope, - }, - import_data: EnvelopeImportData { - block_root, - _phantom: Default::default(), - }, + signed_envelope, + block_root, payload_verification_handle, }) } diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs index b40e8337fb..73ddb43273 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/import.rs @@ -9,13 +9,18 @@ use tracing::{debug, error, info, info_span, instrument, warn}; use types::{BlockImportSource, Hash256, SignedExecutionPayloadEnvelope}; use super::{ - AvailableEnvelope, AvailableExecutedEnvelope, EnvelopeError, EnvelopeImportData, - ExecutedEnvelope, gossip_verified_envelope::GossipVerifiedEnvelope, + AvailableEnvelope, AvailableExecutedEnvelope, EnvelopeError, + gossip_verified_envelope::GossipVerifiedEnvelope, }; use crate::{ - AvailabilityProcessingStatus, BeaconChain, BeaconChainError, BeaconChainTypes, - NotifyExecutionLayer, block_verification_types::AvailableBlockData, metrics, - payload_envelope_verification::ExecutionPendingEnvelope, validator_monitor::get_slot_delay_ms, + AvailabilityProcessingStatus, BeaconChain, BeaconChainError, BeaconChainTypes, BlockError, + NotifyExecutionLayer, + block_verification_types::AvailableBlockData, + metrics, + payload_envelope_verification::{ + AvailabilityPendingExecutedEnvelope, ExecutionPendingEnvelope, + }, + validator_monitor::get_slot_delay_ms, }; const ENVELOPE_METRICS_CACHE_SLOT_LIMIT: u32 = 64; @@ -28,13 +33,13 @@ impl BeaconChain { /// /// Returns an `Err` if the given payload envelope was invalid, or an error was encountered during /// verification. - #[instrument(skip_all, fields(block_root = ?block_root, block_source = %block_source))] + #[instrument(skip_all, fields(block_root = ?block_root, envelope_source = %envelope_source))] pub async fn process_execution_payload_envelope( self: &Arc, block_root: Hash256, unverified_envelope: GossipVerifiedEnvelope, notify_execution_layer: NotifyExecutionLayer, - block_source: BlockImportSource, + envelope_source: BlockImportSource, publish_fn: impl FnOnce() -> Result<(), EnvelopeError>, ) -> Result { let block_slot = unverified_envelope.signed_envelope.slot(); @@ -50,7 +55,7 @@ impl BeaconChain { ); } - // TODO(gloas) insert the pre-executed envelope into some type of cache. + // TODO(gloas) insert the pre-executed envelope into some type of cache? let _full_timer = metrics::start_timer(&metrics::ENVELOPE_PROCESSING_TIMES); @@ -79,12 +84,11 @@ impl BeaconChain { let executed_envelope = chain .into_executed_payload_envelope(execution_pending) .await - .inspect_err(|_| { - // TODO(gloas) If the envelope fails execution for whatever reason (e.g. engine offline), - // and we keep it in the cache, then the node will NOT perform lookup and - // reprocess this block until the block is evicted from DA checker, causing the - // chain to get stuck temporarily if the block is canonical. Therefore we remove - // it from the cache if execution fails. + .map_err(|error| match error { + BlockError::ExecutionPayloadError(error) => { + EnvelopeError::ExecutionPayloadError(error) + } + error => EnvelopeError::ImportError(error), })?; // Record the time it took to wait for execution layer verification. @@ -94,15 +98,9 @@ impl BeaconChain { .set_time_executed(block_root, block_slot, timestamp); } - match executed_envelope { - ExecutedEnvelope::Available(envelope) => { - self.import_available_execution_payload_envelope(Box::new(envelope)) - .await - } - ExecutedEnvelope::AvailabilityPending() => Err(EnvelopeError::InternalError( - "Pending payload envelope not yet implemented".to_owned(), - )), - } + self.check_envelope_availability_and_import(executed_envelope) + .await + .map_err(EnvelopeError::ImportError) }; // Verify and import the payload envelope. @@ -112,7 +110,7 @@ impl BeaconChain { info!( ?block_root, %block_slot, - source = %block_source, + source = %envelope_source, "Execution payload envelope imported" ); @@ -138,6 +136,14 @@ impl BeaconChain { } Err(EnvelopeError::BeaconChainError(e)) } + Err(EnvelopeError::ImportError(BlockError::BeaconChainError(e))) => { + if matches!(e.as_ref(), BeaconChainError::TokioJoin(_)) { + debug!(error = ?e, "Envelope processing cancelled"); + } else { + warn!(error = ?e, "Execution payload envelope rejected"); + } + Err(EnvelopeError::ImportError(BlockError::BeaconChainError(e))) + } Err(other) => { warn!( reason = other.to_string(), @@ -148,6 +154,19 @@ impl BeaconChain { } } + #[instrument(skip_all)] + async fn check_envelope_availability_and_import( + self: &Arc, + envelope: AvailabilityPendingExecutedEnvelope, + ) -> Result { + let slot = envelope.envelope.slot(); + let availability = self + .pending_payload_cache + .put_executed_payload_envelope(envelope)?; + self.process_payload_envelope_availability(slot, availability, || Ok(())) + .await + } + /// Accepts a fully-verified payload envelope and awaits on its payload verification handle to /// get a fully `ExecutedEnvelope`. /// @@ -156,10 +175,10 @@ impl BeaconChain { async fn into_executed_payload_envelope( self: Arc, pending_envelope: ExecutionPendingEnvelope, - ) -> Result, EnvelopeError> { + ) -> Result, BlockError> { let ExecutionPendingEnvelope { signed_envelope, - import_data, + block_root, payload_verification_handle, } = pending_envelope; @@ -173,16 +192,13 @@ impl BeaconChain { .payload_verification_status .is_optimistic() { - return Err(EnvelopeError::OptimisticSyncNotSupported { - block_root: import_data.block_root, - }); + return Err(BlockError::OptimisticSyncNotSupported { block_root }); } - Ok(ExecutedEnvelope::new( + Ok(AvailabilityPendingExecutedEnvelope::new( signed_envelope, - import_data, + block_root, payload_verification_outcome, - self.spec.clone(), )) } @@ -190,18 +206,13 @@ impl BeaconChain { pub async fn import_available_execution_payload_envelope( self: &Arc, envelope: Box>, - ) -> Result { + ) -> Result { let AvailableExecutedEnvelope { envelope, - import_data, + block_root, payload_verification_outcome, } = *envelope; - let EnvelopeImportData { - block_root, - _phantom, - } = import_data; - let block_root = { let chain = self.clone(); self.spawn_blocking_handle( @@ -232,13 +243,13 @@ impl BeaconChain { signed_envelope: AvailableEnvelope, block_root: Hash256, payload_verification_status: PayloadVerificationStatus, - ) -> Result { + ) -> Result { // Everything in this initial section is on the hot path for processing the envelope. // Take an upgradable read lock on fork choice so we can check if this block has already // been imported. We don't want to repeat work importing a block that is already imported. let fork_choice_reader = self.canonical_head.fork_choice_upgradable_read_lock(); if !fork_choice_reader.contains_block(&block_root) { - return Err(EnvelopeError::BlockRootUnknown { block_root }); + return Err(BlockError::EnvelopeBlockRootUnknown(block_root)); } // TODO(gloas) add defensive check to see if payload envelope is already in fork choice @@ -253,7 +264,7 @@ impl BeaconChain { // node which can be eligible for head. fork_choice .on_valid_payload_envelope_received(block_root) - .map_err(|e| EnvelopeError::InternalError(format!("{e:?}")))?; + .map_err(|e| BlockError::InternalError(format!("{e:?}")))?; // TODO(gloas) emit SSE event if the payload became the new head payload diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs index b153a3cd6a..a1e4e34eb6 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/mod.rs @@ -18,14 +18,13 @@ //! //! ``` -use std::marker::PhantomData; +use state_processing::envelope_processing::EnvelopeProcessingError; use std::sync::Arc; - -use state_processing::{BlockProcessingError, envelope_processing::EnvelopeProcessingError}; use store::Error as DBError; +use strum::AsRefStr; use tracing::instrument; use types::{ - BeaconState, BeaconStateError, ChainSpec, DataColumnSidecarList, EthSpec, ExecutionBlockHash, + BeaconState, BeaconStateError, DataColumnSidecarList, EthSpec, ExecutionBlockHash, ExecutionPayloadEnvelope, Hash256, SignedExecutionPayloadEnvelope, Slot, }; @@ -41,39 +40,18 @@ mod payload_notifier; pub use execution_pending_envelope::ExecutionPendingEnvelope; -// TODO(gloas): could remove this type completely, or remove the generic -#[derive(PartialEq)] -pub struct EnvelopeImportData { - pub block_root: Hash256, - _phantom: PhantomData, -} - #[derive(Debug)] -#[allow(dead_code)] pub struct AvailableEnvelope { - execution_block_hash: ExecutionBlockHash, envelope: Arc>, - columns: DataColumnSidecarList, - /// Timestamp at which this envelope first became available (UNIX timestamp, time since 1970). - columns_available_timestamp: Option, - pub spec: Arc, + pub columns: DataColumnSidecarList, } impl AvailableEnvelope { pub fn new( - execution_block_hash: ExecutionBlockHash, envelope: Arc>, columns: DataColumnSidecarList, - columns_available_timestamp: Option, - spec: Arc, ) -> Self { - Self { - execution_block_hash, - envelope, - columns, - columns_available_timestamp, - spec, - } + Self { envelope, columns } } pub fn message(&self) -> &ExecutionPayloadEnvelope { @@ -94,14 +72,6 @@ impl AvailableEnvelope { } } -pub enum MaybeAvailableEnvelope { - Available(AvailableEnvelope), - AvailabilityPending { - block_hash: ExecutionBlockHash, - envelope: Arc>, - }, -} - /// This snapshot is to be used for verifying a payload envelope. #[derive(Debug, Clone)] pub struct EnvelopeProcessingSnapshot { @@ -111,46 +81,25 @@ pub struct EnvelopeProcessingSnapshot { pub beacon_block_root: Hash256, } -/// A payload envelope that has gone through processing checks and execution by an EL client. -/// This envelope hasn't necessarily completed data availability checks. -/// -/// -/// It contains 2 variants: -/// 1. `Available`: This envelope has been executed and also contains all data to consider it -/// fully available. -/// 2. `AvailabilityPending`: This envelope hasn't received all required blobs to consider it -/// fully available. -#[allow(dead_code)] -pub enum ExecutedEnvelope { - Available(AvailableExecutedEnvelope), - // TODO(gloas): check data column availability via DA checker - AvailabilityPending(), +/// A payload envelope that has completed all envelope processing checks, verification +/// by an EL client but does not have all requisite columns to get imported into +/// fork choice. +pub struct AvailabilityPendingExecutedEnvelope { + pub envelope: Arc>, + pub block_root: Hash256, + pub payload_verification_outcome: PayloadVerificationOutcome, } -impl ExecutedEnvelope { +impl AvailabilityPendingExecutedEnvelope { pub fn new( - envelope: MaybeAvailableEnvelope, - import_data: EnvelopeImportData, + envelope: Arc>, + block_root: Hash256, payload_verification_outcome: PayloadVerificationOutcome, - spec: Arc, ) -> Self { - match envelope { - MaybeAvailableEnvelope::Available(available_envelope) => { - Self::Available(AvailableExecutedEnvelope::new( - available_envelope, - import_data, - payload_verification_outcome, - )) - } - // TODO(gloas): check data column availability via DA checker - MaybeAvailableEnvelope::AvailabilityPending { - block_hash, - envelope, - } => Self::Available(AvailableExecutedEnvelope::new( - AvailableEnvelope::new(block_hash, envelope, vec![], None, spec), - import_data, - payload_verification_outcome, - )), + Self { + envelope, + block_root, + payload_verification_outcome, } } } @@ -159,25 +108,25 @@ impl ExecutedEnvelope { /// by an EL client **and** has all requisite blob data to be imported into fork choice. pub struct AvailableExecutedEnvelope { pub envelope: AvailableEnvelope, - pub import_data: EnvelopeImportData, + pub block_root: Hash256, pub payload_verification_outcome: PayloadVerificationOutcome, } impl AvailableExecutedEnvelope { pub fn new( envelope: AvailableEnvelope, - import_data: EnvelopeImportData, + block_root: Hash256, payload_verification_outcome: PayloadVerificationOutcome, ) -> Self { Self { envelope, - import_data, + block_root, payload_verification_outcome, } } } -#[derive(Debug)] +#[derive(Debug, AsRefStr)] pub enum EnvelopeError { /// The envelope's block root is unknown. BlockRootUnknown { block_root: Hash256 }, @@ -205,22 +154,16 @@ pub enum EnvelopeError { payload_slot: Slot, latest_finalized_slot: Slot, }, - /// Optimistic sync is not supported for Gloas payload envelopes. - OptimisticSyncNotSupported { block_root: Hash256 }, /// Some Beacon Chain Error BeaconChainError(Arc), /// Some Beacon State error BeaconStateError(BeaconStateError), - /// Some BlockProcessingError (for electra operations) - BlockProcessingError(BlockProcessingError), /// Some EnvelopeProcessingError EnvelopeProcessingError(EnvelopeProcessingError), /// Error verifying the execution payload ExecutionPayloadError(ExecutionPayloadError), - /// An error from block-level checks reused during envelope import - BlockError(BlockError), - /// Internal error - InternalError(String), + /// An error from importing the envelope. + ImportError(BlockError), } impl std::fmt::Display for EnvelopeError { @@ -253,13 +196,6 @@ impl From for EnvelopeError { } } -impl From for EnvelopeError { - fn from(e: BlockError) -> Self { - EnvelopeError::BlockError(e) - } -} - -/// Pull errors up from EnvelopeProcessingError to EnvelopeError impl From for EnvelopeError { fn from(e: EnvelopeProcessingError) -> Self { match e { diff --git a/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs b/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs index eb5e13b0cc..0bbe32525a 100644 --- a/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs +++ b/beacon_node/beacon_chain/src/payload_envelope_verification/payload_notifier.rs @@ -31,7 +31,8 @@ impl PayloadNotifier { match notify_execution_layer { NotifyExecutionLayer::No if chain.config.optimistic_finalized_sync => { - let new_payload_request = Self::build_new_payload_request(&envelope, &block)?; + let new_payload_request = Self::build_new_payload_request(&envelope, &block) + .map_err(EnvelopeError::ImportError)?; // TODO(gloas): check and test RLP block hash calculation post-Gloas if let Err(e) = new_payload_request.perform_optimistic_sync_verifications() { warn!( diff --git a/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs b/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs new file mode 100644 index 0000000000..2100a5fe9f --- /dev/null +++ b/beacon_node/beacon_chain/src/pending_payload_cache/mod.rs @@ -0,0 +1,781 @@ +//! This module builds out the data availability cache for Gloas. When a beacon block is received +//! over gossip/p2p we insert its bid into this cache, keyed by block root. As soon as the bid +//! is received we can begin using it to verify data columns. +//! +//! When a payload envelope is received and executed against the EL, it is inserted into this cache. +//! Once all required custody columns have been kzg verified and the envelope has been executed we can +//! import the envelope into fork choice and store it to disk. +//! +//! Note that the block must have arrived before the envelope or data columns can reach this cache. +//! Data columns require the bid (from the block) for verification. Columns that arrive before +//! the block are rejected with `BlockRootUnknown`. + +use crate::data_availability_checker::{AvailabilityCheckError, MissingCellsError}; +use crate::payload_envelope_verification::{ + AvailabilityPendingExecutedEnvelope, AvailableExecutedEnvelope, +}; +use crate::{BeaconChainTypes, CustodyContext, metrics}; +use kzg::Kzg; +use lru::LruCache; +use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard, RwLockWriteGuard}; +use std::collections::HashMap; +use std::fmt; +use std::fmt::Debug; +use std::num::NonZeroUsize; +use std::sync::Arc; +use tracing::{Span, debug, error, instrument}; +use types::{ + ChainSpec, ColumnIndex, DataColumnSidecar, DataColumnSidecarList, Epoch, EthSpec, Hash256, + PartialDataColumnSidecarRef, +}; + +mod pending_column; +mod pending_components; + +use crate::data_column_verification::{ + GossipVerifiedDataColumn, KzgVerifiedCustodyDataColumn, KzgVerifiedDataColumn, +}; +use crate::metrics::{ + KZG_DATA_COLUMN_RECONSTRUCTION_ATTEMPTS, KZG_DATA_COLUMN_RECONSTRUCTION_FAILURES, +}; +use crate::observed_data_sidecars::ObservationStrategy; +use pending_components::{PendingComponents, ReconstructColumnsDecision}; +use types::SignedExecutionPayloadBid; +use types::new_non_zero_usize; + +/// The LRU Cache stores `PendingComponents`, which store the block root, the execution payload bid, and its associated column data. +/// The execution payload bid stores the kzg commitments which we use to verify against incoming column data. +/// Setting this to 32 keeps memory usage reasonable. +/// +/// `PendingComponents` are now never removed from the cache manually and are only removed via LRU +/// eviction to prevent race conditions (#7961), so we expect this cache to be full all the time. +const AVAILABILITY_CACHE_CAPACITY: NonZeroUsize = new_non_zero_usize(32); + +/// This type is returned after adding a bid / column to the `DataAvailabilityChecker`. +/// +/// Indicates if the payloads data is fully `Available` or if we need more columns. +pub enum Availability { + MissingComponents(Hash256), + Available(Box>), +} + +impl Debug for Availability { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::MissingComponents(block_root) => { + write!(f, "MissingComponents({})", block_root) + } + Self::Available(envelope) => { + write!(f, "Available({:?})", envelope.block_root) + } + } + } +} + +pub type AvailabilityAndReconstructedColumns = (Availability, DataColumnSidecarList); + +#[derive(Debug)] +pub enum DataColumnReconstructionResult { + Success(AvailabilityAndReconstructedColumns), + NotStarted(&'static str), + RecoveredColumnsNotImported(&'static str), +} + +/// Cache to hold data columns for payloads pending data availability. +/// +/// In Gloas, beacon blocks can be immediately imported into fork choice. The execution payload +/// bid contains the payloads kzg commitments. This cache tracks data columns for payloads until all +/// required columns are received. +/// +/// Usually data becomes available on its slot within a second of receiving its first component +/// over gossip. However, data may never become available if a malicious proposer does not +/// publish its data, or there are network issues. Components are only removed via LRU eviction. +pub struct PendingPayloadCache { + /// Contains all the data we keep in memory, protected by an RwLock + availability_cache: RwLock>>, + kzg: Arc, + custody_context: Arc>, + spec: Arc, +} + +impl PendingPayloadCache { + pub fn new( + kzg: Arc, + custody_context: Arc>, + spec: Arc, + ) -> Result { + Ok(Self { + availability_cache: RwLock::new(LruCache::new(AVAILABILITY_CACHE_CAPACITY)), + kzg, + custody_context, + spec, + }) + } + + pub fn custody_context(&self) -> &Arc> { + &self.custody_context + } + + /// Returns all cached data columns for the given block root, if any. + #[instrument(skip_all, level = "trace")] + pub fn get_data_columns( + &self, + block_root: Hash256, + ) -> Option> { + self.peek_pending_components(&block_root, |components| { + components.map(|c| c.get_cached_data_columns()) + }) + } + + /// Returns the indices of cached data columns for the given block root. + #[instrument(skip_all, level = "trace")] + pub fn cached_data_column_indexes(&self, block_root: &Hash256) -> Option> { + self.peek_pending_components(block_root, |components| { + components.map(|components| components.get_cached_data_columns_indices()) + }) + } + + /// Return the cached Gloas payload bid for `block_root`, if present. + pub fn get_bid( + &self, + block_root: &Hash256, + ) -> Option>> { + self.peek_pending_components(block_root, |components| { + components.map(|components| components.bid.clone()) + }) + } + + /// Filter out cells that are already cached for the given column sidecar. + /// Returns the cells that still need KZG verification, or `None` if all cells are cached. + #[instrument(skip_all, level = "trace")] + pub fn missing_cells_for_column_sidecar<'a>( + &'_ self, + data_column: &'a DataColumnSidecar, + ) -> Result>, MissingCellsError> { + let block_root = data_column.block_root(); + let column_index = *data_column.index(); + + self.peek_pending_components(&block_root, |components| { + let Some(cached) = components.and_then(|c| c.verified_data_columns.get(&column_index)) + else { + return data_column.try_filter_to_partial_ref(|_, _, _| Ok(true)); + }; + + data_column.try_filter_to_partial_ref(|cell_idx, cell, proof| { + match cached.cell_matches(cell_idx, cell, proof) { + None => Ok(true), + Some(true) => Ok(false), + Some(false) => Err(MissingCellsError::MismatchesCachedColumn), + } + }) + }) + } + + /// Insert an executed payload envelope into the cache and performs an availability check + pub fn put_executed_payload_envelope( + &self, + executed_envelope: AvailabilityPendingExecutedEnvelope, + ) -> Result, AvailabilityCheckError> { + let epoch = executed_envelope.envelope.epoch(); + let beacon_block_root = executed_envelope.envelope.beacon_block_root(); + let bid = self + .get_bid(&beacon_block_root) + .ok_or(AvailabilityCheckError::MissingBid(beacon_block_root))?; + + let pending_components = + self.update_pending_components(beacon_block_root, bid, |pending_components| { + pending_components.insert_executed_payload_envelope(executed_envelope); + })?; + + let num_expected_columns = self + .custody_context + .num_of_data_columns_to_sample(epoch, &self.spec); + + pending_components.span.in_scope(|| { + debug!( + component = "executed envelope", + status = pending_components.status_str(num_expected_columns), + "Component added to data availability checker" + ); + }); + + self.check_availability(beacon_block_root, pending_components, num_expected_columns) + } + + /// Inserts a bid into the pending payload cache. + /// This will silently drop the bid if a bid for this block root already exists in the cache. + pub fn insert_bid(&self, block_root: Hash256, bid: Arc>) { + let mut write_lock = self.availability_cache.write(); + write_lock.get_or_insert_mut(block_root, || PendingComponents::new(block_root, bid)); + } + + /// Perform KZG verification on RPC custody columns and insert them into the cache. + /// After insertion check if the envelope becomes available. + #[instrument(skip_all, level = "trace")] + pub fn put_rpc_custody_columns( + &self, + block_root: Hash256, + custody_columns: DataColumnSidecarList, + ) -> Result, AvailabilityCheckError> { + let bid = self + .get_bid(&block_root) + .ok_or(AvailabilityCheckError::MissingBid(block_root))?; + let kzg_verified_columns = KzgVerifiedDataColumn::from_batch_with_scoring_and_commitments( + custody_columns, + bid.message.blob_kzg_commitments.as_ref(), + &self.kzg, + ) + .map_err(AvailabilityCheckError::InvalidColumn)?; + + let epoch = bid.message.slot.epoch(T::EthSpec::slots_per_epoch()); + let sampling_columns = self + .custody_context + .sampling_columns_for_epoch(epoch, &self.spec); + let verified_custody_columns = kzg_verified_columns + .into_iter() + .filter(|col| sampling_columns.contains(&col.index())) + .map(KzgVerifiedCustodyDataColumn::from_asserted_custody) + .collect::>(); + + self.put_kzg_verified_custody_data_columns(block_root, &verified_custody_columns) + } + + /// Perform KZG verification on gossip verified custody columns and insert them into the cache. + /// After insertion check if the envelope becomes available + #[instrument(skip_all, level = "trace")] + pub fn put_gossip_verified_data_columns( + &self, + block_root: Hash256, + data_columns: Vec>, + ) -> Result, AvailabilityCheckError> { + let bid = self + .get_bid(&block_root) + .ok_or(AvailabilityCheckError::MissingBid(block_root))?; + let epoch = bid.message.slot.epoch(T::EthSpec::slots_per_epoch()); + let sampling_columns = self + .custody_context + .sampling_columns_for_epoch(epoch, &self.spec); + let custody_columns = data_columns + .into_iter() + .filter(|col| sampling_columns.contains(&col.index())) + .map(|c| KzgVerifiedCustodyDataColumn::from_asserted_custody(c.into_inner())) + .collect::>(); + + self.put_kzg_verified_custody_data_columns(block_root, &custody_columns) + } + + /// Insert KZG verified columns into the cache. + /// After insertion check if the envelope becomes available. + pub fn put_kzg_verified_custody_data_columns( + &self, + block_root: Hash256, + kzg_verified_data_columns: &[KzgVerifiedCustodyDataColumn], + ) -> Result, AvailabilityCheckError> { + let bid = self + .get_bid(&block_root) + .ok_or(AvailabilityCheckError::MissingBid(block_root))?; + + let pending_components = + self.update_pending_components(block_root, bid.clone(), |pending_components| { + pending_components.merge_data_columns(kzg_verified_data_columns) + })?; + + let epoch = bid.message.slot.epoch(T::EthSpec::slots_per_epoch()); + + let num_expected_columns = self + .custody_context + .num_of_data_columns_to_sample(epoch, &self.spec); + + pending_components.span.in_scope(|| { + debug!( + component = "data_columns", + status = pending_components.status_str(num_expected_columns), + "Component added to data availability checker" + ); + }); + + self.check_availability(block_root, pending_components, num_expected_columns) + } + + #[instrument(skip_all, level = "debug")] + pub fn reconstruct_data_columns( + &self, + block_root: &Hash256, + ) -> Result, AvailabilityCheckError> { + let bid = self + .get_bid(block_root) + .ok_or(AvailabilityCheckError::MissingBid(*block_root))?; + + let verified_data_columns = match self.check_and_set_reconstruction_started(block_root) { + ReconstructColumnsDecision::Yes(verified_data_columns) => verified_data_columns, + ReconstructColumnsDecision::No(reason) => { + return Ok(DataColumnReconstructionResult::NotStarted(reason)); + } + }; + let existing_column_indices = verified_data_columns + .iter() + .map(|data_column| *data_column.index()) + .collect::>(); + + metrics::inc_counter(&KZG_DATA_COLUMN_RECONSTRUCTION_ATTEMPTS); + let timer = metrics::start_timer(&metrics::DATA_AVAILABILITY_RECONSTRUCTION_TIME); + + let all_data_columns = KzgVerifiedCustodyDataColumn::reconstruct_columns( + &self.kzg, + verified_data_columns, + bid.message.blob_kzg_commitments.as_ref(), + &self.spec, + ) + .map_err(|e| { + error!( + ?block_root, + error = ?e, + "Error reconstructing data columns" + ); + self.handle_reconstruction_failure(block_root); + metrics::inc_counter(&KZG_DATA_COLUMN_RECONSTRUCTION_FAILURES); + AvailabilityCheckError::ReconstructColumnsError(e) + })?; + + let slot = bid.message.slot; + let columns_to_sample = self + .custody_context() + .sampling_columns_for_epoch(slot.epoch(T::EthSpec::slots_per_epoch()), &self.spec); + + let data_columns_to_import_and_publish = all_data_columns + .into_iter() + .filter(|d| { + columns_to_sample.contains(&d.index()) + && !existing_column_indices.contains(&d.index()) + }) + .collect::>(); + + metrics::stop_timer(timer); + metrics::inc_counter_by( + &metrics::DATA_AVAILABILITY_RECONSTRUCTED_COLUMNS, + data_columns_to_import_and_publish.len() as u64, + ); + + debug!( + count = data_columns_to_import_and_publish.len(), + ?block_root, + %slot, + "Reconstructed columns" + ); + + self.put_kzg_verified_custody_data_columns(*block_root, &data_columns_to_import_and_publish) + .map(|availability| { + DataColumnReconstructionResult::Success(( + availability, + data_columns_to_import_and_publish + .into_iter() + .map(|d| d.clone_arc()) + .collect::>(), + )) + }) + } + + // ── Metrics ── + + /// Number of pending component entries in memory in the cache. + pub fn cache_size(&self) -> usize { + self.availability_cache.read().len() + } + + // ── Internal helpers ── + + fn check_availability( + &self, + block_root: Hash256, + pending_components: MappedRwLockReadGuard<'_, PendingComponents>, + num_expected_columns: usize, + ) -> Result, AvailabilityCheckError> { + if let Some(available_envelope) = pending_components.make_available(num_expected_columns)? { + // Explicitly drop read lock before acquiring write lock + drop(pending_components); + if let Some(components) = self.availability_cache.write().get_mut(&block_root) { + // Clean up span now that data is available + components.span = Span::none(); + } + + // We never remove the pending components manually to avoid race conditions. + // Components are only removed via LRU eviction as finality advances. + Ok(Availability::Available(Box::new(available_envelope))) + } else { + Ok(Availability::MissingComponents(block_root)) + } + } + + /// Gets or creates `PendingComponents` and applies the `update_fn` while holding the write lock. + /// + /// Once the update is complete, the write lock is downgraded and a read guard with a + /// reference of the updated `PendingComponents` is returned. + fn update_pending_components( + &self, + block_root: Hash256, + bid: Arc>, + update_fn: F, + ) -> Result>, AvailabilityCheckError> + where + F: FnOnce(&mut PendingComponents), + { + let mut write_lock = self.availability_cache.write(); + + { + let pending_components = write_lock + .get_or_insert_mut(block_root, || PendingComponents::new(block_root, bid)); + update_fn(pending_components) + } + + RwLockReadGuard::try_map(RwLockWriteGuard::downgrade(write_lock), |cache| { + cache.peek(&block_root) + }) + .map_err(|_| { + AvailabilityCheckError::Unexpected("pending components should exist".to_string()) + }) + } + + fn peek_pending_components>) -> R>( + &self, + block_root: &Hash256, + f: F, + ) -> R { + f(self.availability_cache.read().peek(block_root)) + } + + /// Check whether data column reconstruction should be attempted. + /// TODO(gloas): rethink reconstruction for the cell model + fn check_and_set_reconstruction_started( + &self, + block_root: &Hash256, + ) -> ReconstructColumnsDecision { + let mut write_lock = self.availability_cache.write(); + let Some(pending_components) = write_lock.get_mut(block_root) else { + return ReconstructColumnsDecision::No("block already imported"); + }; + + let epoch = pending_components.bid.epoch(); + + let total_column_count = T::EthSpec::number_of_columns(); + let sampling_column_count = self + .custody_context + .num_of_data_columns_to_sample(epoch, &self.spec); + + if pending_components.reconstruction_started { + return ReconstructColumnsDecision::No("already started"); + } + let received_column_count = pending_components.num_completed_columns(); + if received_column_count >= sampling_column_count { + return ReconstructColumnsDecision::No("all sampling columns received"); + } + if received_column_count < total_column_count / 2 { + return ReconstructColumnsDecision::No("not enough columns"); + } + + pending_components.reconstruction_started = true; + ReconstructColumnsDecision::Yes(pending_components.get_cached_data_columns()) + } + + /// This could mean some invalid data columns made it through to the `DataAvailabilityChecker`. + /// In this case, we remove all data columns in `PendingComponents`, reset reconstruction + /// status so that we can attempt to retrieve columns from peers again. + fn handle_reconstruction_failure(&self, block_root: &Hash256) { + if let Some(pending_components_mut) = self.availability_cache.write().get_mut(block_root) { + pending_components_mut.verified_data_columns = HashMap::new(); + pending_components_mut.reconstruction_started = false; + } + } + + /// Maintain the cache by removing entries older than the cutoff epoch. + pub fn do_maintenance(&self, cutoff_epoch: Epoch) -> Result<(), AvailabilityCheckError> { + let mut write_lock = self.availability_cache.write(); + let mut keys_to_remove = vec![]; + for (key, value) in write_lock.iter() { + if value.bid.epoch() < cutoff_epoch { + keys_to_remove.push(*key); + } + } + for key in keys_to_remove { + write_lock.pop(&key); + } + + Ok(()) + } +} + +#[cfg(test)] +mod data_availability_checker_tests { + use super::*; + + use crate::block_verification::PayloadVerificationOutcome; + use crate::custody_context::NodeCustodyType; + use crate::test_utils::{ + DiskHarnessType, NumBlobs, generate_data_column_indices_rand_order, + generate_rand_block_and_data_columns, get_kzg, + }; + use fork_choice::PayloadVerificationStatus; + use logging::create_test_tracing_subscriber; + use types::test_utils::test_unstructured; + use types::{ + ExecutionPayloadEnvelope, ExecutionPayloadGloas, ExecutionRequests, ForkName, + MinimalEthSpec, SignedExecutionPayloadEnvelope, + }; + + type E = MinimalEthSpec; + type T = DiskHarnessType; + + const NUM_BLOBS: usize = 1; + + /// Stand up a cache + a 1-blob Gloas block for the given custody type. The bid is registered + /// in the cache; `custody` is pre-filtered to the sampling subset. + fn setup(node_custody: NodeCustodyType) -> Setup { + setup_with(node_custody, NumBlobs::Number(NUM_BLOBS)) + } + + fn setup_zero_blob(node_custody: NodeCustodyType) -> Setup { + setup_with(node_custody, NumBlobs::Number(0)) + } + + fn setup_with(node_custody: NodeCustodyType, num_blobs: NumBlobs) -> Setup { + create_test_tracing_subscriber(); + let spec = Arc::new(ForkName::Gloas.make_genesis_spec(E::default_spec())); + let kzg = get_kzg(&spec); + let custody_context = Arc::new(CustodyContext::::new( + node_custody, + generate_data_column_indices_rand_order::(), + &spec, + )); + let cache = Arc::new( + PendingPayloadCache::::new(kzg, custody_context, spec.clone()) + .expect("create cache"), + ); + + let mut u = test_unstructured(); + let (block, columns) = + generate_rand_block_and_data_columns::(ForkName::Gloas, num_blobs, &mut u, &spec) + .expect("generate test block"); + let block_root = block.canonical_root(); + let bid = Arc::new( + block + .message() + .body() + .signed_execution_payload_bid() + .expect("Gloas block has bid") + .clone(), + ); + cache.insert_bid(block_root, bid.clone()); + + let epoch = bid.message.slot.epoch(E::slots_per_epoch()); + let sampling = cache + .custody_context() + .sampling_columns_for_epoch(epoch, &cache.spec); + let custody = columns + .into_iter() + .filter(|c| sampling.contains(c.index())) + .collect(); + + Setup { + cache, + block_root, + custody, + } + } + + struct Setup { + cache: Arc>, + block_root: Hash256, + custody: DataColumnSidecarList, + } + + impl Setup { + fn put_envelope(&self) -> Availability { + self.cache + .put_executed_payload_envelope(executed_envelope(self.block_root)) + .expect("put envelope") + } + + fn put_columns(&self, columns: DataColumnSidecarList) -> Availability { + self.cache + .put_rpc_custody_columns(self.block_root, columns) + .expect("put columns") + } + + fn reconstruct(&self) -> Result, AvailabilityCheckError> { + self.cache.reconstruct_data_columns(&self.block_root) + } + + fn cached_indexes(&self) -> Vec { + self.cache + .cached_data_column_indexes(&self.block_root) + .expect("entry") + } + } + + /// Hand-rolled executed envelope with bypassed verification; the cache only inspects + /// `beacon_block_root` and the verification outcome, never the signature or payload. + fn executed_envelope(block_root: Hash256) -> AvailabilityPendingExecutedEnvelope { + AvailabilityPendingExecutedEnvelope { + envelope: Arc::new(SignedExecutionPayloadEnvelope { + message: ExecutionPayloadEnvelope { + payload: ExecutionPayloadGloas::default(), + execution_requests: ExecutionRequests::default(), + builder_index: 0, + beacon_block_root: block_root, + parent_beacon_block_root: Hash256::random(), + }, + signature: bls::Signature::infinity().expect("infinity sig"), + }), + block_root, + payload_verification_outcome: PayloadVerificationOutcome { + payload_verification_status: PayloadVerificationStatus::Verified, + }, + } + } + + #[track_caller] + fn assert_missing(availability: Availability) { + assert!( + matches!(availability, Availability::MissingComponents(_)), + "expected MissingComponents, got {availability:?}", + ); + } + + #[track_caller] + fn assert_available(availability: Availability) -> Box> { + match availability { + Availability::Available(env) => env, + other => panic!("expected Available, got {other:?}"), + } + } + + // ─── Tier 1: real-path availability flows ─────────────────────────────── + + /// Envelope first → MissingComponents. Then all sampling columns → Available. + #[tokio::test] + async fn availability_arrives_envelope_first() { + let s = setup(NodeCustodyType::Fullnode); + assert_missing(s.put_envelope()); + let envelope = assert_available(s.put_columns(s.custody.clone())); + assert_eq!(envelope.block_root, s.block_root); + assert_eq!(envelope.envelope.columns.len(), s.custody.len()); + } + + /// Columns first → MissingComponents. Then envelope → Available. + #[tokio::test] + async fn availability_arrives_columns_first() { + let s = setup(NodeCustodyType::Fullnode); + assert_missing(s.put_columns(s.custody.clone())); + let envelope = assert_available(s.put_envelope()); + assert_eq!(envelope.block_root, s.block_root); + assert_eq!(envelope.envelope.columns.len(), s.custody.len()); + } + + /// N-1 columns + envelope is still MissingComponents; the Nth column flips to Available. + /// Guards the strict count comparison in `make_available`. + #[tokio::test] + async fn partial_columns_then_complete() { + let mut s = setup(NodeCustodyType::Fullnode); + assert!(s.custody.len() >= 2, "needs at least 2 sampling columns"); + let last = s.custody.pop().expect("non-empty custody"); + + s.put_envelope(); + assert_missing(s.put_columns(s.custody.clone())); + assert_available(s.put_columns(vec![last])); + } + + /// Zero-blob block + envelope → Available. Guards the `num_blobs_expected == 0` early-return + /// in `make_available`. + #[tokio::test] + async fn zero_blob_envelope_immediately_available() { + let s = setup_zero_blob(NodeCustodyType::Fullnode); + let envelope = assert_available(s.put_envelope()); + assert!(envelope.envelope.columns.is_empty()); + } + + /// Receiving the same column twice keeps a single cache entry. Guards `PendingColumn::insert` + /// staying only-if-empty under repeated arrivals. + #[tokio::test] + async fn dedups_repeated_column_inserts() { + let s = setup(NodeCustodyType::Fullnode); + let column = s.custody.first().cloned().expect("sampling column"); + let column_index = *column.index(); + s.put_columns(vec![column.clone()]); + s.put_columns(vec![column]); + + assert_eq!(s.cached_indexes(), vec![column_index]); + assert_eq!( + s.cache.get_data_columns(s.block_root).map(|c| c.len()), + Some(1), + ); + } + + // ─── Tier 2: reconstruction state machine ─────────────────────────────── + // + // Reconstruction only triggers when `total/2 ≤ received < sampling_count`. Fullnode's small + // sampling count never satisfies this, so these tests use `Supernode`. + + /// Fewer than `number_of_columns / 2` columns received → reconstruction is `NotStarted`. + #[tokio::test] + async fn reconstruction_below_threshold_is_not_started() { + let s = setup(NodeCustodyType::Supernode); + let half = E::number_of_columns() / 2; + s.put_columns(s.custody.iter().take(half - 1).cloned().collect()); + assert!(matches!( + s.reconstruct().expect("reconstruct call"), + DataColumnReconstructionResult::NotStarted("not enough columns") + )); + } + + /// All sampling columns received → reconstruction unnecessary, returns `NotStarted`. + #[tokio::test] + async fn reconstruction_already_complete_is_not_started() { + let s = setup(NodeCustodyType::Supernode); + s.put_columns(s.custody.clone()); + assert!(matches!( + s.reconstruct().expect("reconstruct call"), + DataColumnReconstructionResult::NotStarted("all sampling columns received") + )); + } + + /// Envelope + 50% of sampling columns → reconstruction recovers the rest, the entry flips + /// to `Available`, and the cache holds every sampling column. + #[tokio::test] + async fn reconstruction_success_fills_missing_columns() { + let s = setup(NodeCustodyType::Supernode); + s.put_envelope(); + let sampling_count = s.custody.len(); + let half = sampling_count / 2; + s.put_columns(s.custody.iter().take(half).cloned().collect()); + assert_eq!(s.cached_indexes().len(), half); + + let result = s.reconstruct().expect("reconstruction must succeed"); + let (availability, _recovered) = match result { + DataColumnReconstructionResult::Success(inner) => inner, + other => panic!("expected Success, got {other:?}"), + }; + assert_available(availability); + assert_eq!(s.cached_indexes().len(), sampling_count); + } + + // ─── Tier 3: invariants ───────────────────────────────────────────────── + + /// `get_data_columns` and `cached_data_column_indexes` must agree on which columns are + /// complete. Drift between these two would corrupt the DB on import. + #[tokio::test] + async fn cached_columns_match_completed_indexes() { + let mut s = setup(NodeCustodyType::Fullnode); + let last = s.custody.pop().expect("non-empty custody"); + + let assert_lengths_match = |s: &Setup| { + let indexes_len = s.cached_indexes().len(); + let sidecars_len = s.cache.get_data_columns(s.block_root).expect("entry").len(); + assert_eq!(indexes_len, sidecars_len); + }; + + s.put_columns(s.custody.clone()); + assert_lengths_match(&s); + + s.put_columns(vec![last]); + assert_lengths_match(&s); + } +} diff --git a/beacon_node/beacon_chain/src/pending_payload_cache/pending_column.rs b/beacon_node/beacon_chain/src/pending_payload_cache/pending_column.rs new file mode 100644 index 0000000000..890c17ba67 --- /dev/null +++ b/beacon_node/beacon_chain/src/pending_payload_cache/pending_column.rs @@ -0,0 +1,63 @@ +use kzg::KzgProof; +use ssz_types::VariableList; +use std::sync::Arc; +use types::{Cell, ColumnIndex, DataColumnSidecar, DataColumnSidecarGloas, EthSpec, Hash256, Slot}; + +#[derive(Clone)] +pub struct PendingColumn { + cells: Vec, KzgProof)>>, +} + +impl PendingColumn { + /// Allocate a `PendingColumn` whose `cells` vec has space for `blob_count` entries, all + /// initialised to `None`. Required so that `insert(idx, ...)` can write into `cells[idx]`. + pub fn new_with_capacity(blob_count: usize) -> Self { + Self { + cells: vec![None; blob_count], + } + } + + pub fn insert(&mut self, index: usize, cell: &Cell, proof: &KzgProof) { + if let Some(existing_cell) = self.cells.get_mut(index) + && existing_cell.is_none() + { + *existing_cell = Some((cell.clone(), *proof)); + } + } + + pub fn cell_matches(&self, index: usize, cell: &Cell, proof: &KzgProof) -> Option { + self.cells + .get(index)? + .as_ref() + .map(|(c, p)| c == cell && p == proof) + } + + /// Returns a full `DataColumnSidecar` if all cells are present, or `None` if any are missing. + pub fn to_full_sidecar( + &self, + index: ColumnIndex, + slot: Slot, + beacon_block_root: Hash256, + ) -> Option>> { + let mut column = Vec::with_capacity(self.cells.len()); + let mut kzg_proofs = Vec::with_capacity(self.cells.len()); + + for cell in self.cells.iter() { + let (cell, proof) = cell.as_ref()?; + // TODO(gloas): we likely want to go and arc all cells. This will help us from requiring a clone + // in PendingColumn::insert + column.push(cell.clone()); + kzg_proofs.push(*proof); + } + + // TODO(gloas): this hard-codes the Gloas sidecar variant. Pass the fork in once + // post-Gloas variants are introduced (or move construction to a fork-aware helper). + Some(Arc::new(DataColumnSidecar::Gloas(DataColumnSidecarGloas { + index, + column: VariableList::try_from(column).ok()?, + kzg_proofs: VariableList::try_from(kzg_proofs).ok()?, + slot, + beacon_block_root, + }))) + } +} diff --git a/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs b/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs new file mode 100644 index 0000000000..e7b9009577 --- /dev/null +++ b/beacon_node/beacon_chain/src/pending_payload_cache/pending_components.rs @@ -0,0 +1,180 @@ +use crate::data_availability_checker::AvailabilityCheckError; +use crate::data_column_verification::KzgVerifiedCustodyDataColumn; +use crate::payload_envelope_verification::AvailabilityPendingExecutedEnvelope; +use crate::payload_envelope_verification::AvailableEnvelope; +use crate::payload_envelope_verification::AvailableExecutedEnvelope; +use crate::pending_payload_cache::pending_column::PendingColumn; +use std::cmp::Ordering; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::{Span, debug, debug_span}; +use types::DataColumnSidecar; +use types::{ColumnIndex, EthSpec, Hash256, SignedExecutionPayloadBid}; + +/// This represents the components of a payload pending data availability. +/// +/// The columns are all gossip and kzg verified. +/// The payload is considered "available" when all required columns are received. +pub struct PendingComponents { + pub block_root: Hash256, + pub bid: Arc>, + /// a cached post executed payload envelope + pub envelope: Option>, + /// A column entry in this map may only have some cells filled in (i.e. a partial data column) + pub verified_data_columns: HashMap>, + pub reconstruction_started: bool, + pub(crate) span: Span, +} + +impl PendingComponents { + pub fn num_blobs_expected(&self) -> usize { + self.bid.message.blob_kzg_commitments.len() + } + + /// Returns columns that have all cells present. + pub fn get_cached_data_columns(&self) -> Vec>> { + let slot = self.bid.message.slot; + let block_root = self.block_root; + self.verified_data_columns + .iter() + .filter_map(|(col_idx, col)| col.to_full_sidecar(*col_idx, slot, block_root)) + .collect() + } + + /// Returns the indices of columns that have all cells present. + pub fn get_cached_data_columns_indices(&self) -> Vec { + let slot = self.bid.message.slot; + let block_root = self.block_root; + self.verified_data_columns + .iter() + .filter_map(|(col_idx, col)| { + col.to_full_sidecar(*col_idx, slot, block_root) + .map(|_| *col_idx) + }) + .collect() + } + + /// Merges a given set of data columns into the cache. + pub(crate) fn merge_data_columns( + &mut self, + kzg_verified_data_columns: &[KzgVerifiedCustodyDataColumn], + ) { + let num_blobs_expected = self.num_blobs_expected(); + for data_column in kzg_verified_data_columns { + let data_column = data_column.as_data_column(); + // The Vec-backed `PendingColumn` keys cells by index, so we have to allocate up to + // `num_blobs_expected` entries before inserting; otherwise `cells.get_mut(idx)` returns + // None and the insert is a no-op. + let col = self + .verified_data_columns + .entry(*data_column.index()) + .or_insert_with(|| PendingColumn::new_with_capacity(num_blobs_expected)); + for (cell_idx, (cell, proof)) in data_column + .column() + .iter() + .zip(data_column.kzg_proofs().iter()) + .enumerate() + { + col.insert(cell_idx, cell, proof); + } + } + } + + // TODO(gloas): merge partial columns + + /// Inserts an executed payload envelope into the cache. + pub fn insert_executed_payload_envelope( + &mut self, + envelope: AvailabilityPendingExecutedEnvelope, + ) { + self.envelope = Some(envelope); + } + + pub fn num_completed_columns(&self) -> usize { + self.get_cached_data_columns().len() + } + + /// Returns `Some` if the envelope and all required data columns have been received. + pub fn make_available( + &self, + num_expected_columns: usize, + ) -> Result>, AvailabilityCheckError> { + // Check if the payload has been received and executed + let Some(envelope) = &self.envelope else { + return Ok(None); + }; + + let AvailabilityPendingExecutedEnvelope { + envelope, + block_root, + payload_verification_outcome, + } = envelope; + + let columns = if self.num_blobs_expected() == 0 { + self.span.in_scope(|| { + debug!("Bid has no blobs, data is available"); + }); + vec![] + } else { + let columns = self.get_cached_data_columns(); + match columns.len().cmp(&num_expected_columns) { + Ordering::Greater => { + return Err(AvailabilityCheckError::Unexpected(format!( + "too many columns: got {} expected {num_expected_columns}", + columns.len() + ))); + } + Ordering::Equal => { + self.span.in_scope(|| { + debug!("All data columns received, data is available"); + }); + columns + } + Ordering::Less => { + // Not enough data columns received yet + return Ok(None); + } + } + }; + + let available_envelope = AvailableEnvelope::new(envelope.clone(), columns); + + Ok(Some(AvailableExecutedEnvelope { + envelope: available_envelope, + block_root: *block_root, + payload_verification_outcome: payload_verification_outcome.clone(), + })) + } + + /// Constructs a fresh `PendingComponents` with no envelope and no columns yet. + pub fn new(block_root: Hash256, bid: Arc>) -> Self { + let span = debug_span!(parent: None, "lh_pending_components", %block_root); + let _guard = span.clone().entered(); + Self { + block_root, + bid, + envelope: None, + verified_data_columns: HashMap::new(), + reconstruction_started: false, + span, + } + } + + pub fn status_str(&self, num_expected_columns: usize) -> String { + format!( + "envelope {}, data_columns {}/{}", + self.envelope.is_some(), + self.num_completed_columns(), + num_expected_columns + ) + } +} + +// This enum is only used internally within the crate in the reconstruction function to improve +// readability, so it's OK to not box the variant value, and it shouldn't impact memory much with +// the current usage, as it's deconstructed immediately. +#[allow(clippy::large_enum_variant)] +pub(crate) enum ReconstructColumnsDecision { + Yes(Vec>>), + No(&'static str), +} diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 4378da8405..8e9cc61208 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -2851,11 +2851,42 @@ where .await .expect("newPayload should succeed"); - // Store the envelope. + // Store the envelope and the data columns derived from the block. + // + // Production stores columns inside `import_available_execution_payload_envelope` after + // the cache is satisfied. The harness sidesteps that flow but must still persist columns + // or the `DataColumnMissing` invariant fires for any block with `num_expected_blobs > 0`. + let block = self + .chain + .store + .get_blinded_block(&block_root) + .expect("should read block from store") + .expect("block should exist in store"); + let mut ops = vec![]; + let block_with_full_payload = self + .chain + .store + .make_full_block(&block_root, block.clone()) + .expect("should reconstruct full block"); + let columns = + generate_data_column_sidecars_from_block(&block_with_full_payload, &self.spec); + if !columns.is_empty() + && let Some(store_op) = self.chain.get_blobs_or_columns_store_op( + block_root, + block.slot(), + AvailableBlockData::DataColumns(columns), + ) + { + ops.push(store_op); + } + ops.push(store::StoreOp::PutPayloadEnvelope( + block_root, + std::sync::Arc::new(signed_envelope), + )); self.chain .store - .put_payload_envelope(&block_root, &signed_envelope) - .expect("should store envelope"); + .do_atomically_with_block_and_blobs_cache(ops) + .expect("should persist envelope and columns"); // Update fork choice so it knows the payload was received. self.chain @@ -2876,11 +2907,10 @@ where block: Arc>, ) -> RangeSyncBlock { let block_root = block_root.unwrap_or_else(|| get_block_root(&block)); - let has_blobs = block - .message() - .body() - .blob_kzg_commitments() - .is_ok_and(|c| !c.is_empty()); + // For Gloas, kzg commitments live in the bid (`signed_execution_payload_bid`), so the + // body's `blob_kzg_commitments()` accessor returns Err. `num_expected_blobs` already + // handles both shapes. + let has_blobs = block.num_expected_blobs() > 0; if !has_blobs { return RangeSyncBlock::new( block, @@ -3782,7 +3812,26 @@ pub fn generate_rand_block_and_blobs( SignedBeaconBlock::Fulu(SignedBeaconBlockFulu { ref mut message, .. }) => add_blob_transactions!(message, FullPayloadFulu, num_blobs, u, fork_name), - // TODO(EIP-7732) Add `SignedBeaconBlock::Gloas` variant + SignedBeaconBlock::Gloas(SignedBeaconBlockGloas { + ref mut message, .. + }) => { + // For Gloas, commitments are in the bid, not directly in the body. + // BlobSidecars cannot be created for Gloas because there's no merkle proof + // from the block body to the commitments. Return early with empty blob_sidecars. + let num_blobs = match num_blobs { + NumBlobs::Random => u.int_in_range(DEFAULT_MIN_BLOBS..=DEFAULT_MAX_BLOBS)?, + NumBlobs::Number(n) => n, + NumBlobs::None => 0, + }; + let (bundle, _transactions) = + execution_layer::test_utils::generate_blobs::(num_blobs, fork_name).unwrap(); + message + .body + .signed_execution_payload_bid + .message + .blob_kzg_commitments = bundle.commitments.clone(); + return Ok((block, blob_sidecars)); + } _ => return Ok((block, blob_sidecars)), }; diff --git a/beacon_node/beacon_chain/tests/block_verification.rs b/beacon_node/beacon_chain/tests/block_verification.rs index 6646fe0b1e..533ef61219 100644 --- a/beacon_node/beacon_chain/tests/block_verification.rs +++ b/beacon_node/beacon_chain/tests/block_verification.rs @@ -323,18 +323,34 @@ fn update_data_column_signed_header( ) { for old_custody_column_sidecar in data_columns.as_mut_slice() { let old_column_sidecar = old_custody_column_sidecar.as_data_column(); - let new_column_sidecar = Arc::new(DataColumnSidecar::Fulu(DataColumnSidecarFulu { - index: *old_column_sidecar.index(), - column: old_column_sidecar.column().clone(), - kzg_commitments: old_column_sidecar.kzg_commitments().unwrap().clone(), - kzg_proofs: old_column_sidecar.kzg_proofs().clone(), - signed_block_header: signed_block.signed_block_header(), - kzg_commitments_inclusion_proof: signed_block - .message() - .body() - .kzg_commitments_merkle_proof() - .unwrap(), - })); + let new_column_sidecar = match old_column_sidecar.as_ref() { + DataColumnSidecar::Fulu(_) => { + Arc::new(DataColumnSidecar::Fulu(DataColumnSidecarFulu { + index: *old_column_sidecar.index(), + column: old_column_sidecar.column().clone(), + kzg_commitments: old_column_sidecar.kzg_commitments().unwrap().clone(), + kzg_proofs: old_column_sidecar.kzg_proofs().clone(), + signed_block_header: signed_block.signed_block_header(), + kzg_commitments_inclusion_proof: signed_block + .message() + .body() + .kzg_commitments_merkle_proof() + .unwrap(), + })) + } + // Gloas columns reference the block by `beacon_block_root` instead of holding the + // block header inline, so updating the parent root just means re-keying the column to + // the new canonical root. + DataColumnSidecar::Gloas(g) => { + Arc::new(DataColumnSidecar::Gloas(types::DataColumnSidecarGloas { + index: g.index, + column: g.column.clone(), + kzg_proofs: g.kzg_proofs.clone(), + slot: g.slot, + beacon_block_root: signed_block.canonical_root(), + })) + } + }; *old_custody_column_sidecar = CustodyDataColumn::from_asserted_custody(new_column_sidecar); } } @@ -1150,8 +1166,13 @@ async fn block_gossip_verification() { ) .await .expect("should import valid gossip verified block"); + if let Some(data_sidecars) = blobs_opt { + verify_and_process_gossip_data_sidecars(&harness, data_sidecars).await; + } // Post-Gloas, store the execution payload envelope so that subsequent blocks can look up - // the parent envelope. + // the parent envelope. This must run after gossip column processing because marking the + // payload as received in fork choice causes the gossip column path's + // `is_block_data_imported` gate to reject otherwise-valid columns as duplicates. if let Some(ref envelope) = snapshot.execution_envelope { harness .chain @@ -1165,9 +1186,6 @@ async fn block_gossip_verification() { .on_valid_payload_envelope_received(snapshot.beacon_block_root) .expect("should update fork choice with envelope"); } - if let Some(data_sidecars) = blobs_opt { - verify_and_process_gossip_data_sidecars(&harness, data_sidecars).await; - } } // Recompute the head to ensure we cache the latest view of fork choice. @@ -2246,7 +2264,6 @@ async fn rpc_block_allows_construction_past_da_boundary() { // Now verify the block is past the DA boundary let da_boundary = harness .chain - .data_availability_checker .data_availability_boundary() .expect("DA boundary should be set"); assert!( diff --git a/beacon_node/beacon_chain/tests/events.rs b/beacon_node/beacon_chain/tests/events.rs index cd0e700109..29d0e38b93 100644 --- a/beacon_node/beacon_chain/tests/events.rs +++ b/beacon_node/beacon_chain/tests/events.rs @@ -11,7 +11,8 @@ use std::sync::Arc; use types::data::FixedBlobSidecarList; use types::{ BlobSidecar, DataColumnSidecar, DataColumnSidecarFulu, DataColumnSidecarGloas, Domain, EthSpec, - MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, SignedRoot, Slot, + MinimalEthSpec, PayloadAttestationData, PayloadAttestationMessage, SignedExecutionPayloadBid, + SignedRoot, Slot, }; type E = MinimalEthSpec; @@ -84,6 +85,15 @@ async fn data_column_sidecar_event_on_process_gossip_data_column() { let epoch = slot.epoch(E::slots_per_epoch()); random_sidecar.slot = slot; random_sidecar.index = harness.chain.sampling_columns_for_epoch(epoch)[0]; + + // For gloas, the bid must be known, e.g. in the pending payload cache + let mut bid = SignedExecutionPayloadBid::::empty(); + bid.message.slot = Slot::new(10); + harness + .chain + .pending_payload_cache + .insert_bid(random_sidecar.beacon_block_root, Arc::new(bid)); + DataColumnSidecar::Gloas(random_sidecar) } else { let mut random_sidecar = DataColumnSidecarFulu::arbitrary(&mut u).unwrap(); diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index cfdd54857a..0ff9f6841d 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -23,6 +23,7 @@ use beacon_chain::{ }, custody_context::NodeCustodyType, historical_blocks::HistoricalBlockError, + kzg_utils::reconstruct_blobs, migrate::MigratorConfig, }; use bls::{Keypair, Signature, SignatureBytes}; @@ -68,6 +69,43 @@ static KEYPAIRS: LazyLock> = type E = MinimalEthSpec; type TestHarness = BeaconChainHarness>; +/// Retrieve or reconstruct blobs for a given block root. This uses the block's epoch to determine +/// whether to retrieve blobs directly or reconstruct them from columns. +/// +/// Returns `None` for Gloas blocks (which have no blob sidecar representation). +fn get_or_reconstruct_blobs( + chain: &BeaconChain, + block_root: &Hash256, +) -> Result>, BeaconChainError> { + let Some(block) = chain.store.get_blinded_block(block_root)? else { + return Ok(None); + }; + + if block.fork_name_unchecked().gloas_enabled() { + return Ok(None); + } + + if chain.spec.is_peer_das_enabled_for_epoch(block.epoch()) { + let fork_name = chain.spec.fork_name_at_epoch(block.epoch()); + if let Some(columns) = chain.store.get_data_columns(block_root, fork_name)? { + let num_required_columns = T::EthSpec::number_of_columns() / 2; + if columns.len() >= num_required_columns { + reconstruct_blobs(&chain.kzg, columns, None, &block, &chain.spec) + .map(Some) + .map_err(BeaconChainError::FailedToReconstructBlobs) + } else { + Err(BeaconChainError::InsufficientColumnsToReconstructBlobs { + columns_found: columns.len(), + }) + } + } else { + Ok(None) + } + } else { + Ok(chain.get_blobs(block_root)?.blobs()) + } +} + fn get_store(db_path: &TempDir) -> Arc, BeaconNodeBackend>> { let store_config = StoreConfig { prune_payloads: false, @@ -2835,10 +2873,7 @@ async fn reproduction_unaligned_checkpoint_sync_pruned_payload() { .is_ok() ); - let wss_blobs_opt = harness - .chain - .get_or_reconstruct_blobs(&wss_block_root) - .unwrap(); + let wss_blobs_opt = get_or_reconstruct_blobs(&harness.chain, &wss_block_root).unwrap(); let wss_state = full_store .get_state(&wss_state_root, Some(checkpoint_slot), CACHE_STATE_IN_TESTS) @@ -2971,10 +3006,7 @@ async fn weak_subjectivity_sync_test( .state_root_at_slot(checkpoint_slot) .unwrap() .unwrap(); - let wss_blobs_opt = harness - .chain - .get_or_reconstruct_blobs(&wss_block_root) - .unwrap(); + let wss_blobs_opt = get_or_reconstruct_blobs(&harness.chain, &wss_block_root).unwrap(); let wss_state = full_store .get_state(&wss_state_root, Some(checkpoint_slot), CACHE_STATE_IN_TESTS) .unwrap() @@ -3063,6 +3095,29 @@ async fn weak_subjectivity_sync_test( let beacon_chain = Arc::new(beacon_chain); let wss_block_root = wss_block.canonical_root(); + + // For Gloas, blobs aren't a standalone shape — the WSS data is the column sidecar set, which + // `get_or_reconstruct_blobs` returns `None` for. Copy the WSS block's columns straight from + // the source store so that the destination has them after checkpoint sync, matching what + // network-driven WSS would produce in production. + if wss_block.fork_name_unchecked().gloas_enabled() + && let Ok(Some(source_columns)) = harness + .chain + .store + .get_data_columns(&wss_block_root, ForkName::Gloas) + && !source_columns.is_empty() + && let Some(store_op) = beacon_chain.get_blobs_or_columns_store_op( + wss_block_root, + wss_block.slot(), + beacon_chain::block_verification_types::AvailableBlockData::DataColumns(source_columns), + ) + { + beacon_chain + .store + .do_atomically_with_block_and_blobs_cache(vec![store_op]) + .unwrap(); + } + let store_wss_block = harness .chain .get_block(&wss_block_root) @@ -3070,9 +3125,7 @@ async fn weak_subjectivity_sync_test( .unwrap() .unwrap(); // This test may break in the future if we no longer store the full checkpoint data columns. - let store_wss_blobs_opt = beacon_chain - .get_or_reconstruct_blobs(&wss_block_root) - .unwrap(); + let store_wss_blobs_opt = get_or_reconstruct_blobs(&beacon_chain, &wss_block_root).unwrap(); assert_eq!(store_wss_block, wss_block); // TODO(fulu): Remove this condition once #6760 (PeerDAS checkpoint sync) is merged. @@ -3130,12 +3183,43 @@ async fn weak_subjectivity_sync_test( .await .unwrap(); - // Store the envelope and apply it to fork choice. + // Store the envelope, its columns, and apply to fork choice. if let Some(envelope) = &snapshot.execution_envelope { + // Persist data columns for Gloas blocks. This mirrors what production does in + // `import_available_execution_payload_envelope` and what the harness now does in + // `process_envelope` — the WSS forward-sync loop bypasses both, so do it directly. + let mut ops = vec![]; + let columns_block = beacon_chain + .store + .get_blinded_block(&block_root) + .unwrap() + .and_then(|b| beacon_chain.store.make_full_block(&block_root, b).ok()); + if let Some(full_block) = columns_block { + let columns = beacon_chain::test_utils::generate_data_column_sidecars_from_block( + &full_block, + &beacon_chain.spec, + ); + if !columns.is_empty() + && let Some(store_op) = beacon_chain.get_blobs_or_columns_store_op( + block_root, + full_block.slot(), + beacon_chain::block_verification_types::AvailableBlockData::DataColumns( + columns, + ), + ) + { + ops.push(store_op); + } + } + ops.push(store::StoreOp::PutPayloadEnvelope( + block_root, + std::sync::Arc::new(envelope.as_ref().clone()), + )); beacon_chain .store - .put_payload_envelope(&block_root, envelope) + .do_atomically_with_block_and_blobs_cache(ops) .unwrap(); + // Update fork choice so head selection accounts for Full payload status. beacon_chain .canonical_head diff --git a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs index 65e1a83840..2e7fe693d6 100644 --- a/beacon_node/http_api/src/beacon/execution_payload_envelope.rs +++ b/beacon_node/http_api/src/beacon/execution_payload_envelope.rs @@ -7,6 +7,7 @@ use crate::version::{ execution_optimistic_finalized_beacon_response, }; use beacon_chain::data_column_verification::{GossipDataColumnError, GossipVerifiedDataColumn}; +use beacon_chain::payload_envelope_verification::EnvelopeError; use beacon_chain::{BeaconChain, BeaconChainTypes, NotifyExecutionLayer}; use bytes::Bytes; use eth2::types as api_types; @@ -148,7 +149,7 @@ pub async fn publish_execution_payload_envelope( PubsubMessage::ExecutionPayload(Box::new(envelope_for_gossip)), ) .map_err(|_| { - beacon_chain::payload_envelope_verification::EnvelopeError::BeaconChainError(Arc::new( + EnvelopeError::BeaconChainError(Arc::new( beacon_chain::BeaconChainError::UnableToPublish, )) }) @@ -272,7 +273,7 @@ fn build_gloas_data_columns( let index = *col.index(); match GossipVerifiedDataColumn::new_for_block_publishing(col, chain) { Ok(verified) => Some(verified), - Err(GossipDataColumnError::PriorKnownUnpublished) => None, + Err(GossipDataColumnError::PriorKnown { .. }) => None, Err(e) => { warn!( %slot, diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index 644ade956a..e96c86b17f 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -246,7 +246,7 @@ pub async fn publish_block>( if let Err(e) = Box::pin(chain.process_gossip_data_columns(sampling_columns, publish_fn)).await { - let msg = format!("Invalid data column: {e}"); + let msg = format!("Invalid data column: {e:?}"); return if let BroadcastValidation::Gossip = validation_level { Err(warp_utils::reject::broadcast_without_import(msg)) } else { diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index 184bfffc9a..b47f8e946a 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -382,6 +382,7 @@ pub async fn proposer_boost_re_org_weight_misprediction() { /// - `num_empty_votes`: percentage of comm of attestations for the parent block /// - `num_head_votes`: number of attestations for the head block /// - `should_re_org`: whether the proposer should build on the parent rather than the head +#[allow(clippy::large_stack_frames)] pub async fn proposer_boost_re_org_test( ReOrgTest { head_slot, diff --git a/beacon_node/network/src/metrics.rs b/beacon_node/network/src/metrics.rs index b09dc95db4..4b34d7bfc0 100644 --- a/beacon_node/network/src/metrics.rs +++ b/beacon_node/network/src/metrics.rs @@ -1,5 +1,5 @@ use beacon_chain::{ - AvailabilityProcessingStatus, BlockError, attestation_verification::Error as AttnError, + AvailabilityProcessingStatus, attestation_verification::Error as AttnError, light_client_finality_update_verification::Error as LightClientFinalityUpdateError, light_client_optimistic_update_verification::Error as LightClientOptimisticUpdateError, sync_committee_verification::Error as SyncCommitteeError, @@ -733,7 +733,7 @@ pub fn register_sync_committee_error(error: &SyncCommitteeError) { } pub(crate) fn register_process_result_metrics( - result: &std::result::Result, + result: &std::result::Result>, source: BlockSource, block_component: &'static str, ) { diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 57871a2009..d34668b138 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -698,15 +698,6 @@ impl NetworkBeaconProcessor { } Err(err) => { match err { - GossipDataColumnError::InvalidVariant => { - // TODO(gloas) we should probably penalize the peer here - debug!( - %slot, - %block_root, - %index, - "Invalid gossip data column variant." - ) - } GossipDataColumnError::PriorKnownUnpublished => { debug!( %slot, @@ -732,7 +723,27 @@ impl NetworkBeaconProcessor { column_sidecar, )); } - GossipDataColumnError::PubkeyCacheTimeout + GossipDataColumnError::BlockRootUnknown { + block_root: unknown_block_root, + .. + } => { + debug!( + action = "ignoring", + %unknown_block_root, + "Unknown block root for column" + ); + // TODO(gloas): wire this into proper lookup sync. Sending + // `UnknownBlockHashFromAttestation` here is a Fulu-shaped fallback that + // mixes column processing with the attestation lookup path and is not + // the right primitive for Gloas column lookups. + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Ignore, + ); + } + GossipDataColumnError::InvalidVariant + | GossipDataColumnError::PubkeyCacheTimeout | GossipDataColumnError::BeaconChainError(_) => { crit!( error = ?err, @@ -743,6 +754,7 @@ impl NetworkBeaconProcessor { | GossipDataColumnError::UnknownValidator(_) | GossipDataColumnError::ProposerIndexMismatch { .. } | GossipDataColumnError::IsNotLaterThanParent { .. } + | GossipDataColumnError::BlockSlotMismatch { .. } | GossipDataColumnError::InvalidSubnetId { .. } | GossipDataColumnError::InvalidInclusionProof | GossipDataColumnError::InvalidKzgProof { .. } @@ -803,6 +815,19 @@ impl NetworkBeaconProcessor { MessageAcceptance::Ignore, ); } + GossipDataColumnError::InternalError(err) => { + error!( + error = ?err, + %block_root, + %index, + "Internal error while processing data columns" + ); + self.propagate_validation_result( + message_id, + peer_id, + MessageAcceptance::Ignore, + ); + } } } } @@ -904,14 +929,6 @@ impl NetworkBeaconProcessor { ) { match err { GossipPartialDataColumnError::GossipDataColumnError(err) => match err { - GossipDataColumnError::InvalidVariant => { - // TODO(gloas) we should probably penalize the peer here - debug!( - %block_root, - %index, - "Invalid gossip partial data column variant." - ) - } GossipDataColumnError::PriorKnownUnpublished => { debug!( %block_root, @@ -933,6 +950,24 @@ impl NetworkBeaconProcessor { slot, }); } + GossipDataColumnError::BlockRootUnknown { + block_root: unknown_block_root, + .. + } => { + debug!( + action = "requesting block", + %unknown_block_root, + "Unknown block root for partial column" + ); + // TODO(gloas): wire this into proper lookup sync. Sending + // `UnknownBlockHashFromAttestation` here is a Fulu-shaped fallback that + // mixes column processing with the attestation lookup path and is not + // the right primitive for Gloas column lookups. + self.send_sync_message(SyncMessage::UnknownBlockHashFromAttestation( + peer_id, + unknown_block_root, + )); + } GossipDataColumnError::PubkeyCacheTimeout | GossipDataColumnError::BeaconChainError(_) => { crit!( @@ -940,10 +975,12 @@ impl NetworkBeaconProcessor { "Internal error when verifying partial column sidecar" ) } - GossipDataColumnError::ProposalSignatureInvalid + GossipDataColumnError::InvalidVariant + | GossipDataColumnError::ProposalSignatureInvalid | GossipDataColumnError::UnknownValidator(_) | GossipDataColumnError::ProposerIndexMismatch { .. } | GossipDataColumnError::IsNotLaterThanParent { .. } + | GossipDataColumnError::BlockSlotMismatch { .. } | GossipDataColumnError::InvalidSubnetId { .. } | GossipDataColumnError::InvalidInclusionProof | GossipDataColumnError::InvalidKzgProof { .. } @@ -993,6 +1030,14 @@ impl NetworkBeaconProcessor { "gossip_partial_data_column_high", ); } + GossipDataColumnError::InternalError(err) => { + error!( + error = ?err, + %block_root, + %index, + "Internal error while handling partial data column verification" + ); + } }, GossipPartialDataColumnError::MissingHeader => { metrics::inc_counter( @@ -1052,7 +1097,7 @@ impl NetworkBeaconProcessor { "gossip_partial_data_column_low", ); } - GossipPartialDataColumnError::InternalError(_) => { + GossipPartialDataColumnError::InternalError(err) => { error!( error = ?err, %block_root, @@ -1323,6 +1368,7 @@ impl NetworkBeaconProcessor { let data_column_slot = verified_data_column.slot(); let data_column_index = verified_data_column.index(); + // TODO(gloas): implement partial messages if let DataColumnSidecar::Fulu(col) = verified_data_column.as_data_column() && self .chain @@ -1353,7 +1399,7 @@ impl NetworkBeaconProcessor { .await; register_process_result_metrics(&result, metrics::BlockSource::Gossip, "data_column"); - match &result { + match result { Ok(availability) => match availability { AvailabilityProcessingStatus::Imported(block_root) => { debug!( @@ -1366,6 +1412,14 @@ impl NetworkBeaconProcessor { &metrics::BEACON_BLOB_DELAY_FULL_VERIFICATION, processing_start_time.elapsed().as_millis() as i64, ); + + // If a block is in the da_checker, sync maybe awaiting for an event when block is finally + // imported. A block can become imported both after processing a block or data column. If + // importing a block results in `Imported`, notify. Do not notify of data column errors. + self.send_sync_message(SyncMessage::GossipBlockProcessResult { + block_root, + imported: true, + }); } AvailabilityProcessingStatus::MissingComponents(slot, block_root) => { trace!( @@ -1375,7 +1429,7 @@ impl NetworkBeaconProcessor { "Processed data column, waiting for other components" ); - self.check_reconstruction_trigger(*slot, block_root).await; + self.check_reconstruction_trigger(slot, &block_root).await; } }, Err(BlockError::DuplicateFullyImported(_)) => { @@ -1399,16 +1453,6 @@ impl NetworkBeaconProcessor { ); } } - - // If a block is in the da_checker, sync maybe awaiting for an event when block is finally - // imported. A block can become imported both after processing a block or data column. If a - // importing a block results in `Imported`, notify. Do not notify of data column errors. - if matches!(result, Ok(AvailabilityProcessingStatus::Imported(_))) { - self.send_sync_message(SyncMessage::GossipBlockProcessResult { - block_root, - imported: true, - }); - } } /// Process a gossip-verified partial data column by merging it in the assembler @@ -1575,7 +1619,7 @@ impl NetworkBeaconProcessor { slot, process_fn: Box::pin(async move { cloned_self - .attempt_data_column_reconstruction(block_root) + .attempt_data_column_reconstruction(slot, block_root) .await; }), }, @@ -1827,7 +1871,10 @@ impl NetworkBeaconProcessor { return None; } // BlobNotRequired is unreachable. Only constructed in `process_gossip_blob` - Err(e @ BlockError::InternalError(_)) | Err(e @ BlockError::BlobNotRequired(_)) => { + Err(e @ BlockError::InternalError(_)) + | Err(e @ BlockError::BlobNotRequired(_)) + | Err(e @ BlockError::EnvelopeBlockRootUnknown(_)) + | Err(e @ BlockError::OptimisticSyncNotSupported { .. }) => { error!(error = %e, "Internal block gossip validation error"); return None; } @@ -3814,8 +3861,7 @@ impl NetworkBeaconProcessor { | EnvelopeError::UnknownValidator { .. } | EnvelopeError::IncorrectBlockProposer { .. } | EnvelopeError::ExecutionPayloadError(_) - | EnvelopeError::EnvelopeProcessingError(_) - | EnvelopeError::BlockError(_) => { + | EnvelopeError::EnvelopeProcessingError(_) => { self.propagate_validation_result( message_id, peer_id, @@ -3895,11 +3941,9 @@ impl NetworkBeaconProcessor { } EnvelopeError::PriorToFinalization { .. } - | EnvelopeError::OptimisticSyncNotSupported { .. } | EnvelopeError::BeaconChainError(_) | EnvelopeError::BeaconStateError(_) - | EnvelopeError::BlockProcessingError(_) - | EnvelopeError::InternalError(_) => { + | EnvelopeError::ImportError(_) => { self.propagate_validation_result( message_id, peer_id, diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index 6a3ccbcd65..7bf969db10 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -1014,6 +1014,7 @@ impl NetworkBeaconProcessor { } // Publish partial columns without eager send + // TODO(gloas): implement publish partial columns without eager send if let Some(assembler) = self.chain.data_availability_checker.partial_assembler() { let columns = assembler.get_partials_and_mark_as_local_fetched(block_root, &header); if !columns.is_empty() { @@ -1034,8 +1035,8 @@ impl NetworkBeaconProcessor { /// Attempts to reconstruct all data columns if the conditions checked in /// [`DataAvailabilityCheckerInner::check_and_set_reconstruction_started`] are satisfied. #[instrument(level = "debug", skip_all, fields(?block_root))] - async fn attempt_data_column_reconstruction(self: &Arc, block_root: Hash256) { - let result = self.chain.reconstruct_data_columns(block_root).await; + async fn attempt_data_column_reconstruction(self: &Arc, slot: Slot, block_root: Hash256) { + let result = self.chain.reconstruct_data_columns(slot, block_root).await; match result { Ok(Some((availability_processing_status, data_columns_to_publish))) => { 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 8f89b66948..988a68c9dd 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -731,6 +731,8 @@ impl NetworkBeaconProcessor { .map(|block| block.into_available_block()) .collect::>(); + // TODO(gloas) when implementing backfill sync for gloas + // we need a batch verify kzg function in the new da checker match self .chain .data_availability_checker diff --git a/beacon_node/network/src/network_beacon_processor/tests.rs b/beacon_node/network/src/network_beacon_processor/tests.rs index f13815f7b6..18d34b40b3 100644 --- a/beacon_node/network/src/network_beacon_processor/tests.rs +++ b/beacon_node/network/src/network_beacon_processor/tests.rs @@ -10,7 +10,7 @@ use crate::{ }; use beacon_chain::block_verification_types::LookupBlock; use beacon_chain::custody_context::NodeCustodyType; -use beacon_chain::data_column_verification::validate_data_column_sidecar_for_gossip_fulu; +use beacon_chain::data_column_verification::GossipVerifiedDataColumn; use beacon_chain::kzg_utils::blobs_to_data_column_sidecars; use beacon_chain::observed_data_sidecars::DoNotObserve; use beacon_chain::test_utils::{ @@ -1195,12 +1195,8 @@ async fn accept_processed_gossip_data_columns_without_import() { .map(|data_column| { let subnet_id = DataColumnSubnetId::from_column_index(*data_column.index(), &rig.chain.spec); - validate_data_column_sidecar_for_gossip_fulu::<_, DoNotObserve>( - data_column, - subnet_id, - &rig.chain, - ) - .expect("should be valid data column") + GossipVerifiedDataColumn::<_, DoNotObserve>::new(data_column, subnet_id, &rig.chain) + .expect("should be valid data column") }) .collect(); diff --git a/beacon_node/network/src/sync/block_sidecar_coupling.rs b/beacon_node/network/src/sync/block_sidecar_coupling.rs index f5c0fdb4e5..bb43396473 100644 --- a/beacon_node/network/src/sync/block_sidecar_coupling.rs +++ b/beacon_node/network/src/sync/block_sidecar_coupling.rs @@ -548,13 +548,19 @@ mod tests { #[test] fn no_blobs_into_responses() { + let spec = Arc::new(test_spec::()); + let mut u = types::test_utils::test_unstructured(); let blocks = (0..4) .map(|_| { - generate_rand_block_and_blobs::(ForkName::Base, NumBlobs::None, &mut u) - .unwrap() - .0 - .into() + generate_rand_block_and_blobs::( + spec.fork_name_at_epoch(Epoch::new(0)), + NumBlobs::None, + &mut u, + ) + .unwrap() + .0 + .into() }) .collect::>>>(); @@ -565,7 +571,6 @@ mod tests { // Send blocks and complete terminate response info.add_blocks(blocks_req_id, blocks).unwrap(); - let spec = Arc::new(test_spec::()); let da_checker = Arc::new(test_da_checker(spec.clone(), NodeCustodyType::Fullnode)); // Assert response is finished and RpcBlocks can be constructed diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 734295ac1d..347b018a93 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -181,7 +181,9 @@ pub enum SyncMessage { result: BlockProcessingResult, }, - /// A block from gossip has completed processing, + /// A gossip-received component has completed processing and the block may now be imported. + /// In Fulu this is sent after block or blob processing. In Gloas this is also sent after + /// data column or payload envelope processing triggers availability. GossipBlockProcessResult { block_root: Hash256, imported: bool }, } @@ -905,9 +907,13 @@ impl SyncManager { }), ); } - // TODO(gloas) support gloas data column variant DataColumnSidecar::Gloas(_) => { - error!("Gloas variant not yet supported") + // TODO(gloas): proper lookup sync for Gloas. Routing into + // `handle_unknown_block_root` here mixes column processing with the + // single-block-lookup path; the Gloas column-arrives-before-block + // case wants its own queue/wakeup. + debug!(%block_root, "Received unknown block data column message"); + self.handle_unknown_block_root(peer_id, block_root); } } } diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index b1ba87c75d..465e23998b 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -1085,10 +1085,22 @@ impl SyncNetworkContext { block_root: Hash256, lookup_peers: Arc>>, ) -> Result { + let slot = self + .chain + .canonical_head + .fork_choice_read_lock() + .get_block(&block_root) + .map(|block| block.slot) + .or_else(|| self.chain.slot().ok()) + .ok_or_else(|| { + RpcRequestSendError::InternalError(format!( + "Unable to determine slot for block {block_root:?}" + )) + })?; + let custody_indexes_imported = self .chain - .data_availability_checker - .cached_data_column_indexes(&block_root) + .cached_data_column_indexes(&block_root, slot) .unwrap_or_default(); let current_epoch = self.chain.epoch().map_err(|e| { diff --git a/beacon_node/network/src/sync/tests/lookups.rs b/beacon_node/network/src/sync/tests/lookups.rs index d27c92c21a..c1b2793491 100644 --- a/beacon_node/network/src/sync/tests/lookups.rs +++ b/beacon_node/network/src/sync/tests/lookups.rs @@ -2087,8 +2087,7 @@ async fn too_many_processing_failures(depth: usize) { r.build_chain_and_trigger_last_block(depth).await; // Simulate that a peer always returns empty r.simulate( - SimulateConfig::new() - .with_process_result(|| BlockProcessingResult::Err(BlockError::BlockSlotLimitReached)), + SimulateConfig::new().with_process_result(|| BlockError::BlockSlotLimitReached.into()), ) .await; // We register multiple penalties, the lookup fails and sync does not progress @@ -2156,9 +2155,10 @@ async fn test_single_block_lookup_duplicate_response() { let mut r = TestRig::default(); r.build_chain_and_trigger_last_block(1).await; // Send a DuplicateFullyImported response, the lookup should complete successfully - r.simulate(SimulateConfig::new().with_process_result(|| { - BlockProcessingResult::Err(BlockError::DuplicateFullyImported(Hash256::ZERO)) - })) + r.simulate( + SimulateConfig::new() + .with_process_result(|| BlockError::DuplicateFullyImported(Hash256::ZERO).into()), + ) .await; // The block was not actually imported r.assert_head_slot(0); diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index 593aa27915..a60859585c 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -1514,6 +1514,14 @@ where } } + /// Returns whether the execution payload for a block has been received. + /// + /// Returns `false` for unknown blocks and pre-Gloas nodes. + pub fn is_payload_received(&self, block_root: &Hash256) -> bool { + self.proto_array.is_payload_received(block_root) + && self.is_finalized_checkpoint_or_descendant(*block_root) + } + /// Returns whether the proposer should extend the execution payload chain of the given block. pub fn should_extend_payload(&self, block_root: &Hash256) -> Result> { let proposer_boost_root = self.fc_store.proposer_boost_root(); diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index 76bb9a09db..11ac17dece 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -351,6 +351,12 @@ impl> SignedBeaconBlock self.message() .body() .blob_kzg_commitments() + .or_else(|_| { + self.message() + .body() + .signed_execution_payload_bid() + .map(|bid| &bid.message.blob_kzg_commitments) + }) .map(|c| c.len()) .unwrap_or(0) } diff --git a/consensus/types/src/execution/signed_execution_payload_bid.rs b/consensus/types/src/execution/signed_execution_payload_bid.rs index 3d4f45a267..2ad6dcea1a 100644 --- a/consensus/types/src/execution/signed_execution_payload_bid.rs +++ b/consensus/types/src/execution/signed_execution_payload_bid.rs @@ -23,6 +23,14 @@ pub struct SignedExecutionPayloadBid { } impl SignedExecutionPayloadBid { + pub fn epoch(&self) -> crate::Epoch { + self.message.slot.epoch(E::slots_per_epoch()) + } + + pub fn slot(&self) -> crate::Slot { + self.message.slot + } + pub fn empty() -> Self { Self { message: ExecutionPayloadBid::default(), From fd0852a8e59be905824cebfb4f2fe028a920159b Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Tue, 19 May 2026 11:35:31 +1000 Subject: [PATCH 174/189] Remove outdated SPRP hint (#9312) While working on this code in another branch I noticed we had this messy, complicated and incorrect code about SPRP (slots-per-restore-point), which is no longer a relevant concept since the introduction of hot state diffs. In the name of simplicity, I've removed any kind of hinting here in favour of a simple out of bounds error. The benefit of adding complex hinting code (which is not tested) to such a function is not worth it IMO. Users will work it out (or ask) if we just tell them their request is out of bounds. Co-Authored-By: Michael Sproul --- beacon_node/http_api/src/beacon/states.rs | 74 +++++++++-------------- 1 file changed, 27 insertions(+), 47 deletions(-) diff --git a/beacon_node/http_api/src/beacon/states.rs b/beacon_node/http_api/src/beacon/states.rs index 84ef3c1f26..52b05a807b 100644 --- a/beacon_node/http_api/src/beacon/states.rs +++ b/beacon_node/http_api/src/beacon/states.rs @@ -390,54 +390,34 @@ pub fn get_beacon_state_committees( if let Some(shuffling) = maybe_cached_shuffling { shuffling } else { - let possibly_built_cache = - match RelativeEpoch::from_epoch(current_epoch, epoch) { - Ok(relative_epoch) - if state.committee_cache_is_initialized( - relative_epoch, - ) => - { - state.committee_cache(relative_epoch).cloned() - } - _ => CommitteeCache::initialized( - state, - epoch, - &chain.spec, - ), + let possibly_built_cache = match RelativeEpoch::from_epoch( + current_epoch, + epoch, + ) { + Ok(relative_epoch) + if state.committee_cache_is_initialized( + relative_epoch, + ) => + { + state.committee_cache(relative_epoch).cloned() } - .map_err( - |e| match e { - BeaconStateError::EpochOutOfBounds => { - let max_sprp = - T::EthSpec::slots_per_historical_root() - as u64; - let first_subsequent_restore_point_slot = - ((epoch.start_slot( - T::EthSpec::slots_per_epoch(), - ) / max_sprp) - + 1) - * max_sprp; - if epoch < current_epoch { - warp_utils::reject::custom_bad_request( - format!( - "epoch out of bounds, \ - try state at slot {}", - first_subsequent_restore_point_slot, - ), - ) - } else { - warp_utils::reject::custom_bad_request( - "epoch out of bounds, \ - too far in future" - .into(), - ) - } - } - _ => warp_utils::reject::unhandled_error( - BeaconChainError::from(e), - ), - }, - )?; + _ => CommitteeCache::initialized( + state, + epoch, + &chain.spec, + ), + } + .map_err(|e| match e { + BeaconStateError::EpochOutOfBounds => { + warp_utils::reject::custom_bad_request(format!( + "epoch {} out of bounds for state at {}", + epoch, current_epoch + )) + } + _ => warp_utils::reject::unhandled_error( + BeaconChainError::from(e), + ), + })?; // Attempt to write to the beacon cache (only if the cache // size is not the default value). From 398efc3acca5c8d01befbbe09d35d24cbd04752c Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Mon, 18 May 2026 23:12:17 -0600 Subject: [PATCH 175/189] Use dedicated cache for HTTP API route (#9318) - PR https://github.com/sigp/lighthouse/pull/9305 wants to store PTCs in the committee cache. BUT the http API route wants to use the committee cache and insert historical committees (i.e. given state at epoch 1000, compute and store the committee for epoch 900). If we want a single cache to serve both use cases we need to: - Have entries in the committee cache that have no PTC: Makes reading PTCs from the cache not deterministic - Compute historical PTC: A bunch of complicated code that's useless Instead we can add a separate cache for the API, very simple one, that caches committees only. And have the one in the beacon chain compute and cache PTCs always. ### Performance impact Slightly additional memory cost for users of the `beacon/states/committees` route. Caching is almost equivalent, except for queries of recent committees that may already exist in the beacon chain's committee cache. ### AI disclousure This PR was written by hand 90%. Claude fixed some warp type issues Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/client/src/builder.rs | 5 ++ beacon_node/http_api/src/beacon/states.rs | 81 ++++++++++++----------- beacon_node/http_api/src/caches.rs | 43 ++++++++++++ beacon_node/http_api/src/lib.rs | 17 ++++- beacon_node/http_api/src/test_utils.rs | 7 +- beacon_node/src/config.rs | 3 + 6 files changed, 115 insertions(+), 41 deletions(-) create mode 100644 beacon_node/http_api/src/caches.rs diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index 9dfb8304bc..f532ef716e 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -36,6 +36,7 @@ use rand::SeedableRng; use rand::rngs::{OsRng, StdRng}; use slasher::Slasher; use slasher_service::SlasherService; +use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -639,6 +640,10 @@ where network_globals: self.network_globals.clone(), beacon_processor_send: Some(beacon_processor_channels.beacon_processor_tx.clone()), sse_logging_components: runtime_context.sse_logging_components.clone(), + historical_committee_cache: Arc::new(http_api::HistoricalCommitteeCache::new( + NonZeroUsize::new(self.http_api_config.historical_committee_cache_size) + .unwrap_or(NonZeroUsize::MIN), + )), }); let exit = runtime_context.executor.exit(); diff --git a/beacon_node/http_api/src/beacon/states.rs b/beacon_node/http_api/src/beacon/states.rs index 52b05a807b..1b765aa227 100644 --- a/beacon_node/http_api/src/beacon/states.rs +++ b/beacon_node/http_api/src/beacon/states.rs @@ -1,4 +1,5 @@ use crate::StateId; +use crate::caches::{HistoricalCommitteeCache, HistoricalShufflingId}; use crate::task_spawner::{Priority, TaskSpawner}; use crate::utils::ResponseFilter; use crate::validator::pubkey_to_validator_index; @@ -13,7 +14,10 @@ use eth2::types::{ }; use ssz::Encode; use std::sync::Arc; -use types::{AttestationShufflingId, BeaconStateError, CommitteeCache, EthSpec, RelativeEpoch}; +use types::{ + AttestationShufflingId, BeaconStateError, CommitteeCache, EthSpec, RelativeEpoch, + RelativeEpochError, +}; use warp::filters::BoxedFilter; use warp::http::Response; use warp::hyper::Body; @@ -26,6 +30,8 @@ type BeaconStatesPath = BoxedFilter<( Arc>, )>; +type BeaconStatesCommitteesFilter = BoxedFilter<(Arc,)>; + // GET beacon/states/{state_id}/pending_consolidations pub fn get_beacon_state_pending_consolidations( beacon_states_path: BeaconStatesPath, @@ -337,17 +343,20 @@ pub fn get_beacon_state_sync_committees( // GET beacon/states/{state_id}/committees?slot,index,epoch pub fn get_beacon_state_committees( beacon_states_path: BeaconStatesPath, + beacon_states_committees_filter: BeaconStatesCommitteesFilter, ) -> ResponseFilter { beacon_states_path .clone() .and(warp::path("committees")) .and(warp::query::()) + .and(beacon_states_committees_filter) .and(warp::path::end()) .then( |state_id: StateId, task_spawner: TaskSpawner, chain: Arc>, - query: eth2::types::CommitteesQuery| { + query: eth2::types::CommitteesQuery, + historical_committee_cache: Arc| { task_spawner.blocking_json_task(Priority::P1, move || { let (data, execution_optimistic, finalized) = state_id .map_state_and_execution_optimistic_and_finalized( @@ -364,33 +373,33 @@ pub fn get_beacon_state_committees( let shuffling_id = if let Ok(Some(shuffling_decision_block)) = chain.block_root_at_slot(decision_slot, WhenSlotSkipped::Prev) { - Some(AttestationShufflingId { - shuffling_epoch: epoch, - shuffling_decision_block, - }) + Some(HistoricalShufflingId::ShufflingId( + AttestationShufflingId { + shuffling_epoch: epoch, + shuffling_decision_block, + }, + )) + } else if epoch < chain.head().finalized_checkpoint().epoch { + // Use the case for finalized epochs + Some(HistoricalShufflingId::FinalizedEpoch(epoch)) } else { None }; // Attempt to read from the chain cache if there exists a // shuffling_id - let maybe_cached_shuffling = if let Some(shuffling_id) = - shuffling_id.as_ref() - { - chain - .shuffling_cache - .try_write_for(std::time::Duration::from_secs(1)) - .and_then(|mut cache_write| cache_write.get(shuffling_id)) - .and_then(|cache_item| cache_item.wait().ok()) - } else { - None - }; + let maybe_cached_shuffling = + if let Some(shuffling_id) = shuffling_id.as_ref() { + historical_committee_cache.get(shuffling_id) + } else { + None + }; let committee_cache = if let Some(shuffling) = maybe_cached_shuffling { shuffling } else { - let possibly_built_cache = match RelativeEpoch::from_epoch( + let committee_cache = match RelativeEpoch::from_epoch( current_epoch, epoch, ) { @@ -401,11 +410,19 @@ pub fn get_beacon_state_committees( { state.committee_cache(relative_epoch).cloned() } - _ => CommitteeCache::initialized( - state, - epoch, - &chain.spec, - ), + Ok(_) | Err(RelativeEpochError::EpochTooLow { .. }) => { + CommitteeCache::initialized( + state, + epoch, + &chain.spec, + ) + } + Err(RelativeEpochError::EpochTooHigh { .. }) => { + Err(BeaconStateError::EpochOutOfBounds) + } + Err(RelativeEpochError::ArithError(e)) => { + Err(BeaconStateError::ArithError(e)) + } } .map_err(|e| match e { BeaconStateError::EpochOutOfBounds => { @@ -419,22 +436,12 @@ pub fn get_beacon_state_committees( ), })?; - // Attempt to write to the beacon cache (only if the cache - // size is not the default value). - if chain.config.shuffling_cache_size - != beacon_chain::shuffling_cache::DEFAULT_CACHE_SIZE - && let Some(shuffling_id) = shuffling_id - && let Some(mut cache_write) = chain - .shuffling_cache - .try_write_for(std::time::Duration::from_secs(1)) - { - cache_write.insert_committee_cache( - shuffling_id, - &possibly_built_cache, - ); + if let Some(shuffling_id) = shuffling_id { + historical_committee_cache + .insert(shuffling_id, committee_cache.clone()); } - possibly_built_cache + committee_cache }; // Use either the supplied slot or all slots in the epoch. diff --git a/beacon_node/http_api/src/caches.rs b/beacon_node/http_api/src/caches.rs new file mode 100644 index 0000000000..d92571594a --- /dev/null +++ b/beacon_node/http_api/src/caches.rs @@ -0,0 +1,43 @@ +use lru::LruCache; +use parking_lot::Mutex; +use std::num::NonZeroUsize; +use std::sync::Arc; +use types::{AttestationShufflingId, CommitteeCache, Epoch}; + +/// See `shuffling_cache::DEFAULT_CACHE_SIZE` for rationale +pub const DEFAULT_HISTORICAL_COMMITTEE_CACHE_SIZE: usize = 16; + +/// Indexes the `HistoricalCommitteeCache`. We can compute committees for very old epochs, and we +/// can't retrieve the decision root cheaply from a state. For those cases we allow the cache to +/// key those committees by finalized epoch. +#[derive(Eq, Hash, PartialEq)] +pub enum HistoricalShufflingId { + FinalizedEpoch(Epoch), + ShufflingId(AttestationShufflingId), +} + +/// Dedicated cache for attestation committees, used exclusively by the HTTP API. +/// +/// This may contain committees for finalized and unfinalized epochs. The name is slightly +/// missleading :) +pub struct HistoricalCommitteeCache { + committees: Mutex>>, +} + +impl HistoricalCommitteeCache { + pub fn new(size: NonZeroUsize) -> Self { + Self { + committees: Mutex::new(LruCache::new(size)), + } + } +} + +impl HistoricalCommitteeCache { + pub fn get(&self, id: &HistoricalShufflingId) -> Option> { + self.committees.lock().get(id).cloned() + } + + pub fn insert(&self, id: HistoricalShufflingId, cache: Arc) { + self.committees.lock().put(id, cache); + } +} diff --git a/beacon_node/http_api/src/lib.rs b/beacon_node/http_api/src/lib.rs index f31817c5ba..74bf1ccd76 100644 --- a/beacon_node/http_api/src/lib.rs +++ b/beacon_node/http_api/src/lib.rs @@ -12,6 +12,7 @@ mod beacon; mod block_id; mod build_block_contents; mod builder_states; +mod caches; mod custody; mod database; mod light_client; @@ -40,6 +41,8 @@ use crate::beacon::execution_payload_envelope::{ post_beacon_execution_payload_envelope_ssz, }; use crate::beacon::pool::*; +use crate::caches::DEFAULT_HISTORICAL_COMMITTEE_CACHE_SIZE; +pub use crate::caches::HistoricalCommitteeCache; use crate::light_client::{get_light_client_bootstrap, get_light_client_updates}; use crate::utils::{AnyVersionFilter, EthV1Filter}; use crate::validator::post_validator_liveness_epoch; @@ -132,6 +135,7 @@ pub struct Context { pub network_globals: Option>>, pub beacon_processor_send: Option>, pub sse_logging_components: Option, + pub historical_committee_cache: Arc, } /// Configuration for the HTTP server. @@ -148,6 +152,7 @@ pub struct Config { #[serde(with = "eth2::types::serde_status_code")] pub duplicate_block_status_code: StatusCode, pub target_peers: usize, + pub historical_committee_cache_size: usize, } impl Default for Config { @@ -163,6 +168,7 @@ impl Default for Config { enable_beacon_processor: true, duplicate_block_status_code: StatusCode::ACCEPTED, target_peers: 100, + historical_committee_cache_size: DEFAULT_HISTORICAL_COMMITTEE_CACHE_SIZE, } } } @@ -416,6 +422,11 @@ pub fn serve( }) .boxed(); + let historical_committee_cache = ctx.historical_committee_cache.clone(); + let beacon_states_committees_filter = warp::any() + .map(move || historical_committee_cache.clone()) + .boxed(); + // Create a `warp` filter that provides access to the network sender channel. let network_tx = ctx .network_senders @@ -628,8 +639,10 @@ pub fn serve( states::get_beacon_state_validators_id(beacon_states_path.clone()); // GET beacon/states/{state_id}/committees?slot,index,epoch - let get_beacon_state_committees = - states::get_beacon_state_committees(beacon_states_path.clone()); + let get_beacon_state_committees = states::get_beacon_state_committees( + beacon_states_path.clone(), + beacon_states_committees_filter, + ); // GET beacon/states/{state_id}/sync_committees?epoch let get_beacon_state_sync_committees = diff --git a/beacon_node/http_api/src/test_utils.rs b/beacon_node/http_api/src/test_utils.rs index 27e2a27d35..f27a04d17a 100644 --- a/beacon_node/http_api/src/test_utils.rs +++ b/beacon_node/http_api/src/test_utils.rs @@ -1,4 +1,4 @@ -use crate::{Config, Context}; +use crate::{Config, Context, caches::HistoricalCommitteeCache}; use beacon_chain::{ BeaconChain, BeaconChainTypes, custody_context::NodeCustodyType, @@ -22,10 +22,10 @@ use lighthouse_network::{ }; use network::{NetworkReceivers, NetworkSenders}; use sensitive_url::SensitiveUrl; -use std::future::Future; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; +use std::{future::Future, num::NonZeroUsize}; use store::MemoryStore; use task_executor::test_utils::TestRuntime; use types::{ChainSpec, EthSpec}; @@ -293,6 +293,9 @@ pub async fn create_api_server_with_config( network_globals: Some(network_globals), beacon_processor_send: Some(beacon_processor_send), sse_logging_components: None, + historical_committee_cache: Arc::new(HistoricalCommitteeCache::new( + NonZeroUsize::new(http_config.historical_committee_cache_size).unwrap(), + )), }); let (listening_socket, server) = diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index 8ba2c0f321..f10f9e3b45 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -215,6 +215,9 @@ pub fn get_config( if let Some(cache_size) = clap_utils::parse_optional(cli_args, "shuffling-cache-size")? { client_config.chain.shuffling_cache_size = cache_size; + // Mantain backwards compatibility with users customizing `shuffling_cache_size` to tweak + // the behaviour of the HTTP API route `beacon/states/committees` + client_config.http_api.historical_committee_cache_size = cache_size; } if let Some(batches) = clap_utils::parse_optional(cli_args, "blob-publication-batches")? { From 2c76ee5b6b03cdcd43563e89d1befa7f07f4cc75 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Wed, 20 May 2026 06:56:49 -0600 Subject: [PATCH 176/189] Gloas lookup sync boilerplate (#9322) Implements the boring boilerplate to send envelopes by root requests and process them. Pre-step to - https://github.com/sigp/lighthouse/pull/9155 Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/beacon_chain/src/beacon_chain.rs | 6 + beacon_node/beacon_processor/src/lib.rs | 10 ++ .../src/scheduler/work_queue.rs | 6 + .../src/service/api_types.rs | 2 + .../src/network_beacon_processor/mod.rs | 19 +++ .../network_beacon_processor/sync_methods.rs | 57 +++++++ beacon_node/network/src/router.rs | 35 ++++- .../network/src/sync/block_lookups/mod.rs | 2 + beacon_node/network/src/sync/manager.rs | 66 +++++++- .../network/src/sync/network_context.rs | 145 +++++++++++++++++- .../src/sync/network_context/requests.rs | 4 + .../requests/payload_envelopes_by_root.rs | 54 +++++++ 12 files changed, 398 insertions(+), 8 deletions(-) create mode 100644 beacon_node/network/src/sync/network_context/requests/payload_envelopes_by_root.rs diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index af8cd477d6..f3f6cd299e 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -6339,6 +6339,12 @@ impl BeaconChain { .contains_block(root) } + pub fn envelope_is_known_to_fork_choice(&self, root: &Hash256) -> bool { + self.canonical_head + .fork_choice_read_lock() + .is_payload_received(root) + } + /// Determines the beacon proposer for the next slot. If that proposer is registered in the /// `execution_layer`, provide the `execution_layer` with the necessary information to produce /// `PayloadAttributes` for future calls to fork choice. diff --git a/beacon_node/beacon_processor/src/lib.rs b/beacon_node/beacon_processor/src/lib.rs index 25944bcf8a..ce3851ea54 100644 --- a/beacon_node/beacon_processor/src/lib.rs +++ b/beacon_node/beacon_processor/src/lib.rs @@ -418,6 +418,7 @@ pub enum Work { process_fn: AsyncFn, }, RpcCustodyColumn(AsyncFn), + RpcEnvelope(AsyncFn), ColumnReconstruction(AsyncFn), IgnoredRpcBlock { process_fn: BlockingFn, @@ -485,6 +486,7 @@ pub enum WorkType { RpcBlock, RpcBlobs, RpcCustodyColumn, + RpcEnvelope, ColumnReconstruction, IgnoredRpcBlock, ChainSegment, @@ -548,6 +550,7 @@ impl Work { Work::RpcBlock { .. } => WorkType::RpcBlock, Work::RpcBlobs { .. } => WorkType::RpcBlobs, Work::RpcCustodyColumn { .. } => WorkType::RpcCustodyColumn, + Work::RpcEnvelope(_) => WorkType::RpcEnvelope, Work::ColumnReconstruction(_) => WorkType::ColumnReconstruction, Work::IgnoredRpcBlock { .. } => WorkType::IgnoredRpcBlock, Work::ChainSegment { .. } => WorkType::ChainSegment, @@ -825,6 +828,8 @@ impl BeaconProcessor { Some(item) } else if let Some(item) = work_queues.rpc_custody_column_queue.pop() { Some(item) + } else if let Some(item) = work_queues.rpc_envelope_queue.pop() { + Some(item) // Check delayed blocks before gossip blocks, the gossip blocks might rely // on the delayed ones. } else if let Some(item) = work_queues.delayed_block_queue.pop() { @@ -1192,6 +1197,9 @@ impl BeaconProcessor { work_queues.rpc_block_queue.push(work, work_id) } Work::RpcBlobs { .. } => work_queues.rpc_blob_queue.push(work, work_id), + Work::RpcEnvelope(_) => { + work_queues.rpc_envelope_queue.push(work, work_id) + } Work::RpcCustodyColumn { .. } => { work_queues.rpc_custody_column_queue.push(work, work_id) } @@ -1330,6 +1338,7 @@ impl BeaconProcessor { WorkType::RpcBlobs | WorkType::IgnoredRpcBlock => { work_queues.rpc_blob_queue.len() } + WorkType::RpcEnvelope => work_queues.rpc_envelope_queue.len(), WorkType::RpcCustodyColumn => work_queues.rpc_custody_column_queue.len(), WorkType::ColumnReconstruction => { work_queues.column_reconstruction_queue.len() @@ -1523,6 +1532,7 @@ impl BeaconProcessor { } | Work::RpcBlobs { process_fn } | Work::RpcCustodyColumn(process_fn) + | Work::RpcEnvelope(process_fn) | Work::ColumnReconstruction(process_fn) => task_spawner.spawn_async(process_fn), Work::IgnoredRpcBlock { process_fn } => task_spawner.spawn_blocking(process_fn), Work::GossipBlock(work) diff --git a/beacon_node/beacon_processor/src/scheduler/work_queue.rs b/beacon_node/beacon_processor/src/scheduler/work_queue.rs index eb57b97df2..2fdc15182c 100644 --- a/beacon_node/beacon_processor/src/scheduler/work_queue.rs +++ b/beacon_node/beacon_processor/src/scheduler/work_queue.rs @@ -120,6 +120,7 @@ pub struct BeaconProcessorQueueLengths { rpc_block_queue: usize, rpc_blob_queue: usize, rpc_custody_column_queue: usize, + rpc_envelope_queue: usize, column_reconstruction_queue: usize, chain_segment_queue: usize, backfill_chain_segment: usize, @@ -195,6 +196,8 @@ impl BeaconProcessorQueueLengths { // We don't request more than `PARENT_DEPTH_TOLERANCE` (32) lookups, so we can limit // this queue size. With 48 max blobs per block, each column sidecar list could be up to 12MB. rpc_custody_column_queue: 64, + // Bounded by `PARENT_DEPTH_TOLERANCE`; one envelope per Gloas block. + rpc_envelope_queue: 1024, column_reconstruction_queue: 1, chain_segment_queue: 64, backfill_chain_segment: 64, @@ -253,6 +256,7 @@ pub struct WorkQueues { pub rpc_block_queue: FifoQueue>, pub rpc_blob_queue: FifoQueue>, pub rpc_custody_column_queue: FifoQueue>, + pub rpc_envelope_queue: FifoQueue>, pub column_reconstruction_queue: LifoQueue>, pub chain_segment_queue: FifoQueue>, pub backfill_chain_segment: FifoQueue>, @@ -323,6 +327,7 @@ impl WorkQueues { let rpc_block_queue = FifoQueue::new(queue_lengths.rpc_block_queue); let rpc_blob_queue = FifoQueue::new(queue_lengths.rpc_blob_queue); let rpc_custody_column_queue = FifoQueue::new(queue_lengths.rpc_custody_column_queue); + let rpc_envelope_queue = FifoQueue::new(queue_lengths.rpc_envelope_queue); let column_reconstruction_queue = LifoQueue::new(queue_lengths.column_reconstruction_queue); let chain_segment_queue = FifoQueue::new(queue_lengths.chain_segment_queue); let backfill_chain_segment = FifoQueue::new(queue_lengths.backfill_chain_segment); @@ -391,6 +396,7 @@ impl WorkQueues { rpc_block_queue, rpc_blob_queue, rpc_custody_column_queue, + rpc_envelope_queue, chain_segment_queue, column_reconstruction_queue, backfill_chain_segment, diff --git a/beacon_node/lighthouse_network/src/service/api_types.rs b/beacon_node/lighthouse_network/src/service/api_types.rs index f598f59aee..2429b813e9 100644 --- a/beacon_node/lighthouse_network/src/service/api_types.rs +++ b/beacon_node/lighthouse_network/src/service/api_types.rs @@ -23,6 +23,8 @@ pub enum SyncRequestId { SingleBlock { id: SingleLookupReqId }, /// Request searching for a set of blobs given a hash. SingleBlob { id: SingleLookupReqId }, + /// Request searching for a payload envelope given a hash. + SinglePayloadEnvelope { id: SingleLookupReqId }, /// Request searching for a set of data columns given a hash and list of column indices. DataColumnsByRoot(DataColumnsByRootRequestId), /// Blocks by range request diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index 7bf969db10..7817feb0bd 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -588,6 +588,25 @@ impl NetworkBeaconProcessor { }) } + /// Create a new `Work` event for an RPC-fetched payload envelope. `process_lookup_envelope` + /// reports the result back to sync. + pub fn send_lookup_envelope( + self: &Arc, + block_root: Hash256, + envelope: Arc>, + seen_timestamp: Duration, + process_type: BlockProcessType, + ) -> Result<(), Error> { + let s = self.clone(); + self.try_send(BeaconWorkEvent { + drop_during_sync: false, + work: Work::RpcEnvelope(Box::pin(async move { + s.process_lookup_envelope(block_root, envelope, seen_timestamp, process_type) + .await; + })), + }) + } + /// Create a new `Work` event for some custody columns. `process_rpc_custody_columns` reports /// the result back to sync. pub fn send_rpc_custody_columns( 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 988a68c9dd..e3ba6fb3c4 100644 --- a/beacon_node/network/src/network_beacon_processor/sync_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/sync_methods.rs @@ -426,6 +426,63 @@ impl NetworkBeaconProcessor { }); } + /// Attempt to verify and import an execution payload envelope received via RPC. + #[instrument( + name = "lh_process_lookup_envelope", + parent = None, + level = "debug", + skip_all, + fields(?block_root), + )] + pub async fn process_lookup_envelope( + self: Arc>, + block_root: Hash256, + envelope: Arc>, + _seen_timestamp: Duration, + process_type: BlockProcessType, + ) { + debug!( + ?block_root, + slot = %envelope.slot(), + ?process_type, + "Processing RPC payload envelope" + ); + + // Gossip verification runs the same signature / slot / builder-index / block-hash checks + // independently of gossip propagation, so we can reuse it for RPC-fetched envelopes. + #[allow(clippy::result_large_err)] + let result = match self + .chain + .clone() + .verify_envelope_for_gossip(envelope.clone()) + .await + { + Ok(verified) => { + self.chain + .process_execution_payload_envelope( + block_root, + verified, + NotifyExecutionLayer::Yes, + BlockImportSource::Lookup, + || Ok(()), + ) + .await + } + Err(e) => Err(e), + }; + + // TODO(gloas): structured penalty classification arrives with the envelope lookup state + // machine; for now, fold the EnvelopeError into BlockError::InternalError so it flows + // through the existing `BlockProcessingResult::Err` path. + let result: Result = + result.map_err(|e| BlockError::InternalError(format!("envelope: {e}"))); + + self.send_sync_message(SyncMessage::BlockComponentProcessed { + process_type, + result: result.into(), + }); + } + pub fn process_historic_data_columns( &self, batch_id: CustodyBackfillBatchId, diff --git a/beacon_node/network/src/router.rs b/beacon_node/network/src/router.rs index a718997e0a..35939c6f39 100644 --- a/beacon_node/network/src/router.rs +++ b/beacon_node/network/src/router.rs @@ -26,6 +26,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::{debug, error, trace, warn}; use types::{ BlobSidecar, DataColumnSidecar, EthSpec, ForkContext, PartialDataColumn, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, }; /// Handles messages from the network and routes them to the appropriate service to be handled. @@ -348,10 +349,13 @@ impl Router { Response::DataColumnsByRange(data_column) => { self.on_data_columns_by_range_response(peer_id, app_request_id, data_column); } - // TODO(EIP-7732): implement outgoing payload envelopes by range and root - // responses once sync manager requests them. - Response::PayloadEnvelopesByRoot(_) | Response::PayloadEnvelopesByRange(_) => { - debug!("Requesting envelopes by root and by range not supported yet"); + Response::PayloadEnvelopesByRoot(envelope) => { + self.on_payload_envelopes_by_root_response(peer_id, app_request_id, envelope); + } + // TODO(EIP-7732): implement outgoing payload envelopes by range responses + // once sync manager requests them. + Response::PayloadEnvelopesByRange(_) => { + debug!("Requesting envelopes by range not supported yet"); } // Lighthouse currently only serves BlocksByHead and does not issue it as a client, // so receiving a response is unexpected. Drop it without crashing. @@ -821,6 +825,29 @@ impl Router { } } + /// Handle a `PayloadEnvelopesByRoot` response from the peer. + pub fn on_payload_envelopes_by_root_response( + &mut self, + peer_id: PeerId, + app_request_id: AppRequestId, + envelope: Option>>, + ) { + let sync_request_id = match app_request_id { + AppRequestId::Sync(id @ SyncRequestId::SinglePayloadEnvelope { .. }) => id, + other => { + crit!(request = ?other, %peer_id, "PayloadEnvelopesByRoot response on incorrect request"); + return; + } + }; + + self.send_to_sync(SyncMessage::RpcPayloadEnvelope { + sync_request_id, + peer_id, + envelope, + seen_timestamp: self.chain.slot_clock.now_duration().unwrap_or_default(), + }); + } + fn handle_beacon_processor_send_result( &mut self, result: Result<(), crate::network_beacon_processor::Error>, diff --git a/beacon_node/network/src/sync/block_lookups/mod.rs b/beacon_node/network/src/sync/block_lookups/mod.rs index 3929f74aa0..f10610c751 100644 --- a/beacon_node/network/src/sync/block_lookups/mod.rs +++ b/beacon_node/network/src/sync/block_lookups/mod.rs @@ -559,6 +559,8 @@ impl BlockLookups { BlockProcessType::SingleCustodyColumn(id) => { self.on_processing_result_inner::>(id, result, cx) } + // TODO(gloas): route into the payload envelope lookup state machine. + BlockProcessType::SinglePayloadEnvelope(_) => Ok(LookupResult::Pending), }; self.on_lookup_result(process_type.id(), lookup_result, "processing_result", cx); } diff --git a/beacon_node/network/src/sync/manager.rs b/beacon_node/network/src/sync/manager.rs index 347b018a93..14a38f0e72 100644 --- a/beacon_node/network/src/sync/manager.rs +++ b/beacon_node/network/src/sync/manager.rs @@ -73,7 +73,8 @@ use strum::IntoStaticStr; use tokio::sync::mpsc; use tracing::{debug, error, info, trace}; use types::{ - BlobSidecar, DataColumnSidecar, EthSpec, ForkContext, Hash256, SignedBeaconBlock, Slot, + BlobSidecar, DataColumnSidecar, EthSpec, ForkContext, Hash256, SignedBeaconBlock, + SignedExecutionPayloadEnvelope, Slot, }; /// The number of slots ahead of us that is allowed before requesting a long-range (batch) Sync @@ -132,6 +133,14 @@ pub enum SyncMessage { seen_timestamp: Duration, }, + /// A payload envelope has been received from the RPC. + RpcPayloadEnvelope { + sync_request_id: SyncRequestId, + peer_id: PeerId, + envelope: Option>>, + seen_timestamp: Duration, + }, + /// A block with an unknown parent has been received. UnknownParentBlock(PeerId, Arc>, Hash256), @@ -193,6 +202,7 @@ pub enum BlockProcessType { SingleBlock { id: Id }, SingleBlob { id: Id }, SingleCustodyColumn(Id), + SinglePayloadEnvelope(Id), } impl BlockProcessType { @@ -200,7 +210,8 @@ impl BlockProcessType { match self { BlockProcessType::SingleBlock { id } | BlockProcessType::SingleBlob { id } - | BlockProcessType::SingleCustodyColumn(id) => *id, + | BlockProcessType::SingleCustodyColumn(id) + | BlockProcessType::SinglePayloadEnvelope(id) => *id, } } } @@ -502,6 +513,9 @@ impl SyncManager { SyncRequestId::SingleBlob { id } => { self.on_single_blob_response(id, peer_id, RpcEvent::RPCError(error)) } + SyncRequestId::SinglePayloadEnvelope { id } => { + self.on_single_payload_envelope_response(id, peer_id, RpcEvent::RPCError(error)) + } SyncRequestId::DataColumnsByRoot(req_id) => { self.on_data_columns_by_root_response(req_id, peer_id, RpcEvent::RPCError(error)) } @@ -848,6 +862,17 @@ impl SyncManager { } => { self.rpc_data_column_received(sync_request_id, peer_id, data_column, seen_timestamp) } + SyncMessage::RpcPayloadEnvelope { + sync_request_id, + peer_id, + envelope, + seen_timestamp, + } => self.rpc_payload_envelope_received( + sync_request_id, + peer_id, + envelope, + seen_timestamp, + ), SyncMessage::UnknownParentBlock(peer_id, block, block_root) => { let block_slot = block.slot(); let parent_root = block.parent_root(); @@ -1209,6 +1234,27 @@ 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, + peer_id: PeerId, + envelope: Option>>, + seen_timestamp: Duration, + ) { + match sync_request_id { + SyncRequestId::SinglePayloadEnvelope { id } => self + .on_single_payload_envelope_response( + id, + peer_id, + RpcEvent::from_chunk(envelope, seen_timestamp), + ), + _ => { + crit!(%peer_id, "bad request id for payload envelope"); + } + } + } + fn rpc_data_column_received( &mut self, sync_request_id: SyncRequestId, @@ -1237,6 +1283,22 @@ impl SyncManager { } } + fn on_single_payload_envelope_response( + &mut self, + id: SingleLookupReqId, + peer_id: PeerId, + envelope: RpcEvent>>, + ) { + 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. + } + } + fn on_single_blob_response( &mut self, id: SingleLookupReqId, diff --git a/beacon_node/network/src/sync/network_context.rs b/beacon_node/network/src/sync/network_context.rs index 465e23998b..9d5ac40c0a 100644 --- a/beacon_node/network/src/sync/network_context.rs +++ b/beacon_node/network/src/sync/network_context.rs @@ -2,7 +2,10 @@ //! channel and stores a global RPC ID to perform requests. use self::custody::{ActiveCustodyRequest, Error as CustodyRequestError}; -pub use self::requests::{BlocksByRootSingleRequest, DataColumnsByRootSingleBlockRequest}; +pub use self::requests::{ + BlocksByRootSingleRequest, DataColumnsByRootSingleBlockRequest, + PayloadEnvelopesByRootSingleRequest, +}; use super::SyncMessage; use super::block_sidecar_coupling::RangeBlockComponentsRequest; use super::manager::BlockProcessType; @@ -37,6 +40,7 @@ pub use requests::LookupVerifyError; use requests::{ ActiveRequests, BlobsByRangeRequestItems, BlobsByRootRequestItems, BlocksByRangeRequestItems, BlocksByRootRequestItems, DataColumnsByRangeRequestItems, DataColumnsByRootRequestItems, + PayloadEnvelopesByRootRequestItems, }; #[cfg(test)] use slot_clock::SlotClock; @@ -52,7 +56,7 @@ use tracing::{Span, debug, debug_span, error, warn}; use types::data::FixedBlobSidecarList; use types::{ BlobSidecar, BlockImportSource, ColumnIndex, DataColumnSidecar, DataColumnSidecarList, EthSpec, - ForkContext, Hash256, SignedBeaconBlock, Slot, + ForkContext, Hash256, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, }; pub mod custody; @@ -201,6 +205,9 @@ pub struct SyncNetworkContext { ActiveRequests>, /// A mapping of active BlobsByRoot requests, including both current slot and parent lookups. blobs_by_root_requests: ActiveRequests>, + /// A mapping of active PayloadEnvelopesByRoot requests + payload_envelopes_by_root_requests: + ActiveRequests>, /// A mapping of active DataColumnsByRoot requests data_columns_by_root_requests: ActiveRequests>, @@ -294,6 +301,7 @@ impl SyncNetworkContext { request_id: 1, blocks_by_root_requests: ActiveRequests::new("blocks_by_root"), blobs_by_root_requests: ActiveRequests::new("blobs_by_root"), + payload_envelopes_by_root_requests: ActiveRequests::new("payload_envelopes_by_root"), data_columns_by_root_requests: ActiveRequests::new("data_columns_by_root"), blocks_by_range_requests: ActiveRequests::new("blocks_by_range"), blobs_by_range_requests: ActiveRequests::new("blobs_by_range"), @@ -322,6 +330,7 @@ impl SyncNetworkContext { request_id: _, blocks_by_root_requests, blobs_by_root_requests, + payload_envelopes_by_root_requests, data_columns_by_root_requests, blocks_by_range_requests, blobs_by_range_requests, @@ -345,6 +354,10 @@ impl SyncNetworkContext { .active_requests_of_peer(peer_id) .into_iter() .map(|id| SyncRequestId::SingleBlob { id: *id }); + let payload_envelopes_by_root_ids = payload_envelopes_by_root_requests + .active_requests_of_peer(peer_id) + .into_iter() + .map(|id| SyncRequestId::SinglePayloadEnvelope { id: *id }); let data_column_by_root_ids = data_columns_by_root_requests .active_requests_of_peer(peer_id) .into_iter() @@ -363,6 +376,7 @@ impl SyncNetworkContext { .map(|req_id| SyncRequestId::DataColumnsByRange(*req_id)); blocks_by_root_ids .chain(blobs_by_root_ids) + .chain(payload_envelopes_by_root_ids) .chain(data_column_by_root_ids) .chain(blocks_by_range_ids) .chain(blobs_by_range_ids) @@ -419,6 +433,7 @@ impl SyncNetworkContext { request_id: _, blocks_by_root_requests, blobs_by_root_requests, + payload_envelopes_by_root_requests, data_columns_by_root_requests, blocks_by_range_requests, blobs_by_range_requests, @@ -441,6 +456,7 @@ impl SyncNetworkContext { for peer_id in blocks_by_root_requests .iter_request_peers() .chain(blobs_by_root_requests.iter_request_peers()) + .chain(payload_envelopes_by_root_requests.iter_request_peers()) .chain(data_columns_by_root_requests.iter_request_peers()) .chain(blocks_by_range_requests.iter_request_peers()) .chain(blobs_by_range_requests.iter_request_peers()) @@ -927,6 +943,81 @@ impl SyncNetworkContext { Ok(LookupRequestResult::RequestSent(id.req_id)) } + /// 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 { + // 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) { + return Ok(LookupRequestResult::NoRequestNeeded( + "envelope already known to fork-choice", + )); + } + + let active_request_count_by_peer = self.active_request_count_by_peer(); + let Some(peer_id) = lookup_peers + .read() + .iter() + .map(|peer| { + ( + active_request_count_by_peer.get(peer).copied().unwrap_or(0), + rand::random::(), + peer, + ) + }) + .min() + .map(|(_, _, peer)| *peer) + else { + return Ok(LookupRequestResult::Pending("no peers")); + }; + + let id = SingleLookupReqId { + lookup_id, + req_id: self.next_id(), + }; + + let request = PayloadEnvelopesByRootSingleRequest { block_root }; + + let network_request = RequestType::PayloadEnvelopesByRoot( + request + .clone() + .into_request(&self.fork_context) + .map_err(RpcRequestSendError::InternalError)?, + ); + self.network_send + .send(NetworkMessage::SendRequest { + peer_id, + request: network_request, + app_request_id: AppRequestId::Sync(SyncRequestId::SinglePayloadEnvelope { id }), + }) + .map_err(|_| RpcRequestSendError::InternalError("network send error".to_owned()))?; + + debug!( + method = "PayloadEnvelopesByRoot", + ?block_root, + peer = %peer_id, + %id, + "Sync RPC request sent" + ); + + self.payload_envelopes_by_root_requests.insert( + id, + peer_id, + // true = enforce that the peer returns a response. We only request a single envelope + // and the peer must have it. + true, + PayloadEnvelopesByRootRequestItems::new(request), + Span::none(), + ); + + Ok(LookupRequestResult::RequestSent(id.req_id)) + } + /// Request necessary blobs for `block_root`. Requests only the necessary blobs by checking: /// - If we have a downloaded but not yet processed block /// - If the da_checker has a pending block @@ -1476,6 +1567,27 @@ impl SyncNetworkContext { self.on_rpc_response_result(resp, peer_id) } + pub(crate) fn on_single_payload_envelope_response( + &mut self, + id: SingleLookupReqId, + peer_id: PeerId, + rpc_event: RpcEvent>>, + ) -> Option>>> { + let resp = self + .payload_envelopes_by_root_requests + .on_response(id, rpc_event); + let resp = resp.map(|res| { + res.and_then(|(mut envelopes, seen_timestamp)| { + match envelopes.pop() { + Some(envelope) => Ok((envelope, seen_timestamp)), + // Should never happen, we enforce at least 1 chunk. + None => Err(LookupVerifyError::NotEnoughResponsesReturned { actual: 0 }.into()), + } + }) + }); + self.on_rpc_response_result(resp, peer_id) + } + #[allow(clippy::type_complexity)] pub(crate) fn on_data_columns_by_root_response( &mut self, @@ -1652,6 +1764,35 @@ impl SyncNetworkContext { }) } + #[allow(dead_code)] + pub fn send_payload_for_processing( + &self, + block_root: Hash256, + envelope: Arc>, + seen_timestamp: Duration, + process_type: BlockProcessType, + ) -> Result<(), SendErrorProcessor> { + let beacon_processor = self + .beacon_processor_if_enabled() + .ok_or(SendErrorProcessor::ProcessorNotAvailable)?; + + debug!( + ?block_root, + ?process_type, + "Sending payload envelope for processing" + ); + + beacon_processor + .send_lookup_envelope(block_root, envelope, seen_timestamp, process_type) + .map_err(|e| { + error!( + error = ?e, + "Failed to send sync payload envelope to processor" + ); + SendErrorProcessor::SendError + }) + } + pub fn send_custody_columns_for_processing( &self, _id: Id, diff --git a/beacon_node/network/src/sync/network_context/requests.rs b/beacon_node/network/src/sync/network_context/requests.rs index ad60dffb45..8c091eca80 100644 --- a/beacon_node/network/src/sync/network_context/requests.rs +++ b/beacon_node/network/src/sync/network_context/requests.rs @@ -16,6 +16,9 @@ pub use data_columns_by_range::DataColumnsByRangeRequestItems; pub use data_columns_by_root::{ DataColumnsByRootRequestItems, DataColumnsByRootSingleBlockRequest, }; +pub use payload_envelopes_by_root::{ + PayloadEnvelopesByRootRequestItems, PayloadEnvelopesByRootSingleRequest, +}; use crate::metrics; @@ -27,6 +30,7 @@ mod blocks_by_range; mod blocks_by_root; mod data_columns_by_range; mod data_columns_by_root; +mod payload_envelopes_by_root; #[derive(Debug, PartialEq, Eq, IntoStaticStr)] pub enum LookupVerifyError { diff --git a/beacon_node/network/src/sync/network_context/requests/payload_envelopes_by_root.rs b/beacon_node/network/src/sync/network_context/requests/payload_envelopes_by_root.rs new file mode 100644 index 0000000000..a142d86e90 --- /dev/null +++ b/beacon_node/network/src/sync/network_context/requests/payload_envelopes_by_root.rs @@ -0,0 +1,54 @@ +use lighthouse_network::rpc::methods::PayloadEnvelopesByRootRequest; +use std::sync::Arc; +use types::{EthSpec, ForkContext, Hash256, SignedExecutionPayloadEnvelope}; + +use super::{ActiveRequestItems, LookupVerifyError}; + +#[derive(Debug, Clone)] +pub struct PayloadEnvelopesByRootSingleRequest { + pub block_root: Hash256, +} + +impl PayloadEnvelopesByRootSingleRequest { + pub fn into_request( + self, + fork_context: &ForkContext, + ) -> Result { + PayloadEnvelopesByRootRequest::new(vec![self.block_root], fork_context) + } +} + +pub struct PayloadEnvelopesByRootRequestItems { + request: PayloadEnvelopesByRootSingleRequest, + items: Vec>>, +} + +impl PayloadEnvelopesByRootRequestItems { + pub fn new(request: PayloadEnvelopesByRootSingleRequest) -> Self { + Self { + request, + items: vec![], + } + } +} + +impl ActiveRequestItems for PayloadEnvelopesByRootRequestItems { + type Item = Arc>; + + /// Append a response to the single chunk request. We expect exactly one envelope per + /// block root. Returns `true` when the single expected item has been received. + fn add(&mut self, envelope: Self::Item) -> Result { + let block_root = envelope.message.beacon_block_root; + if self.request.block_root != block_root { + return Err(LookupVerifyError::UnrequestedBlockRoot(block_root)); + } + + self.items.push(envelope); + // Always returns true, we expect a single envelope per block root + Ok(true) + } + + fn consume(&mut self) -> Vec { + std::mem::take(&mut self.items) + } +} From a9637c16502abb0215b9a76b7067207b6bc70d8c Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Thu, 21 May 2026 05:25:02 +0200 Subject: [PATCH 177/189] Partial columns cleanup (#9321) #8314 left a few ugly potentially panicking location behind - all of them believed to be unreachable, but this PR fixes them regardless for good hygiene. Update to `ethereum_ssz 0.10.4` for two new helpers: `not_inplace` and `clone_zeroed`. Remove remaining `expect` and `todo!` in favour of these helpers and one new fallible (but practically infallible) method. Co-Authored-By: Daniel Knopik --- Cargo.lock | 4 ++-- .../src/data_column_verification.rs | 11 ++++++++- beacon_node/http_api/src/publish_blocks.rs | 12 ++++++---- .../lighthouse_network/src/types/partial.rs | 18 +++++--------- .../gossip_methods.rs | 24 +++++++++++-------- .../types/src/data/data_column_sidecar.rs | 17 ++++++------- 6 files changed, 47 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 078f699f3c..d42bcd8fc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3282,9 +3282,9 @@ dependencies = [ [[package]] name = "ethereum_ssz" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368a4a4e4273b0135111fe9464e35465067766a8f664615b5a86338b73864407" +checksum = "e462875ad8693755ea8913d6e905715c76ea4836e2254e18c9cf0f7a8f8c2a13" dependencies = [ "alloy-primitives", "arbitrary", diff --git a/beacon_node/beacon_chain/src/data_column_verification.rs b/beacon_node/beacon_chain/src/data_column_verification.rs index 71562b376b..45cd687b36 100644 --- a/beacon_node/beacon_chain/src/data_column_verification.rs +++ b/beacon_node/beacon_chain/src/data_column_verification.rs @@ -1220,7 +1220,16 @@ pub fn validate_partial_data_column_sidecar_for_gossip( header, }; } - Err(MissingCellsError::UnexpectedError(e)) => todo!("handle unexpected error {:?}", e), + Err(MissingCellsError::UnexpectedError(e)) => { + return PartialColumnVerificationResult::ErrWithValidHeader { + err: GossipDataColumnError::InternalError(format!( + "An unexpected error occurred while validating partial data columns: {:?}", + e + )) + .into(), + header, + }; + } }; // We do not have to check block related data here, as we create the verifiable column from diff --git a/beacon_node/http_api/src/publish_blocks.rs b/beacon_node/http_api/src/publish_blocks.rs index e96c86b17f..ca4ab85524 100644 --- a/beacon_node/http_api/src/publish_blocks.rs +++ b/beacon_node/http_api/src/publish_blocks.rs @@ -524,11 +524,15 @@ pub(crate) fn publish_column_sidecars( if chain.config.enable_partial_columns && let DataColumnSidecar::Fulu(fulu_data_col) = data_col.as_ref() { - let mut partial = fulu_data_col.to_partial(); - if let Some(header) = partial.sidecar.header.take() { - partial_header = Some(header); + match fulu_data_col.to_partial() { + Ok(mut partial) => { + if let Some(header) = partial.sidecar.header.take() { + partial_header = Some(header); + } + partial_columns.push(Arc::new(partial)); + } + Err(err) => crit!(?err, "Could not convert from full to partial"), } - partial_columns.push(Arc::new(partial)); } let subnet = DataColumnSubnetId::from_column_index(*data_col.index(), &chain.spec); diff --git a/beacon_node/lighthouse_network/src/types/partial.rs b/beacon_node/lighthouse_network/src/types/partial.rs index f25ce9ec36..26705b7106 100644 --- a/beacon_node/lighthouse_network/src/types/partial.rs +++ b/beacon_node/lighthouse_network/src/types/partial.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use tracing::{debug, error}; use types::core::{EthSpec, Hash256}; use types::data::{ - CellBitmap, PartialDataColumn, PartialDataColumnHeader, PartialDataColumnPartsMetadata, + PartialDataColumn, PartialDataColumnHeader, PartialDataColumnPartsMetadata, PartialDataColumnSidecar, PartialDataColumnSidecarRef, }; @@ -32,12 +32,8 @@ impl OutgoingPartialColumn { header_sent_set: HeaderSentSet, ) -> Self { // For now, always request all cells - let mut requests = partial_column.sidecar.cells_present_bitmap.clone(); - for idx in 0..requests.len() { - requests - .set(idx, true) - .expect("Bound asserted via `len` above"); - } + let mut requests = partial_column.sidecar.cells_present_bitmap.clone_zeroed(); + requests.not_inplace(); let metadata = PartialDataColumnPartsMetadata:: { available: partial_column.sidecar.cells_present_bitmap.clone(), requests, @@ -45,10 +41,7 @@ impl OutgoingPartialColumn { .into(); let header_message = PartialDataColumnSidecarRef { - cells_present_bitmap: CellBitmap::::with_capacity( - partial_column.sidecar.cells_present_bitmap.len(), - ) - .expect("Taking length from bitmap with same bound"), + cells_present_bitmap: partial_column.sidecar.cells_present_bitmap.clone_zeroed(), column: vec![], kzg_proofs: vec![], header: Some(header).into(), @@ -210,7 +203,7 @@ impl Partial for OutgoingPartialColumn { let send = self .partial_column .sidecar - .filter(|idx| want.get(idx).expect("Bound checked above")) + .filter(|idx| want.get(idx).unwrap_or(false)) .map_err(|err| { error!(?err, "Unexpected error filtering sidecar"); PartialError::InvalidFormat @@ -262,6 +255,7 @@ mod tests { use fixed_bytes::FixedBytesExtended; use libp2p::identity::Keypair; use ssz_types::FixedVector; + use types::CellBitmap; use types::block::{BeaconBlockHeader, SignedBeaconBlockHeader}; use types::core::{MinimalEthSpec, Slot}; use types::data::PartialDataColumnHeader; diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index d34668b138..7a902649cb 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -1381,16 +1381,20 @@ impl NetworkBeaconProcessor { &[&data_column_index.to_string()], ); - let mut column = col.to_partial(); - let header = column.sidecar.header.take(); - if let Some(header) = header { - self.send_network_message(NetworkMessage::PublishPartialColumns { - columns: vec![Arc::new(column)], - header: Arc::new(header), - }); - } else { - crit!("Converting from full to partial yielded headerless partial") - }; + match col.to_partial() { + Ok(mut column) => { + let header = column.sidecar.header.take(); + if let Some(header) = header { + self.send_network_message(NetworkMessage::PublishPartialColumns { + columns: vec![Arc::new(column)], + header: Arc::new(header), + }); + } else { + crit!("Converting from full to partial yielded headerless partial") + }; + } + Err(err) => crit!(?err, "Could not convert from full to partial"), + } } let result = self diff --git a/consensus/types/src/data/data_column_sidecar.rs b/consensus/types/src/data/data_column_sidecar.rs index 170aa99666..d15651730f 100644 --- a/consensus/types/src/data/data_column_sidecar.rs +++ b/consensus/types/src/data/data_column_sidecar.rs @@ -250,19 +250,16 @@ impl DataColumnSidecarFulu { } /// Convert this full data column into a verifiable partial data column. - pub fn to_partial(&self) -> PartialDataColumn { + /// Note: This is not expected to ever fail. + pub fn to_partial(&self) -> Result, PartialDataColumnSidecarError> { let cell_count = self.column.len(); - let mut bitmap = - CellBitmap::::with_capacity(cell_count).expect("our column has the same bound"); - for idx in 0..cell_count { - bitmap - .set(idx, true) - .expect("The correct size is initialized right above"); - } + let mut bitmap = CellBitmap::::with_capacity(cell_count) + .map_err(|_| PartialDataColumnSidecarError::UnexpectedBounds)?; + bitmap.not_inplace(); let block_root = self.block_root(); - PartialDataColumn { + Ok(PartialDataColumn { block_root, index: self.index, sidecar: PartialDataColumnSidecar { @@ -276,7 +273,7 @@ impl DataColumnSidecarFulu { }) .into(), }, - } + }) } } From 1caaa10fa86cfe9ad47cffc03f7de81b3e6642e6 Mon Sep 17 00:00:00 2001 From: Lion - dapplion <35266934+dapplion@users.noreply.github.com> Date: Thu, 21 May 2026 02:35:35 -0600 Subject: [PATCH 178/189] Drop unused EthSpec generic from Stores (#9281) Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- beacon_node/beacon_chain/src/beacon_chain.rs | 4 +- .../src/beacon_fork_choice_store.rs | 12 ++-- beacon_node/beacon_chain/src/builder.rs | 19 +++---- .../overflow_lru_cache.rs | 10 ++-- beacon_node/beacon_chain/src/migrate.rs | 4 +- .../payload_attestation_verification/tests.rs | 2 +- .../beacon_chain/src/persisted_custody.rs | 4 +- beacon_node/beacon_chain/src/test_utils.rs | 18 +++--- .../beacon_chain/tests/op_verification.rs | 2 +- .../beacon_chain/tests/prepare_payload.rs | 8 +-- .../beacon_chain/tests/schema_stability.rs | 2 +- beacon_node/beacon_chain/tests/store_tests.rs | 14 ++--- beacon_node/client/src/builder.rs | 15 +++-- beacon_node/http_api/src/test_utils.rs | 2 +- .../src/network_beacon_processor/mod.rs | 3 +- beacon_node/network/src/persisted_dht.rs | 13 ++--- .../network/src/subnet_service/tests/mod.rs | 7 +-- beacon_node/network/src/sync/tests/mod.rs | 2 +- beacon_node/src/lib.rs | 2 +- beacon_node/store/src/database/interface.rs | 13 ++--- .../store/src/database/leveldb_impl.rs | 13 ++--- beacon_node/store/src/database/redb_impl.rs | 15 ++--- beacon_node/store/src/forwards_iter.rs | 18 +++--- beacon_node/store/src/hot_cold_store.rs | 16 +++--- beacon_node/store/src/invariants.rs | 2 +- beacon_node/store/src/iter.rs | 56 ++++++++----------- beacon_node/store/src/lib.rs | 8 +-- beacon_node/store/src/memory_store.rs | 12 ++-- beacon_node/store/src/reconstruct.rs | 4 +- consensus/fork_choice/tests/tests.rs | 2 +- database_manager/src/lib.rs | 22 ++++---- 31 files changed, 141 insertions(+), 183 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index f3f6cd299e..2259e1d809 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -325,8 +325,8 @@ pub enum StateSkipConfig { } pub trait BeaconChainTypes: Send + Sync + 'static { - type HotStore: store::ItemStore; - type ColdStore: store::ItemStore; + type HotStore: store::ItemStore; + type ColdStore: store::ItemStore; type SlotClock: slot_clock::SlotClock; type EthSpec: types::EthSpec; } diff --git a/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs b/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs index 95fde28f5b..133eaa2fc6 100644 --- a/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs +++ b/beacon_node/beacon_chain/src/beacon_fork_choice_store.rs @@ -129,8 +129,8 @@ impl BalancesCache { /// Implements `fork_choice::ForkChoiceStore` in order to provide a persistent backing to the /// `fork_choice::ForkChoice` struct. #[derive(Debug, Educe)] -#[educe(PartialEq(bound(E: EthSpec, Hot: ItemStore, Cold: ItemStore)))] -pub struct BeaconForkChoiceStore, Cold: ItemStore> { +#[educe(PartialEq(bound(E: EthSpec, Hot: ItemStore, Cold: ItemStore)))] +pub struct BeaconForkChoiceStore { #[educe(PartialEq(ignore))] store: Arc>, balances_cache: BalancesCache, @@ -150,8 +150,8 @@ pub struct BeaconForkChoiceStore, Cold: ItemStore< impl BeaconForkChoiceStore where E: EthSpec, - Hot: ItemStore, - Cold: ItemStore, + Hot: ItemStore, + Cold: ItemStore, { /// Initialize `Self` from some `anchor` checkpoint which may or may not be the genesis state. /// @@ -267,8 +267,8 @@ where impl ForkChoiceStore for BeaconForkChoiceStore where E: EthSpec, - Hot: ItemStore, - Cold: ItemStore, + Hot: ItemStore, + Cold: ItemStore, { type Error = Error; diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index e668bef7c0..61c026e0a9 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -60,8 +60,8 @@ pub struct Witness( impl BeaconChainTypes for Witness where - THotStore: ItemStore + 'static, - TColdStore: ItemStore + 'static, + THotStore: ItemStore + 'static, + TColdStore: ItemStore + 'static, TSlotClock: SlotClock + 'static, E: EthSpec + 'static, { @@ -115,8 +115,8 @@ pub struct BeaconChainBuilder { impl BeaconChainBuilder> where - THotStore: ItemStore + 'static, - TColdStore: ItemStore + 'static, + THotStore: ItemStore + 'static, + TColdStore: ItemStore + 'static, TSlotClock: SlotClock + 'static, E: EthSpec + 'static, { @@ -1162,8 +1162,8 @@ where impl BeaconChainBuilder> where - THotStore: ItemStore + 'static, - TColdStore: ItemStore + 'static, + THotStore: ItemStore + 'static, + TColdStore: ItemStore + 'static, E: EthSpec + 'static, { /// Sets the `BeaconChain` slot clock to `TestingSlotClock`. @@ -1301,11 +1301,8 @@ mod test { let validator_count = 1; let genesis_time = 13_371_337; - let store: HotColdDB< - MinimalEthSpec, - MemoryStore, - MemoryStore, - > = HotColdDB::open_ephemeral(StoreConfig::default(), ChainSpec::minimal().into()).unwrap(); + let store: HotColdDB = + HotColdDB::open_ephemeral(StoreConfig::default(), ChainSpec::minimal().into()).unwrap(); let spec = MinimalEthSpec::default_spec(); let genesis_state = interop_genesis_state( diff --git a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs index 3034e196b9..8a80f835ab 100644 --- a/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs +++ b/beacon_node/beacon_chain/src/data_availability_checker/overflow_lru_cache.rs @@ -802,7 +802,7 @@ mod test { fn get_store_with_spec( db_path: &TempDir, spec: Arc, - ) -> Arc, BeaconNodeBackend>> { + ) -> Arc> { let hot_path = db_path.path().join("hot_db"); let cold_path = db_path.path().join("cold_db"); let blobs_path = db_path.path().join("blobs_db"); @@ -860,8 +860,8 @@ mod test { ) where E: EthSpec, - Hot: ItemStore, - Cold: ItemStore, + Hot: ItemStore, + Cold: ItemStore, { let chain = &harness.chain; let head = chain.head_snapshot(); @@ -946,8 +946,8 @@ mod test { where E: EthSpec, T: BeaconChainTypes< - HotStore = BeaconNodeBackend, - ColdStore = BeaconNodeBackend, + HotStore = BeaconNodeBackend, + ColdStore = BeaconNodeBackend, EthSpec = E, >, { diff --git a/beacon_node/beacon_chain/src/migrate.rs b/beacon_node/beacon_chain/src/migrate.rs index 3c17c1ebba..9c70bcafa2 100644 --- a/beacon_node/beacon_chain/src/migrate.rs +++ b/beacon_node/beacon_chain/src/migrate.rs @@ -30,7 +30,7 @@ pub const DEFAULT_EPOCHS_PER_MIGRATION: u64 = 1; /// The background migrator runs a thread to perform pruning and migrate state from the hot /// to the cold database. -pub struct BackgroundMigrator, Cold: ItemStore> { +pub struct BackgroundMigrator { db: Arc>, /// Record of when the last migration ran, for enforcing `epochs_per_migration`. prev_migration: Arc>, @@ -135,7 +135,7 @@ pub struct FinalizationNotification { pub prev_migration: Arc>, } -impl, Cold: ItemStore> BackgroundMigrator { +impl BackgroundMigrator { /// Create a new `BackgroundMigrator` and spawn its thread if necessary. pub fn new(db: Arc>, config: MigratorConfig) -> Self { // Estimate last migration run from DB split slot. diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs index 7faad98e55..c45df51ac8 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs @@ -43,7 +43,7 @@ struct TestContext { keypairs: Vec, spec: ChainSpec, genesis_block_root: Hash256, - store: Arc, store::MemoryStore>>, + store: Arc>, } impl TestContext { diff --git a/beacon_node/beacon_chain/src/persisted_custody.rs b/beacon_node/beacon_chain/src/persisted_custody.rs index ba221c67b5..cc7219fa90 100644 --- a/beacon_node/beacon_chain/src/persisted_custody.rs +++ b/beacon_node/beacon_chain/src/persisted_custody.rs @@ -9,7 +9,7 @@ pub const CUSTODY_DB_KEY: Hash256 = Hash256::ZERO; pub struct PersistedCustody(pub CustodyContextSsz); -pub fn load_custody_context, Cold: ItemStore>( +pub fn load_custody_context( store: Arc>, ) -> Option { let res: Result, _> = @@ -22,7 +22,7 @@ pub fn load_custody_context, Cold: ItemStore>( } /// Attempt to persist the custody context object to `self.store`. -pub fn persist_custody_context, Cold: ItemStore>( +pub fn persist_custody_context( store: Arc>, custody_context: CustodyContextSsz, ) -> Result<(), store::Error> { diff --git a/beacon_node/beacon_chain/src/test_utils.rs b/beacon_node/beacon_chain/src/test_utils.rs index 8e9cc61208..c2ccad7d8c 100644 --- a/beacon_node/beacon_chain/src/test_utils.rs +++ b/beacon_node/beacon_chain/src/test_utils.rs @@ -124,8 +124,8 @@ pub fn get_kzg(spec: &ChainSpec) -> Arc { pub type BaseHarnessType = Witness; -pub type DiskHarnessType = BaseHarnessType, BeaconNodeBackend>; -pub type EphemeralHarnessType = BaseHarnessType, MemoryStore>; +pub type DiskHarnessType = BaseHarnessType; +pub type EphemeralHarnessType = BaseHarnessType; pub type BoxedMutator = Box< dyn FnOnce( @@ -334,7 +334,7 @@ impl Builder> { /// Manually restore from a given `MemoryStore`. pub fn resumed_ephemeral_store( mut self, - store: Arc, MemoryStore>>, + store: Arc>, ) -> Self { let mutator = move |builder: BeaconChainBuilder<_>| { builder @@ -350,7 +350,7 @@ impl Builder> { /// Disk store, start from genesis. pub fn fresh_disk_store( mut self, - store: Arc, BeaconNodeBackend>>, + store: Arc>, ) -> Self { let validator_keypairs = self .validator_keypairs @@ -384,7 +384,7 @@ impl Builder> { /// Disk store, resume. pub fn resumed_disk_store( mut self, - store: Arc, BeaconNodeBackend>>, + store: Arc>, ) -> Self { let mutator = move |builder: BeaconChainBuilder<_>| { builder @@ -399,8 +399,8 @@ impl Builder> { impl Builder> where E: EthSpec, - Hot: ItemStore, - Cold: ItemStore, + Hot: ItemStore, + Cold: ItemStore, { pub fn new(eth_spec_instance: E) -> Self { let runtime = TestRuntime::default(); @@ -760,8 +760,8 @@ pub type HarnessSyncContributions = Vec<( impl BeaconChainHarness> where E: EthSpec, - Hot: ItemStore, - Cold: ItemStore, + Hot: ItemStore, + Cold: ItemStore, { pub fn builder(eth_spec_instance: E) -> Builder> { create_test_tracing_subscriber(); diff --git a/beacon_node/beacon_chain/tests/op_verification.rs b/beacon_node/beacon_chain/tests/op_verification.rs index 2f97f10745..adc14541a9 100644 --- a/beacon_node/beacon_chain/tests/op_verification.rs +++ b/beacon_node/beacon_chain/tests/op_verification.rs @@ -27,7 +27,7 @@ static KEYPAIRS: LazyLock> = type E = MinimalEthSpec; type TestHarness = BeaconChainHarness>; -type HotColdDB = store::HotColdDB, BeaconNodeBackend>; +type HotColdDB = store::HotColdDB; fn get_store(db_path: &TempDir) -> Arc { let spec = Arc::new(test_spec::()); diff --git a/beacon_node/beacon_chain/tests/prepare_payload.rs b/beacon_node/beacon_chain/tests/prepare_payload.rs index 47dd1ef517..de8bfb3865 100644 --- a/beacon_node/beacon_chain/tests/prepare_payload.rs +++ b/beacon_node/beacon_chain/tests/prepare_payload.rs @@ -34,7 +34,7 @@ type TestHarness = BeaconChainHarness>; fn get_store( db_path: &TempDir, spec: Arc, -) -> Arc, BeaconNodeBackend>> { +) -> Arc> { let store_config = StoreConfig { prune_payloads: false, ..StoreConfig::default() @@ -46,7 +46,7 @@ fn get_store_generic( db_path: &TempDir, config: StoreConfig, spec: Arc, -) -> Arc, BeaconNodeBackend>> { +) -> Arc> { create_test_tracing_subscriber(); let hot_path = db_path.path().join("chain_db"); let cold_path = db_path.path().join("freezer_db"); @@ -64,7 +64,7 @@ fn get_store_generic( } fn get_harness( - store: Arc, BeaconNodeBackend>>, + store: Arc>, validator_count: usize, ) -> TestHarness { // Most tests expect to retain historic states, so we use this as the default. @@ -81,7 +81,7 @@ fn get_harness( } fn get_harness_generic( - store: Arc, BeaconNodeBackend>>, + store: Arc>, validator_count: usize, chain_config: ChainConfig, node_custody_type: NodeCustodyType, diff --git a/beacon_node/beacon_chain/tests/schema_stability.rs b/beacon_node/beacon_chain/tests/schema_stability.rs index 8200748ae6..899a40511d 100644 --- a/beacon_node/beacon_chain/tests/schema_stability.rs +++ b/beacon_node/beacon_chain/tests/schema_stability.rs @@ -20,7 +20,7 @@ use tempfile::{TempDir, tempdir}; use types::{ChainSpec, Hash256, MainnetEthSpec, Slot}; type E = MainnetEthSpec; -type Store = Arc, BeaconNodeBackend>>; +type Store = Arc>; type TestHarness = BeaconChainHarness>; const VALIDATOR_COUNT: usize = 32; diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 0ff9f6841d..7e50f4e5ac 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -106,7 +106,7 @@ fn get_or_reconstruct_blobs( } } -fn get_store(db_path: &TempDir) -> Arc, BeaconNodeBackend>> { +fn get_store(db_path: &TempDir) -> Arc> { let store_config = StoreConfig { prune_payloads: false, ..StoreConfig::default() @@ -118,7 +118,7 @@ fn get_store_generic( db_path: &TempDir, config: StoreConfig, spec: ChainSpec, -) -> Arc, BeaconNodeBackend>> { +) -> Arc> { create_test_tracing_subscriber(); let hot_path = db_path.path().join("chain_db"); let cold_path = db_path.path().join("freezer_db"); @@ -136,7 +136,7 @@ fn get_store_generic( } fn get_harness( - store: Arc, BeaconNodeBackend>>, + store: Arc>, validator_count: usize, ) -> TestHarness { // Most tests expect to retain historic states, so we use this as the default. @@ -153,7 +153,7 @@ fn get_harness( } fn get_harness_import_all_data_columns( - store: Arc, BeaconNodeBackend>>, + store: Arc>, validator_count: usize, ) -> TestHarness { // Most tests expect to retain historic states, so we use this as the default. @@ -171,7 +171,7 @@ fn get_harness_import_all_data_columns( } fn get_harness_generic( - store: Arc, BeaconNodeBackend>>, + store: Arc>, validator_count: usize, chain_config: ChainConfig, node_custody_type: NodeCustodyType, @@ -205,7 +205,7 @@ fn check_db_invariants(harness: &TestHarness) { } fn get_states_descendant_of_block( - store: &HotColdDB, BeaconNodeBackend>, + store: &HotColdDB, block_root: Hash256, ) -> Vec<(Hash256, Slot)> { let summaries = store.load_hot_state_summaries().unwrap(); @@ -5859,7 +5859,7 @@ async fn test_gloas_hot_state_hierarchy() { /// Check that the HotColdDB's split_slot is equal to the start slot of the last finalized epoch. fn check_split_slot( harness: &TestHarness, - store: Arc, BeaconNodeBackend>>, + store: Arc>, ) { let split_slot = store.get_split_slot(); assert_eq!( diff --git a/beacon_node/client/src/builder.rs b/beacon_node/client/src/builder.rs index f532ef716e..0a3c414632 100644 --- a/beacon_node/client/src/builder.rs +++ b/beacon_node/client/src/builder.rs @@ -98,8 +98,8 @@ impl where TSlotClock: SlotClock + Clone + 'static, E: EthSpec + 'static, - THotStore: ItemStore + 'static, - TColdStore: ItemStore + 'static, + THotStore: ItemStore + 'static, + TColdStore: ItemStore + 'static, { /// Instantiates a new, empty builder. /// @@ -815,8 +815,8 @@ impl where TSlotClock: SlotClock + Clone + 'static, E: EthSpec + 'static, - THotStore: ItemStore + 'static, - TColdStore: ItemStore + 'static, + THotStore: ItemStore + 'static, + TColdStore: ItemStore + 'static, { /// Consumes the internal `BeaconChainBuilder`, attaching the resulting `BeaconChain` to self. #[instrument(skip_all)] @@ -847,8 +847,7 @@ where } } -impl - ClientBuilder, BeaconNodeBackend>> +impl ClientBuilder> where TSlotClock: SlotClock + 'static, E: EthSpec + 'static, @@ -889,8 +888,8 @@ where impl ClientBuilder> where E: EthSpec + 'static, - THotStore: ItemStore + 'static, - TColdStore: ItemStore + 'static, + THotStore: ItemStore + 'static, + TColdStore: ItemStore + 'static, { /// Specifies that the slot clock should read the time from the computers system clock. pub fn system_time_slot_clock(mut self) -> Result { diff --git a/beacon_node/http_api/src/test_utils.rs b/beacon_node/http_api/src/test_utils.rs index f27a04d17a..467a5216b1 100644 --- a/beacon_node/http_api/src/test_utils.rs +++ b/beacon_node/http_api/src/test_utils.rs @@ -57,7 +57,7 @@ pub struct ApiServer> { type HarnessBuilder = Builder>; type Initializer = Box) -> HarnessBuilder>; -type Mutator = BoxedMutator, MemoryStore>; +type Mutator = BoxedMutator; impl InteractiveTester { pub async fn new(spec: Option, validator_count: usize) -> Self { diff --git a/beacon_node/network/src/network_beacon_processor/mod.rs b/beacon_node/network/src/network_beacon_processor/mod.rs index 7817feb0bd..434f7ecc8b 100644 --- a/beacon_node/network/src/network_beacon_processor/mod.rs +++ b/beacon_node/network/src/network_beacon_processor/mod.rs @@ -1267,8 +1267,7 @@ use { }; #[cfg(test)] -pub(crate) type TestBeaconChainType = - Witness, MemoryStore>; +pub(crate) type TestBeaconChainType = Witness; #[cfg(test)] impl NetworkBeaconProcessor> { diff --git a/beacon_node/network/src/persisted_dht.rs b/beacon_node/network/src/persisted_dht.rs index 113b3cdd32..3672f97113 100644 --- a/beacon_node/network/src/persisted_dht.rs +++ b/beacon_node/network/src/persisted_dht.rs @@ -6,7 +6,7 @@ use types::{EthSpec, Hash256}; /// 32-byte key for accessing the `DhtEnrs`. All zero because `DhtEnrs` has its own column. pub const DHT_DB_KEY: Hash256 = Hash256::ZERO; -pub fn load_dht, Cold: ItemStore>( +pub fn load_dht( store: Arc>, ) -> Vec { // Load DHT from store @@ -20,7 +20,7 @@ pub fn load_dht, Cold: ItemStore>( } /// Attempt to persist the ENR's in the DHT to `self.store`. -pub fn persist_dht, Cold: ItemStore>( +pub fn persist_dht( store: Arc>, enrs: Vec, ) -> Result<(), store::Error> { @@ -28,7 +28,7 @@ pub fn persist_dht, Cold: ItemStore>( } /// Attempts to clear any DHT entries. -pub fn clear_dht, Cold: ItemStore>( +pub fn clear_dht( store: Arc>, ) -> Result<(), store::Error> { store.hot_db.delete::(&DHT_DB_KEY) @@ -75,11 +75,8 @@ mod tests { use types::{ChainSpec, MinimalEthSpec}; #[test] fn test_persisted_dht() { - let store: HotColdDB< - MinimalEthSpec, - MemoryStore, - MemoryStore, - > = HotColdDB::open_ephemeral(StoreConfig::default(), ChainSpec::minimal().into()).unwrap(); + let store: HotColdDB = + HotColdDB::open_ephemeral(StoreConfig::default(), ChainSpec::minimal().into()).unwrap(); let enrs = vec![Enr::from_str("enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8").unwrap()]; store .put_item(&DHT_DB_KEY, &PersistedDht { enrs: enrs.clone() }) diff --git a/beacon_node/network/src/subnet_service/tests/mod.rs b/beacon_node/network/src/subnet_service/tests/mod.rs index 619154d738..745934053a 100644 --- a/beacon_node/network/src/subnet_service/tests/mod.rs +++ b/beacon_node/network/src/subnet_service/tests/mod.rs @@ -25,12 +25,7 @@ const SLOT_DURATION_MILLIS: u64 = 400; const TEST_LOG_LEVEL: Option<&str> = None; -type TestBeaconChainType = Witness< - SystemTimeSlotClock, - MainnetEthSpec, - MemoryStore, - MemoryStore, ->; +type TestBeaconChainType = Witness; pub struct TestBeaconChain { chain: Arc>, diff --git a/beacon_node/network/src/sync/tests/mod.rs b/beacon_node/network/src/sync/tests/mod.rs index dd8c3ae432..4e185cc081 100644 --- a/beacon_node/network/src/sync/tests/mod.rs +++ b/beacon_node/network/src/sync/tests/mod.rs @@ -26,7 +26,7 @@ use types::{ForkName, Hash256, MinimalEthSpec as E, Slot}; mod lookups; mod range; -type T = Witness, MemoryStore>; +type T = Witness; /// This test utility enables integration testing of Lighthouse sync components. /// diff --git a/beacon_node/src/lib.rs b/beacon_node/src/lib.rs index e33da17e26..6400427f8c 100644 --- a/beacon_node/src/lib.rs +++ b/beacon_node/src/lib.rs @@ -20,7 +20,7 @@ use types::{ChainSpec, Epoch, EthSpec, ForkName}; /// A type-alias to the tighten the definition of a production-intended `Client`. pub type ProductionClient = - Client, BeaconNodeBackend>>; + Client>; /// The beacon node `Client` that is used in production. /// diff --git a/beacon_node/store/src/database/interface.rs b/beacon_node/store/src/database/interface.rs index 5646f1179c..7e0a09a3e9 100644 --- a/beacon_node/store/src/database/interface.rs +++ b/beacon_node/store/src/database/interface.rs @@ -6,18 +6,17 @@ use crate::{ColumnIter, ColumnKeyIter, DBColumn, Error, ItemStore, Key, KeyValue use crate::{KeyValueStoreOp, StoreConfig, config::DatabaseBackend}; use std::collections::HashSet; use std::path::Path; -use types::EthSpec; -pub enum BeaconNodeBackend { +pub enum BeaconNodeBackend { #[cfg(feature = "leveldb")] - LevelDb(leveldb_impl::LevelDB), + LevelDb(leveldb_impl::LevelDB), #[cfg(feature = "redb")] - Redb(redb_impl::Redb), + Redb(redb_impl::Redb), } -impl ItemStore for BeaconNodeBackend {} +impl ItemStore for BeaconNodeBackend {} -impl KeyValueStore for BeaconNodeBackend { +impl KeyValueStore for BeaconNodeBackend { fn get_bytes(&self, column: DBColumn, key: &[u8]) -> Result>, Error> { match self { #[cfg(feature = "leveldb")] @@ -183,7 +182,7 @@ impl KeyValueStore for BeaconNodeBackend { } } -impl BeaconNodeBackend { +impl BeaconNodeBackend { pub fn open(config: &StoreConfig, path: &Path) -> Result { metrics::inc_counter_vec(&metrics::DISK_DB_TYPE, &[&config.backend.to_string()]); match config.backend { diff --git a/beacon_node/store/src/database/leveldb_impl.rs b/beacon_node/store/src/database/leveldb_impl.rs index 6e01648263..0531eb900e 100644 --- a/beacon_node/store/src/database/leveldb_impl.rs +++ b/beacon_node/store/src/database/leveldb_impl.rs @@ -15,15 +15,13 @@ use leveldb::{ options::{Options, ReadOptions}, }; use std::collections::HashSet; -use std::marker::PhantomData; use std::path::Path; -use types::{EthSpec, Hash256}; +use types::Hash256; use super::interface::WriteOptions; -pub struct LevelDB { +pub struct LevelDB { db: Database, - _phantom: PhantomData, } impl From for leveldb::options::WriteOptions { @@ -34,7 +32,7 @@ impl From for leveldb::options::WriteOptions { } } -impl LevelDB { +impl LevelDB { pub fn open(path: &Path) -> Result { let mut options = Options::new(); @@ -42,10 +40,7 @@ impl LevelDB { let db = Database::open(path, options)?; - Ok(Self { - db, - _phantom: PhantomData, - }) + Ok(Self { db }) } pub fn read_options(&self) -> ReadOptions<'_, BytesKey> { diff --git a/beacon_node/store/src/database/redb_impl.rs b/beacon_node/store/src/database/redb_impl.rs index 4077326eca..dc39f22114 100644 --- a/beacon_node/store/src/database/redb_impl.rs +++ b/beacon_node/store/src/database/redb_impl.rs @@ -3,17 +3,15 @@ use crate::{DBColumn, Error, KeyValueStoreOp}; use parking_lot::RwLock; use redb::TableDefinition; use std::collections::HashSet; -use std::{borrow::BorrowMut, marker::PhantomData, path::Path}; +use std::{borrow::BorrowMut, path::Path}; use strum::IntoEnumIterator; -use types::EthSpec; use super::interface::WriteOptions; pub const DB_FILE_NAME: &str = "database.redb"; -pub struct Redb { +pub struct Redb { db: RwLock, - _phantom: PhantomData, } impl From for redb::Durability { @@ -26,19 +24,16 @@ impl From for redb::Durability { } } -impl Redb { +impl Redb { pub fn open(path: &Path) -> Result { let db_file = path.join(DB_FILE_NAME); let db = redb::Database::create(db_file)?; for column in DBColumn::iter() { - Redb::::create_table(&db, column.into())?; + Self::create_table(&db, column.into())?; } - Ok(Self { - db: db.into(), - _phantom: PhantomData, - }) + Ok(Self { db: db.into() }) } fn create_table(db: &redb::Database, table_name: &str) -> Result<(), Error> { diff --git a/beacon_node/store/src/forwards_iter.rs b/beacon_node/store/src/forwards_iter.rs index 255b7d8eac..ef4312f506 100644 --- a/beacon_node/store/src/forwards_iter.rs +++ b/beacon_node/store/src/forwards_iter.rs @@ -9,7 +9,7 @@ pub type HybridForwardsBlockRootsIterator<'a, E, Hot, Cold> = pub type HybridForwardsStateRootsIterator<'a, E, Hot, Cold> = HybridForwardsIterator<'a, E, Hot, Cold>; -impl, Cold: ItemStore> HotColdDB { +impl HotColdDB { fn simple_forwards_iterator( &self, column: DBColumn, @@ -116,7 +116,7 @@ impl, Cold: ItemStore> HotColdDB } /// Forwards root iterator that makes use of a slot -> root mapping in the freezer DB. -pub struct FrozenForwardsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { +pub struct FrozenForwardsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { inner: ColumnIter<'a, Vec>, column: DBColumn, next_slot: Slot, @@ -124,9 +124,7 @@ pub struct FrozenForwardsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemS _phantom: PhantomData<(E, Hot, Cold)>, } -impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> - FrozenForwardsIterator<'a, E, Hot, Cold> -{ +impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> FrozenForwardsIterator<'a, E, Hot, Cold> { /// `end_slot` is EXCLUSIVE here. pub fn new( store: &'a HotColdDB, @@ -148,7 +146,7 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> } } -impl, Cold: ItemStore> Iterator +impl Iterator for FrozenForwardsIterator<'_, E, Hot, Cold> { type Item = Result<(Hash256, Slot)>; @@ -199,7 +197,7 @@ impl Iterator for SimpleForwardsIterator { } /// Fusion of the above two approaches to forwards iteration. Fast and efficient. -pub enum HybridForwardsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { +pub enum HybridForwardsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { PreFinalization { iter: Box>, store: &'a HotColdDB, @@ -220,9 +218,7 @@ pub enum HybridForwardsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemSto Finished, } -impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> - HybridForwardsIterator<'a, E, Hot, Cold> -{ +impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> HybridForwardsIterator<'a, E, Hot, Cold> { /// Construct a new hybrid iterator. /// /// The `get_state` closure should return a beacon state and final block/state root to backtrack @@ -349,7 +345,7 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> } } -impl, Cold: ItemStore> Iterator +impl Iterator for HybridForwardsIterator<'_, E, Hot, Cold> { type Item = Result<(Hash256, Slot)>; diff --git a/beacon_node/store/src/hot_cold_store.rs b/beacon_node/store/src/hot_cold_store.rs index e9b9de76e6..a625a97004 100644 --- a/beacon_node/store/src/hot_cold_store.rs +++ b/beacon_node/store/src/hot_cold_store.rs @@ -49,7 +49,7 @@ use zstd::{Decoder, Encoder}; /// Stores vector fields like the `block_roots` and `state_roots` separately, and only stores /// intermittent "restore point" states pre-finalization. #[derive(Debug)] -pub struct HotColdDB, Cold: ItemStore> { +pub struct HotColdDB { /// The slot and state root at the point where the database is split between hot and cold. /// /// States with slots less than `split.slot` are in the cold DB, while states with slots @@ -217,11 +217,11 @@ pub enum HotColdDBError { Rollback, } -impl HotColdDB, MemoryStore> { +impl HotColdDB { pub fn open_ephemeral( config: StoreConfig, spec: Arc, - ) -> Result, MemoryStore>, Error> { + ) -> Result, Error> { config.verify::()?; let hierarchy = config.hierarchy_config.to_moduli()?; @@ -258,7 +258,7 @@ impl HotColdDB, MemoryStore> { } } -impl HotColdDB, BeaconNodeBackend> { +impl HotColdDB { /// Open a new or existing database, with the given paths to the hot and cold DBs. /// /// The `migrate_schema` function is passed in so that the parent `BeaconChain` can provide @@ -451,7 +451,7 @@ impl HotColdDB, BeaconNodeBackend> { } } -impl, Cold: ItemStore> HotColdDB { +impl HotColdDB { fn cold_storage_strategy(&self, slot: Slot) -> Result { // The start slot for the freezer HDiff is always 0 Ok(self.hierarchy.storage_strategy(slot, Slot::new(0))?) @@ -3575,7 +3575,7 @@ impl, Cold: ItemStore> HotColdDB /// This function previously did a combination of freezer migration alongside pruning. Now it is /// *just* responsible for copying relevant data to the freezer, while pruning is implemented /// in `prune_hot_db`. -pub fn migrate_database, Cold: ItemStore>( +pub fn migrate_database( store: Arc>, finalized_state_root: Hash256, finalized_block_root: Hash256, @@ -3786,7 +3786,7 @@ pub enum StateSummaryIteratorError { /// Return the ancestor state root of a state beyond SlotsPerHistoricalRoot using the roots iterator /// and the store -pub fn get_ancestor_state_root<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore>( +pub fn get_ancestor_state_root<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore>( store: &'a HotColdDB, from_state: &'a BeaconState, target_slot: Slot, @@ -3993,7 +3993,7 @@ impl StoreItem for HotStateSummary { impl HotStateSummary { /// Construct a new summary of the given state. - pub fn new, Cold: ItemStore>( + pub fn new( store: &HotColdDB, state_root: Hash256, state: &BeaconState, diff --git a/beacon_node/store/src/invariants.rs b/beacon_node/store/src/invariants.rs index d251fb8800..4ec72b82bd 100644 --- a/beacon_node/store/src/invariants.rs +++ b/beacon_node/store/src/invariants.rs @@ -242,7 +242,7 @@ pub enum InvariantViolation { ColdStateBaseSummaryMissing { slot: Slot, base_slot: Slot }, } -impl, Cold: ItemStore> HotColdDB { +impl HotColdDB { /// Run all database invariant checks. /// /// The `ctx` parameter provides data from the beacon chain layer (fork choice, state cache, diff --git a/beacon_node/store/src/iter.rs b/beacon_node/store/src/iter.rs index 0cb803d1ed..cf1ab86ffe 100644 --- a/beacon_node/store/src/iter.rs +++ b/beacon_node/store/src/iter.rs @@ -13,12 +13,12 @@ use types::{ /// /// It is assumed that all ancestors for this object are stored in the database. If this is not the /// case, the iterator will start returning `None` prior to genesis. -pub trait AncestorIter<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore, I: Iterator> { +pub trait AncestorIter<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore, I: Iterator> { /// Returns an iterator over the roots of the ancestors of `self`. fn try_iter_ancestor_roots(&self, store: &'a HotColdDB) -> Option; } -impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> +impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> AncestorIter<'a, E, Hot, Cold, BlockRootsIterator<'a, E, Hot, Cold>> for SignedBeaconBlock { /// Iterates across all available prior block roots of `self`, starting at the most recent and ending @@ -37,7 +37,7 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> } } -impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> +impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> AncestorIter<'a, E, Hot, Cold, StateRootsIterator<'a, E, Hot, Cold>> for BeaconState { /// Iterates across all available prior state roots of `self`, starting at the most recent and ending @@ -51,13 +51,11 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> } } -pub struct StateRootsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { +pub struct StateRootsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { inner: RootsIterator<'a, E, Hot, Cold>, } -impl, Cold: ItemStore> Clone - for StateRootsIterator<'_, E, Hot, Cold> -{ +impl Clone for StateRootsIterator<'_, E, Hot, Cold> { fn clone(&self) -> Self { Self { inner: self.inner.clone(), @@ -65,7 +63,7 @@ impl, Cold: ItemStore> Clone } } -impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> StateRootsIterator<'a, E, Hot, Cold> { +impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> StateRootsIterator<'a, E, Hot, Cold> { pub fn new(store: &'a HotColdDB, beacon_state: &'a BeaconState) -> Self { Self { inner: RootsIterator::new(store, beacon_state), @@ -79,7 +77,7 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> StateRootsIterator<' } } -impl, Cold: ItemStore> Iterator +impl Iterator for StateRootsIterator<'_, E, Hot, Cold> { type Item = Result<(Hash256, Slot), Error>; @@ -99,13 +97,11 @@ impl, Cold: ItemStore> Iterator /// exhausted. /// /// Returns `None` for roots prior to genesis or when there is an error reading from `Store`. -pub struct BlockRootsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { +pub struct BlockRootsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { inner: RootsIterator<'a, E, Hot, Cold>, } -impl, Cold: ItemStore> Clone - for BlockRootsIterator<'_, E, Hot, Cold> -{ +impl Clone for BlockRootsIterator<'_, E, Hot, Cold> { fn clone(&self) -> Self { Self { inner: self.inner.clone(), @@ -113,7 +109,7 @@ impl, Cold: ItemStore> Clone } } -impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> BlockRootsIterator<'a, E, Hot, Cold> { +impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> BlockRootsIterator<'a, E, Hot, Cold> { /// Create a new iterator over all block roots in the given `beacon_state` and prior states. pub fn new(store: &'a HotColdDB, beacon_state: &'a BeaconState) -> Self { Self { @@ -138,7 +134,7 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> BlockRootsIterator<' } } -impl, Cold: ItemStore> Iterator +impl Iterator for BlockRootsIterator<'_, E, Hot, Cold> { type Item = Result<(Hash256, Slot), Error>; @@ -151,13 +147,13 @@ impl, Cold: ItemStore> Iterator } /// Iterator over state and block roots that backtracks using the vectors from a `BeaconState`. -pub struct RootsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { +pub struct RootsIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { store: &'a HotColdDB, beacon_state: Cow<'a, BeaconState>, slot: Slot, } -impl, Cold: ItemStore> Clone for RootsIterator<'_, E, Hot, Cold> { +impl Clone for RootsIterator<'_, E, Hot, Cold> { fn clone(&self) -> Self { Self { store: self.store, @@ -167,7 +163,7 @@ impl, Cold: ItemStore> Clone for RootsIterator< } } -impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> RootsIterator<'a, E, Hot, Cold> { +impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> RootsIterator<'a, E, Hot, Cold> { pub fn new(store: &'a HotColdDB, beacon_state: &'a BeaconState) -> Self { Self { store, @@ -234,9 +230,7 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> RootsIterator<'a, E, } } -impl, Cold: ItemStore> Iterator - for RootsIterator<'_, E, Hot, Cold> -{ +impl Iterator for RootsIterator<'_, E, Hot, Cold> { /// (block_root, state_root, slot) type Item = Result<(Hash256, Hash256, Slot), Error>; @@ -246,15 +240,13 @@ impl, Cold: ItemStore> Iterator } /// Block iterator that uses the `parent_root` of each block to backtrack. -pub struct ParentRootBlockIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { +pub struct ParentRootBlockIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { store: &'a HotColdDB, next_block_root: Hash256, _phantom: PhantomData, } -impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> - ParentRootBlockIterator<'a, E, Hot, Cold> -{ +impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> ParentRootBlockIterator<'a, E, Hot, Cold> { pub fn new(store: &'a HotColdDB, start_block_root: Hash256) -> Self { Self { store, @@ -283,7 +275,7 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> } } -impl, Cold: ItemStore> Iterator +impl Iterator for ParentRootBlockIterator<'_, E, Hot, Cold> { type Item = Result<(Hash256, SignedBeaconBlock>), Error>; @@ -295,11 +287,11 @@ impl, Cold: ItemStore> Iterator #[derive(Clone)] /// Extends `BlockRootsIterator`, returning `SignedBeaconBlock` instances, instead of their roots. -pub struct BlockIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { +pub struct BlockIterator<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> { roots: BlockRootsIterator<'a, E, Hot, Cold>, } -impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> BlockIterator<'a, E, Hot, Cold> { +impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> BlockIterator<'a, E, Hot, Cold> { /// Create a new iterator over all blocks in the given `beacon_state` and prior states. pub fn new(store: &'a HotColdDB, beacon_state: &'a BeaconState) -> Self { Self { @@ -324,9 +316,7 @@ impl<'a, E: EthSpec, Hot: ItemStore, Cold: ItemStore> BlockIterator<'a, E, } } -impl, Cold: ItemStore> Iterator - for BlockIterator<'_, E, Hot, Cold> -{ +impl Iterator for BlockIterator<'_, E, Hot, Cold> { type Item = Result>, Error>; fn next(&mut self) -> Option { @@ -338,7 +328,7 @@ impl, Cold: ItemStore> Iterator /// /// Return `Err(HistoryUnavailable)` in the case where no more backtrack states are available /// due to weak subjectivity sync. -fn next_historical_root_backtrack_state, Cold: ItemStore>( +fn next_historical_root_backtrack_state( store: &HotColdDB, current_state: &BeaconState, ) -> Result, Error> { @@ -386,7 +376,7 @@ mod test { harness.get_current_state() } - fn get_store() -> HotColdDB, MemoryStore> { + fn get_store() -> HotColdDB { let store = HotColdDB::open_ephemeral(Config::default(), Arc::new(E::default_spec())).unwrap(); // Init achor info so anchor slot is set. Use a random block as it is only used for the diff --git a/beacon_node/store/src/lib.rs b/beacon_node/store/src/lib.rs index bd8caa3ad5..56cdd18fbe 100644 --- a/beacon_node/store/src/lib.rs +++ b/beacon_node/store/src/lib.rs @@ -46,7 +46,7 @@ pub type ColumnKeyIter<'a, K> = Box> + 'a>; pub type RawEntryIter<'a> = Result, Vec), Error>> + 'a>, Error>; -pub trait KeyValueStore: Sync + Send + Sized + 'static { +pub trait KeyValueStore: Sync + Send + Sized + 'static { /// Retrieve some bytes in `column` with `key`. fn get_bytes(&self, column: DBColumn, key: &[u8]) -> Result>, Error>; @@ -177,7 +177,7 @@ pub enum KeyValueStoreOp { DeleteKey(DBColumn, Vec), } -pub trait ItemStore: KeyValueStore + Sync + Send + Sized + 'static { +pub trait ItemStore: KeyValueStore + Sync + Send + Sized + 'static { /// Store an item in `Self`. fn put(&self, key: &Hash256, item: &I) -> Result<(), Error> { let column = I::db_column(); @@ -493,7 +493,7 @@ mod tests { } } - fn test_impl(store: impl ItemStore) { + fn test_impl(store: impl ItemStore) { let key = Hash256::random(); let item = StorableThing { a: 1, b: 42 }; @@ -531,7 +531,7 @@ mod tests { #[test] fn exists() { - let store = MemoryStore::::open(); + let store = MemoryStore::open(); let key = Hash256::random(); let item = StorableThing { a: 1, b: 42 }; diff --git a/beacon_node/store/src/memory_store.rs b/beacon_node/store/src/memory_store.rs index 6baef61c9d..8be9278d90 100644 --- a/beacon_node/store/src/memory_store.rs +++ b/beacon_node/store/src/memory_store.rs @@ -4,28 +4,24 @@ use crate::{ }; use parking_lot::RwLock; use std::collections::{BTreeMap, HashSet}; -use std::marker::PhantomData; -use types::*; type DBMap = BTreeMap>; /// A thread-safe `BTreeMap` wrapper. -pub struct MemoryStore { +pub struct MemoryStore { db: RwLock, - _phantom: PhantomData, } -impl MemoryStore { +impl MemoryStore { /// Create a new, empty database. pub fn open() -> Self { Self { db: RwLock::new(BTreeMap::new()), - _phantom: PhantomData, } } } -impl KeyValueStore for MemoryStore { +impl KeyValueStore for MemoryStore { /// Get the value of some key from the database. Returns `None` if the key does not exist. fn get_bytes(&self, col: DBColumn, key: &[u8]) -> Result>, Error> { let column_key = BytesKey::from_vec(get_key_for_col(col, key)); @@ -148,4 +144,4 @@ impl KeyValueStore for MemoryStore { } } -impl ItemStore for MemoryStore {} +impl ItemStore for MemoryStore {} diff --git a/beacon_node/store/src/reconstruct.rs b/beacon_node/store/src/reconstruct.rs index 04a519af02..2fb40daa0d 100644 --- a/beacon_node/store/src/reconstruct.rs +++ b/beacon_node/store/src/reconstruct.rs @@ -15,8 +15,8 @@ use types::{EthSpec, Slot}; impl HotColdDB where E: EthSpec, - Hot: ItemStore, - Cold: ItemStore, + Hot: ItemStore, + Cold: ItemStore, { pub fn reconstruct_historic_states( self: &Arc, diff --git a/consensus/fork_choice/tests/tests.rs b/consensus/fork_choice/tests/tests.rs index 353893026b..848834b4d8 100644 --- a/consensus/fork_choice/tests/tests.rs +++ b/consensus/fork_choice/tests/tests.rs @@ -79,7 +79,7 @@ impl ForkChoiceTest { /// Get a value from the `ForkChoice` instantiation. fn get(&self, func: T) -> U where - T: Fn(&BeaconForkChoiceStore, MemoryStore>) -> U, + T: Fn(&BeaconForkChoiceStore) -> U, { func( self.harness diff --git a/database_manager/src/lib.rs b/database_manager/src/lib.rs index 608400fa7e..2509b500e0 100644 --- a/database_manager/src/lib.rs +++ b/database_manager/src/lib.rs @@ -55,7 +55,7 @@ pub fn display_db_version( let blobs_path = client_config.get_blobs_db_path(); let mut version = CURRENT_SCHEMA_VERSION; - HotColdDB::, BeaconNodeBackend>::open( + HotColdDB::::open( &hot_path, &cold_path, &blobs_path, @@ -143,13 +143,13 @@ pub fn inspect_db( let mut num_keys = 0; let sub_db = if inspect_config.freezer { - BeaconNodeBackend::::open(&client_config.store, &cold_path) + BeaconNodeBackend::open(&client_config.store, &cold_path) .map_err(|e| format!("Unable to open freezer DB: {e:?}"))? } else if inspect_config.blobs_db { - BeaconNodeBackend::::open(&client_config.store, &blobs_path) + BeaconNodeBackend::open(&client_config.store, &blobs_path) .map_err(|e| format!("Unable to open blobs DB: {e:?}"))? } else { - BeaconNodeBackend::::open(&client_config.store, &hot_path) + BeaconNodeBackend::open(&client_config.store, &hot_path) .map_err(|e| format!("Unable to open hot DB: {e:?}"))? }; @@ -264,17 +264,17 @@ pub fn compact_db( let (sub_db, db_name) = if compact_config.freezer { ( - BeaconNodeBackend::::open(&client_config.store, &cold_path)?, + BeaconNodeBackend::open(&client_config.store, &cold_path)?, "freezer_db", ) } else if compact_config.blobs_db { ( - BeaconNodeBackend::::open(&client_config.store, &blobs_path)?, + BeaconNodeBackend::open(&client_config.store, &blobs_path)?, "blobs_db", ) } else { ( - BeaconNodeBackend::::open(&client_config.store, &hot_path)?, + BeaconNodeBackend::open(&client_config.store, &hot_path)?, "hot_db", ) }; @@ -309,7 +309,7 @@ pub fn migrate_db( let mut from = CURRENT_SCHEMA_VERSION; let to = migrate_config.to; - let db = HotColdDB::, BeaconNodeBackend>::open( + let db = HotColdDB::::open( &hot_path, &cold_path, &blobs_path, @@ -339,7 +339,7 @@ pub fn prune_payloads( let cold_path = client_config.get_freezer_db_path(); let blobs_path = client_config.get_blobs_db_path(); - let db = HotColdDB::, BeaconNodeBackend>::open( + let db = HotColdDB::::open( &hot_path, &cold_path, &blobs_path, @@ -363,7 +363,7 @@ pub fn prune_blobs( let cold_path = client_config.get_freezer_db_path(); let blobs_path = client_config.get_blobs_db_path(); - let db = HotColdDB::, BeaconNodeBackend>::open( + let db = HotColdDB::::open( &hot_path, &cold_path, &blobs_path, @@ -398,7 +398,7 @@ pub fn prune_states( let cold_path = client_config.get_freezer_db_path(); let blobs_path = client_config.get_blobs_db_path(); - let db = HotColdDB::, BeaconNodeBackend>::open( + let db = HotColdDB::::open( &hot_path, &cold_path, &blobs_path, From b5d5644eebcb889b025c13b084c30ac1025adb59 Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Thu, 21 May 2026 22:00:16 +0200 Subject: [PATCH 179/189] Add getBlobsV3 to `LIGHTHOUSE_CAPABILITIES` (#9330) Forgot to add `ENGINE_GET_BLOBS_V3` to `LIGHTHOUSE_CAPABILITIES`. Add `ENGINE_GET_BLOBS_V3` to `LIGHTHOUSE_CAPABILITIES`. Co-Authored-By: Daniel Knopik --- beacon_node/execution_layer/src/engine_api/http.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/beacon_node/execution_layer/src/engine_api/http.rs b/beacon_node/execution_layer/src/engine_api/http.rs index 110e155c77..7c63f78a22 100644 --- a/beacon_node/execution_layer/src/engine_api/http.rs +++ b/beacon_node/execution_layer/src/engine_api/http.rs @@ -94,6 +94,7 @@ pub static LIGHTHOUSE_CAPABILITIES: &[&str] = &[ ENGINE_GET_CLIENT_VERSION_V1, ENGINE_GET_BLOBS_V1, ENGINE_GET_BLOBS_V2, + ENGINE_GET_BLOBS_V3, ]; /// We opt to initialize the JsonClientVersionV1 rather than the ClientVersionV1 From 60abd4b5b985f5ef47baa799c43c085521e3e596 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Thu, 21 May 2026 23:21:20 -0700 Subject: [PATCH 180/189] Gloas alpha spec 8 (#9315) https://github.com/ethereum/consensus-specs/releases/tag/v1.7.0-alpha.8 Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Michael Sproul --- beacon_node/beacon_chain/src/beacon_chain.rs | 30 ++- .../src/block_production/gloas.rs | 64 +++-- .../beacon_chain/src/execution_payload.rs | 20 +- .../gossip_verified_bid.rs | 98 +++++++- .../src/payload_bid_verification/mod.rs | 2 +- .../src/payload_bid_verification/tests.rs | 15 +- .../gossip_verified_proposer_preferences.rs | 47 +++- .../proposer_preferences_verification/mod.rs | 2 +- .../proposer_preference_cache.rs | 2 +- .../tests.rs | 6 +- .../tests/payload_invalidation.rs | 1 + beacon_node/execution_layer/src/engine_api.rs | 43 ++-- .../src/engine_api/json_structures.rs | 5 + beacon_node/execution_layer/src/lib.rs | 9 +- .../src/test_utils/mock_builder.rs | 7 +- .../src/test_utils/mock_execution_layer.rs | 7 +- beacon_node/http_api/tests/tests.rs | 2 +- .../gossip_methods.rs | 2 +- .../mainnet/config.yaml | 4 +- consensus/fork_choice/src/fork_choice.rs | 96 +++++--- consensus/proto_array/src/error.rs | 5 + .../src/fork_choice_test_definition.rs | 6 +- consensus/proto_array/src/proto_array.rs | 99 ++++++-- .../src/proto_array_fork_choice.rs | 30 +++ .../process_operations.rs | 44 ++-- .../state_processing/src/upgrade/gloas.rs | 94 ++++--- consensus/types/configs/mainnet.yaml | 4 +- .../types/src/builder/proposer_preferences.rs | 2 +- consensus/types/src/core/chain_spec.rs | 92 ++++++- consensus/types/src/state/beacon_state.rs | 4 +- testing/ef_tests/Makefile | 2 +- testing/ef_tests/src/cases/fork_choice.rs | 233 +++++++++++++++++- testing/ef_tests/src/handler.rs | 13 +- testing/ef_tests/tests/tests.rs | 6 + .../src/test_rig.rs | 8 +- .../src/proposer_preferences_service.rs | 2 +- 36 files changed, 863 insertions(+), 243 deletions(-) diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index 2259e1d809..db8f55a18a 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -96,8 +96,8 @@ use eth2::types::{ SseExtendedPayloadAttributes, SseHead, }; use execution_layer::{ - BlockProposalContents, BlockProposalContentsType, BuilderParams, ChainHealth, ExecutionLayer, - FailedCondition, PayloadAttributes, PayloadStatus, + BlockProposalContents, BlockProposalContentsType, BuilderParams, ChainHealth, + DEFAULT_GAS_LIMIT, ExecutionLayer, FailedCondition, PayloadAttributes, PayloadStatus, }; use fixed_bytes::FixedBytesExtended; use fork_choice::{ @@ -2185,12 +2185,20 @@ impl BeaconChain { // TODO(gloas) do we want to use a dedicated envelope cache instead? // Maybe the new gloas DA cache? (Or should the gloas DA cache use - // the envelopes_times_cache internally?) + // the envelopes_times_cache internally? + // The payload is considered present only if it was observed before + // the payload due deadline (PAYLOAD_DUE_BPS into the slot). + let payload_due = self.spec.get_payload_due(); let payload_present = self .envelope_times_cache .read() .cache - .contains_key(&beacon_block_root); + .get(&beacon_block_root) + .and_then(|entry| entry.timestamps.observed) + .is_some_and(|observed| { + let slot_start = self.slot_clock.start_of(request_slot); + slot_start.is_some_and(|start| observed.saturating_sub(start) < payload_due) + }); // TODO(EIP-7732): Check blob data availability. For now, default to true. let blob_data_available = true; @@ -6476,6 +6484,19 @@ impl BeaconChain { None }; + let target_gas_limit = if prepare_slot_fork.gloas_enabled() { + let proposer_gas_limit = execution_layer.get_proposer_gas_limit(proposer).await; + if proposer_gas_limit.is_none() { + warn!( + %proposer, + "No proposer gas limit configured, falling back to parent gas limit" + ); + } + proposer_gas_limit.or(Some(DEFAULT_GAS_LIMIT)) + } else { + None + }; + let payload_attributes = PayloadAttributes::new( self.slot_clock .start_of(prepare_slot) @@ -6486,6 +6507,7 @@ impl BeaconChain { withdrawals.map(Into::into), parent_beacon_block_root, slot_number, + target_gas_limit, ); execution_layer diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 6510c20ba7..82dad6f6ad 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -2,11 +2,13 @@ use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; use std::sync::Arc; +use proto_array::PayloadStatus; + use bls::{PublicKeyBytes, Signature}; use execution_layer::{ - BlockProposalContentsGloas, BuilderParams, PayloadAttributes, PayloadParameters, + BlockProposalContentsGloas, BuilderParams, DEFAULT_GAS_LIMIT, PayloadAttributes, + PayloadParameters, }; -use fork_choice::PayloadStatus; use operation_pool::CompactAttestationRef; use ssz::Encode; use state_processing::common::{get_attesting_indices_from_state, get_indexed_payload_attestation}; @@ -150,8 +152,24 @@ impl BeaconChain { verification: ProduceBlockVerification, builder_boost_factor: Option, ) -> Result, BlockProductionError> { - // Extract the parent's execution requests from the envelope (if parent was full). - let parent_execution_requests = if parent_payload_status == PayloadStatus::Full { + let parent_root = if state.slot() > 0 { + *state + .get_block_root(state.slot() - 1) + .map_err(|_| BlockProductionError::UnableToGetBlockRootFromState)? + } else { + state.latest_block_header().canonical_root() + }; + + let should_build_on_full = self + .canonical_head + .fork_choice_read_lock() + .should_build_on_full(&parent_root, parent_payload_status) + .map_err(|e| { + BlockProductionError::BeaconChain(Box::new(BeaconChainError::ForkChoiceError(e))) + })?; + + // Extract the parent's execution requests from the envelope (if building on full). + let parent_execution_requests = if should_build_on_full { parent_envelope .as_ref() .map(|env| env.message.execution_requests.clone()) @@ -197,7 +215,7 @@ impl BeaconChain { .clone() .produce_execution_payload_bid( state, - parent_payload_status, + should_build_on_full, parent_envelope, produce_at_slot, BID_VALUE_SELF_BUILD, @@ -700,12 +718,12 @@ impl BeaconChain { /// data needed to construct the `ExecutionPayloadEnvelope` after the beacon block is /// created, plus the EL block value and `should_override_builder` flag used by the /// caller to compare against any cached p2p builder bid. - #[allow(clippy::type_complexity)] + #[allow(clippy::type_complexity, clippy::too_many_arguments)] #[instrument(level = "debug", skip_all)] pub async fn produce_execution_payload_bid( self: Arc, state: BeaconState, - parent_payload_status: PayloadStatus, + should_build_on_full: bool, parent_envelope: Option>>, produce_at_slot: Slot, bid_value: u64, @@ -751,20 +769,18 @@ impl BeaconChain { let parent_bid = state.latest_execution_payload_bid()?; - // TODO(gloas): need should_extend_payload check here as well let parent_block_slot = state.latest_block_header().slot; let parent_is_pre_gloas = !self .spec .fork_name_at_slot::(parent_block_slot) .gloas_enabled(); - let parent_block_hash = - if parent_payload_status == PayloadStatus::Full || parent_is_pre_gloas { - // Build on parent bid's payload. - parent_bid.block_hash - } else { - // Skip parent bid's payload. For genesis this is the EL genesis hash. - parent_bid.parent_block_hash - }; + let parent_block_hash = if should_build_on_full || parent_is_pre_gloas { + // Build on parent bid's payload. + parent_bid.block_hash + } else { + // Skip parent bid's payload. For genesis this is the EL genesis hash. + parent_bid.parent_block_hash + }; // TODO(gloas) this should be BlockProductionVersion::V4 // V3 is okay for now as long as we're not connected to a builder @@ -953,10 +969,7 @@ fn get_execution_payload_gloas( compute_timestamp_at_slot(state, state.slot(), spec).map_err(BeaconStateError::from)?; let random = *state.get_randao_mix(current_epoch)?; - // TODO(gloas): this gas limit calc is not necessarily right let parent_bid = state.latest_execution_payload_bid()?; - let latest_gas_limit = parent_bid.gas_limit; - let is_parent_block_full = parent_block_hash == parent_bid.block_hash; let withdrawals = if is_parent_block_full { @@ -992,7 +1005,6 @@ fn get_execution_payload_gloas( random, proposer_index, parent_block_hash, - latest_gas_limit, builder_params, withdrawals, parent_beacon_block_root, @@ -1020,7 +1032,6 @@ async fn prepare_execution_payload( random: Hash256, proposer_index: u64, parent_block_hash: ExecutionBlockHash, - parent_gas_limit: u64, builder_params: BuilderParams, withdrawals: Vec, parent_beacon_block_root: Hash256, @@ -1058,6 +1069,10 @@ where .get_suggested_fee_recipient(proposer_index) .await; let slot_number = Some(builder_params.slot.as_u64()); + let target_gas_limit = execution_layer + .get_proposer_gas_limit(proposer_index) + .await + .unwrap_or(DEFAULT_GAS_LIMIT); let payload_attributes = PayloadAttributes::new( timestamp, @@ -1066,13 +1081,12 @@ where Some(withdrawals), Some(parent_beacon_block_root), slot_number, + Some(target_gas_limit), ); - - let target_gas_limit = execution_layer.get_proposer_gas_limit(proposer_index).await; let payload_parameters = PayloadParameters { parent_hash: parent_block_hash, - parent_gas_limit, - proposer_gas_limit: target_gas_limit, + parent_gas_limit: None, + proposer_gas_limit: Some(target_gas_limit), payload_attributes: &payload_attributes, forkchoice_update_params: &forkchoice_update_params, current_fork: fork, diff --git a/beacon_node/beacon_chain/src/execution_payload.rs b/beacon_node/beacon_chain/src/execution_payload.rs index 16542eea2d..c8976fc6a8 100644 --- a/beacon_node/beacon_chain/src/execution_payload.rs +++ b/beacon_node/beacon_chain/src/execution_payload.rs @@ -342,7 +342,7 @@ pub fn get_execution_payload( Ok(join_handle) } -/// Prepares an execution payload for inclusion in a block. +/// Prepares an execution payload (pre-gloas) for inclusion in a block. /// /// ## Errors /// @@ -373,6 +373,13 @@ where { let spec = &chain.spec; let fork = spec.fork_name_at_slot::(builder_params.slot); + + if fork.gloas_enabled() { + return Err(BlockProductionError::InvalidBlockVariant( + "Called pre-gloas prepare_execution_payload on a gloas block".to_string(), + )); + } + let execution_layer = chain .execution_layer .as_ref() @@ -403,25 +410,20 @@ where .get_suggested_fee_recipient(proposer_index) .await; - let slot_number = if fork.gloas_enabled() { - Some(builder_params.slot.as_u64()) - } else { - None - }; - let payload_attributes = PayloadAttributes::new( timestamp, random, suggested_fee_recipient, withdrawals, parent_beacon_block_root, - slot_number, + None, + None, ); let target_gas_limit = execution_layer.get_proposer_gas_limit(proposer_index).await; let payload_parameters = PayloadParameters { parent_hash, - parent_gas_limit: latest_execution_payload_header_gas_limit, + parent_gas_limit: Some(latest_execution_payload_header_gas_limit), proposer_gas_limit: target_gas_limit, payload_attributes: &payload_attributes, forkchoice_update_params: &forkchoice_update_params, diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs index 1f3f074598..354705b92c 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs @@ -43,9 +43,6 @@ pub(crate) fn verify_bid_consistency( if bid.fee_recipient != proposer_preferences.message.fee_recipient { return Err(PayloadBidError::InvalidFeeRecipient); } - if bid.gas_limit != proposer_preferences.message.gas_limit { - return Err(PayloadBidError::InvalidGasLimit); - } let max_blobs_per_block = spec.max_blobs_per_block(bid_slot.epoch(E::slots_per_epoch())) as usize; @@ -161,7 +158,23 @@ impl GossipVerifiedPayloadBid { }); } - // TODO(gloas) [IGNORE] bid.parent_block_hash is the block hash of a known execution payload in fork choice. + // TODO(gloas): [IGNORE] bid.parent_block_hash is the block hash of a known execution + // payload in fork choice. + + // TODO(gloas): This uses head state's bid gas_limit as parent_gas_limit, which is only + // correct when the bid's parent is the head. If the parent is an ancestor further back + // this check may be inaccurate. Fixing this requires storing + // gas_limit in fork choice or looking it up from the store by parent_block_hash. Taking the above + // TODO into consideration maybe should persist parent block hash and gas limit in fork choice? + if let Ok(parent_bid) = head_state.latest_execution_payload_bid() + && !is_gas_limit_target_compatible( + parent_bid.gas_limit, + signed_bid.message.gas_limit, + proposer_preferences.message.target_gas_limit, + )? + { + return Err(PayloadBidError::InvalidGasLimit); + } drop(fork_choice); @@ -263,8 +276,36 @@ impl BeaconChain { } } +/// Check if `gas_limit` is compatible with `target_gas_limit` under the +/// EIP-1559 transition rule from `parent_gas_limit`. +pub fn is_gas_limit_target_compatible( + parent_gas_limit: u64, + gas_limit: u64, + target_gas_limit: u64, +) -> Result { + let max_gas_limit_difference = (parent_gas_limit / 1024) + .max(1) + .checked_sub(1) + .ok_or(PayloadBidError::InvalidGasLimit)?; + let min_gas_limit = parent_gas_limit + .checked_sub(max_gas_limit_difference) + .ok_or(PayloadBidError::InvalidGasLimit)?; + let max_gas_limit = parent_gas_limit + .checked_add(max_gas_limit_difference) + .ok_or(PayloadBidError::InvalidGasLimit)?; + + if target_gas_limit >= min_gas_limit && target_gas_limit <= max_gas_limit { + Ok(gas_limit == target_gas_limit) + } else if target_gas_limit > max_gas_limit { + Ok(gas_limit == max_gas_limit) + } else { + Ok(gas_limit == min_gas_limit) + } +} + #[cfg(test)] mod tests { + use super::is_gas_limit_target_compatible; use bls::Signature; use kzg::KzgCommitment; use ssz_types::VariableList; @@ -288,11 +329,14 @@ mod tests { } } - fn make_preferences(fee_recipient: Address, gas_limit: u64) -> SignedProposerPreferences { + fn make_preferences( + fee_recipient: Address, + target_gas_limit: u64, + ) -> SignedProposerPreferences { SignedProposerPreferences { message: ProposerPreferences { fee_recipient, - gas_limit, + target_gas_limit, ..ProposerPreferences::default() }, signature: Signature::empty(), @@ -382,13 +426,41 @@ mod tests { } #[test] - fn test_gas_limit_mismatch() { - let (state, spec) = state_and_spec(); - let current_slot = Slot::new(10); - let bid = make_bid(current_slot, Address::ZERO, 30_000_000); - let prefs = make_preferences(Address::ZERO, 50_000_000); + fn test_is_gas_limit_target_compatible_increase_within_limit() { + assert!(is_gas_limit_target_compatible(60_000_000, 60_000_100, 60_000_100).unwrap()); + } - let result = verify_bid_consistency::(&bid, current_slot, &prefs, &state, &spec); - assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); + #[test] + fn test_is_gas_limit_target_compatible_increase_exceeding_limit() { + // max_diff = 60_000_000 / 1024 - 1 = 58_592 + // max_gas_limit = 60_000_000 + 58_592 = 60_058_592 + assert!(is_gas_limit_target_compatible(60_000_000, 60_058_592, 100_000_000).unwrap()); + } + + #[test] + fn test_is_gas_limit_target_compatible_increase_exceeding_off_by_one() { + assert!(!is_gas_limit_target_compatible(60_000_000, 60_058_593, 100_000_000).unwrap()); + } + + #[test] + fn test_is_gas_limit_target_compatible_decrease_within_limit() { + assert!(is_gas_limit_target_compatible(60_000_000, 59_999_990, 59_999_990).unwrap()); + } + + #[test] + fn test_is_gas_limit_target_compatible_decrease_exceeding_limit() { + // min_gas_limit = 60_000_000 - 58_592 = 59_941_408 + assert!(is_gas_limit_target_compatible(60_000_000, 59_941_408, 30_000_000).unwrap()); + } + + #[test] + fn test_is_gas_limit_target_compatible_target_equals_parent() { + assert!(is_gas_limit_target_compatible(60_000_000, 60_000_000, 60_000_000).unwrap()); + } + + #[test] + fn test_is_gas_limit_target_compatible_parent_underflows() { + // parent=1023: max(1023/1024, 1) - 1 = max(0, 1) - 1 = 0, no change allowed + assert!(is_gas_limit_target_compatible(1023, 1023, 60_000_000).unwrap()); } } diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs index 514695f5c0..a40fd14872 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -48,7 +48,7 @@ pub enum PayloadBidError { }, /// The bids fee recipient doesn't match the proposer preferences fee recipient. InvalidFeeRecipient, - /// The bids gas limit doesn't match the proposer preferences gas limit. + /// The bid's gas limit is not compatible with the proposer's target gas limit. InvalidGasLimit, /// The bids execution payment is non-zero ExecutionPaymentNonZero { execution_payment: u64 }, diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs index c68e6d9d32..ccdf64d41d 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -101,6 +101,17 @@ impl TestContext { root: Hash256::ZERO, }; + // Set a non-zero gas_limit on latest_execution_payload_bid so the gas limit + // compatibility check doesn't reject all bids at genesis. + if let Ok(bid) = state.latest_execution_payload_bid_mut() { + bid.gas_limit = 30_000_000; + } + // Update body_root to reflect the modified bid (genesis block embeds it). + let genesis_body_root = genesis_block(&state, &spec) + .expect("should build genesis block") + .body_root(); + state.latest_block_header_mut().body_root = genesis_body_root; + let inactive_keypair = &keypairs[NUM_BUILDERS]; let inactive_creds = builder_withdrawal_credentials(&inactive_keypair.pk, &spec); let inactive_builder_index = state @@ -248,7 +259,7 @@ fn make_signed_preferences( proposal_slot: Slot, validator_index: u64, fee_recipient: Address, - gas_limit: u64, + target_gas_limit: u64, ) -> Arc { Arc::new(SignedProposerPreferences { message: ProposerPreferences { @@ -256,7 +267,7 @@ fn make_signed_preferences( proposal_slot, validator_index, fee_recipient, - gas_limit, + target_gas_limit, }, signature: Signature::empty(), }) diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs index 4ba33fde72..586721d8c1 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/gossip_verified_proposer_preferences.rs @@ -18,13 +18,16 @@ pub(crate) fn verify_preferences_consistency( preferences: &ProposerPreferences, current_slot: Slot, head_state: &BeaconState, + spec: &ChainSpec, ) -> Result<(), ProposerPreferencesError> { let proposal_slot = preferences.proposal_slot; let validator_index = preferences.validator_index; let current_epoch = current_slot.epoch(E::slots_per_epoch()); let proposal_epoch = proposal_slot.epoch(E::slots_per_epoch()); - if proposal_epoch < current_epoch || proposal_epoch > current_epoch.saturating_add(1u64) { + if proposal_epoch < current_epoch + || proposal_epoch > current_epoch.saturating_add(spec.min_seed_lookahead) + { return Err(ProposerPreferencesError::InvalidProposalEpoch { proposal_epoch }); } @@ -35,7 +38,7 @@ pub(crate) fn verify_preferences_consistency( }); } - if !head_state.is_valid_proposal_slot(preferences)? { + if !head_state.is_valid_proposal_slot(preferences, spec)? { return Err(ProposerPreferencesError::InvalidProposalSlot { validator_index, proposal_slot, @@ -83,7 +86,12 @@ impl GossipVerifiedProposerPreferences { }); } - verify_preferences_consistency(&signed_preferences.message, current_slot, head_state)?; + verify_preferences_consistency( + &signed_preferences.message, + current_slot, + head_state, + ctx.spec, + )?; // Verify signature proposer_preferences_signature_set( @@ -155,11 +163,13 @@ impl BeaconChain { #[cfg(test)] mod tests { use types::{ - Address, BeaconState, EthSpec, Hash256, MinimalEthSpec, ProposerPreferences, Slot, + Address, BeaconState, ChainSpec, EthSpec, Hash256, MinimalEthSpec, ProposerPreferences, + Slot, }; use super::verify_preferences_consistency; use crate::proposer_preferences_verification::ProposerPreferencesError; + use crate::test_utils::{fork_name_from_env, test_spec}; type E = MinimalEthSpec; @@ -169,20 +179,28 @@ mod tests { proposal_slot, validator_index, fee_recipient: Address::ZERO, - gas_limit: 30_000_000, + target_gas_limit: 30_000_000, } } fn state() -> BeaconState { - BeaconState::new(0, <_>::default(), &E::default_spec()) + let spec = spec(); + BeaconState::new(0, <_>::default(), &spec) + } + + fn spec() -> ChainSpec { + test_spec::() } #[test] fn test_invalid_epoch_too_old() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } let current_slot = Slot::new(2 * E::slots_per_epoch()); let prefs = make_preferences(Slot::new(3), 0); - let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + let result = verify_preferences_consistency::(&prefs, current_slot, &state(), &spec()); assert!(matches!( result, Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) @@ -191,10 +209,13 @@ mod tests { #[test] fn test_invalid_epoch_too_far_ahead() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } let current_slot = Slot::new(E::slots_per_epoch()); let prefs = make_preferences(Slot::new(3 * E::slots_per_epoch() + 1), 0); - let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + let result = verify_preferences_consistency::(&prefs, current_slot, &state(), &spec()); assert!(matches!( result, Err(ProposerPreferencesError::InvalidProposalEpoch { .. }) @@ -203,10 +224,13 @@ mod tests { #[test] fn test_proposal_slot_already_passed() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } let current_slot = Slot::new(10); let prefs = make_preferences(Slot::new(9), 0); - let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + let result = verify_preferences_consistency::(&prefs, current_slot, &state(), &spec()); assert!(matches!( result, Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) @@ -215,10 +239,13 @@ mod tests { #[test] fn test_proposal_slot_equal_to_current() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } let current_slot = Slot::new(10); let prefs = make_preferences(Slot::new(10), 0); - let result = verify_preferences_consistency::(&prefs, current_slot, &state()); + let result = verify_preferences_consistency::(&prefs, current_slot, &state(), &spec()); assert!(matches!( result, Err(ProposerPreferencesError::ProposalSlotAlreadyPassed { .. }) diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs index a2e96dfce1..6c79e56733 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/mod.rs @@ -24,7 +24,7 @@ mod tests; #[derive(Debug)] pub enum ProposerPreferencesError { - /// The proposal slot is not in the current or next epoch. + /// The proposal slot is not within the proposer lookahead. InvalidProposalEpoch { proposal_epoch: Epoch }, /// The proposal slot has already passed. ProposalSlotAlreadyPassed { diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs index 7bbdf34888..c423418fbc 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/proposer_preference_cache.rs @@ -87,7 +87,7 @@ mod tests { proposal_slot: slot, validator_index, fee_recipient: Address::ZERO, - gas_limit: 30_000_000, + target_gas_limit: 30_000_000, }, signature: Signature::empty(), }), diff --git a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs index 468e08ff3b..53c1c4ded3 100644 --- a/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs +++ b/beacon_node/beacon_chain/src/proposer_preferences_verification/tests.rs @@ -112,7 +112,7 @@ impl TestContext { let slot_in_epoch = slot.as_usize() % E::slots_per_epoch() as usize; let epoch = slot.epoch(E::slots_per_epoch()); let current_epoch = state.slot().epoch(E::slots_per_epoch()); - let index = if epoch == current_epoch.saturating_add(1u64) { + let index = if epoch == current_epoch.saturating_add(self.spec.min_seed_lookahead) { E::slots_per_epoch() as usize + slot_in_epoch } else { slot_in_epoch @@ -131,7 +131,7 @@ fn make_signed_preferences( proposal_slot, validator_index, fee_recipient: Address::ZERO, - gas_limit: 30_000_000, + target_gas_limit: 30_000_000, }, signature: Signature::empty(), }) @@ -271,7 +271,7 @@ fn same_validator_different_dependent_root_not_deduplicated() { validator_index: 42, dependent_root: Hash256::repeat_byte(0xaa), fee_recipient: Address::ZERO, - gas_limit: 30_000_000, + target_gas_limit: 30_000_000, }, signature: Signature::empty(), }), diff --git a/beacon_node/beacon_chain/tests/payload_invalidation.rs b/beacon_node/beacon_chain/tests/payload_invalidation.rs index be85fc2245..abf1fe48a6 100644 --- a/beacon_node/beacon_chain/tests/payload_invalidation.rs +++ b/beacon_node/beacon_chain/tests/payload_invalidation.rs @@ -1035,6 +1035,7 @@ async fn payload_preparation() { None, None, None, + None, ); assert_eq!(rig.previous_payload_attributes(), payload_attributes); } diff --git a/beacon_node/execution_layer/src/engine_api.rs b/beacon_node/execution_layer/src/engine_api.rs index acf5f2778b..7337a29c8f 100644 --- a/beacon_node/execution_layer/src/engine_api.rs +++ b/beacon_node/execution_layer/src/engine_api.rs @@ -178,6 +178,8 @@ pub struct PayloadAttributes { pub parent_beacon_block_root: Hash256, #[superstruct(only(V4), partial_getter(copy))] pub slot_number: u64, + #[superstruct(only(V4), partial_getter(copy))] + pub target_gas_limit: u64, } impl PayloadAttributes { @@ -188,19 +190,29 @@ impl PayloadAttributes { withdrawals: Option>, parent_beacon_block_root: Option, slot_number: Option, + target_gas_limit: Option, ) -> Self { - match (withdrawals, parent_beacon_block_root, slot_number) { - (Some(withdrawals), Some(parent_beacon_block_root), Some(slot_number)) => { - PayloadAttributes::V4(PayloadAttributesV4 { - timestamp, - prev_randao, - suggested_fee_recipient, - withdrawals, - parent_beacon_block_root, - slot_number, - }) - } - (Some(withdrawals), Some(parent_beacon_block_root), None) => { + match ( + withdrawals, + parent_beacon_block_root, + slot_number, + target_gas_limit, + ) { + ( + Some(withdrawals), + Some(parent_beacon_block_root), + Some(slot_number), + Some(target_gas_limit), + ) => PayloadAttributes::V4(PayloadAttributesV4 { + timestamp, + prev_randao, + suggested_fee_recipient, + withdrawals, + parent_beacon_block_root, + slot_number, + target_gas_limit, + }), + (Some(withdrawals), Some(parent_beacon_block_root), _, _) => { PayloadAttributes::V3(PayloadAttributesV3 { timestamp, prev_randao, @@ -209,13 +221,13 @@ impl PayloadAttributes { parent_beacon_block_root, }) } - (Some(withdrawals), None, _) => PayloadAttributes::V2(PayloadAttributesV2 { + (Some(withdrawals), None, _, _) => PayloadAttributes::V2(PayloadAttributesV2 { timestamp, prev_randao, suggested_fee_recipient, withdrawals, }), - (None, _, _) => PayloadAttributes::V1(PayloadAttributesV1 { + (None, _, _, _) => PayloadAttributes::V1(PayloadAttributesV1 { timestamp, prev_randao, suggested_fee_recipient, @@ -260,7 +272,7 @@ impl From for SsePayloadAttributes { withdrawals, parent_beacon_block_root, }), - // V4 maps to V3 for SSE (slot_number is not part of the SSE spec) + // V4 maps to V3 for SSE (slot_number/target_gas_limit are not part of the SSE spec) PayloadAttributes::V4(PayloadAttributesV4 { timestamp, prev_randao, @@ -268,6 +280,7 @@ impl From for SsePayloadAttributes { withdrawals, parent_beacon_block_root, slot_number: _, + target_gas_limit: _, }) => Self::V3(SsePayloadAttributesV3 { timestamp, prev_randao, diff --git a/beacon_node/execution_layer/src/engine_api/json_structures.rs b/beacon_node/execution_layer/src/engine_api/json_structures.rs index 9d9391a1e1..fb516e3e16 100644 --- a/beacon_node/execution_layer/src/engine_api/json_structures.rs +++ b/beacon_node/execution_layer/src/engine_api/json_structures.rs @@ -777,6 +777,9 @@ pub struct JsonPayloadAttributes { #[superstruct(only(V4))] #[serde(with = "serde_utils::u64_hex_be")] pub slot_number: u64, + #[superstruct(only(V4))] + #[serde(with = "serde_utils::u64_hex_be")] + pub target_gas_limit: u64, } impl From for JsonPayloadAttributes { @@ -807,6 +810,7 @@ impl From for JsonPayloadAttributes { withdrawals: pa.withdrawals.into_iter().map(Into::into).collect(), parent_beacon_block_root: pa.parent_beacon_block_root, slot_number: pa.slot_number, + target_gas_limit: pa.target_gas_limit, }), } } @@ -840,6 +844,7 @@ impl From for PayloadAttributes { withdrawals: jpa.withdrawals.into_iter().map(Into::into).collect(), parent_beacon_block_root: jpa.parent_beacon_block_root, slot_number: jpa.slot_number, + target_gas_limit: jpa.target_gas_limit, }), } } diff --git a/beacon_node/execution_layer/src/lib.rs b/beacon_node/execution_layer/src/lib.rs index b2dabb7c01..b1b8b0deaa 100644 --- a/beacon_node/execution_layer/src/lib.rs +++ b/beacon_node/execution_layer/src/lib.rs @@ -73,6 +73,8 @@ pub const DEFAULT_EXECUTION_ENDPOINT: &str = "http://localhost:8551/"; /// Name for the default file used for the jwt secret. pub const DEFAULT_JWT_FILE: &str = "jwt.hex"; +pub const DEFAULT_GAS_LIMIT: u64 = 60_000_000; + /// A fee recipient address for use during block production. Only used as a very last resort if /// there is no address provided by the user. /// @@ -358,7 +360,10 @@ impl> BlockProposalContents { pub parent_hash: ExecutionBlockHash, - pub parent_gas_limit: u64, + // NOTE: The `parent_gas_limit` is a bit scuffed. We made it optional for Gloas because it + // isn't currently required, but it should possibly be made non-optional again if needed. + // Or we should superstruct this type. + pub parent_gas_limit: Option, pub proposer_gas_limit: Option, pub payload_attributes: &'a PayloadAttributes, pub forkchoice_update_params: &'a ForkchoiceUpdateParameters, @@ -2082,7 +2087,7 @@ fn verify_builder_bid( let payload_withdrawals_root = header.withdrawals_root().ok(); let expected_gas_limit = proposer_gas_limit - .and_then(|target_gas_limit| expected_gas_limit(parent_gas_limit, target_gas_limit, spec)); + .and_then(|target_gas_limit| expected_gas_limit(parent_gas_limit?, target_gas_limit, spec)); if header.parent_hash() != parent_hash { Err(Box::new(InvalidBuilderPayload::ParentHash { diff --git a/beacon_node/execution_layer/src/test_utils/mock_builder.rs b/beacon_node/execution_layer/src/test_utils/mock_builder.rs index d6243a7c4d..d456c9adc1 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_builder.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_builder.rs @@ -282,7 +282,7 @@ impl BidStuff for BuilderBid { #[derive(Clone)] pub struct PayloadParametersCloned { pub parent_hash: ExecutionBlockHash, - pub parent_gas_limit: u64, + pub parent_gas_limit: Option, pub proposer_gas_limit: Option, pub payload_attributes: PayloadAttributes, pub forkchoice_update_params: ForkchoiceUpdateParameters, @@ -903,6 +903,7 @@ impl MockBuilder { expected_withdrawals, None, None, + None, ), ForkName::Deneb | ForkName::Electra | ForkName::Fulu => PayloadAttributes::new( timestamp, @@ -911,6 +912,7 @@ impl MockBuilder { expected_withdrawals, Some(head_block_root), None, + None, ), ForkName::Gloas => PayloadAttributes::new( timestamp, @@ -919,6 +921,7 @@ impl MockBuilder { expected_withdrawals, Some(head_block_root), Some(slot.as_u64()), + None, // TODO(gloas): pass target_gas_limit ), ForkName::Base | ForkName::Altair => { return Err("invalid fork".to_string()); @@ -969,7 +972,7 @@ impl MockBuilder { let payload_parameters = PayloadParametersCloned { parent_hash: head_execution_hash, - parent_gas_limit: head_gas_limit, + parent_gas_limit: Some(head_gas_limit), proposer_gas_limit: Some(proposer_gas_limit), payload_attributes, forkchoice_update_params, diff --git a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs index 5b721bcab2..583808281f 100644 --- a/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs +++ b/beacon_node/execution_layer/src/test_utils/mock_execution_layer.rs @@ -105,6 +105,7 @@ impl MockExecutionLayer { None, None, None, + None, ); // Insert a proposer to ensure the fork choice updated command works. @@ -146,11 +147,12 @@ impl MockExecutionLayer { None, None, None, + None, ); let payload_parameters = PayloadParameters { parent_hash, - parent_gas_limit, + parent_gas_limit: Some(parent_gas_limit), proposer_gas_limit: None, payload_attributes: &payload_attributes, forkchoice_update_params: &forkchoice_update_params, @@ -199,11 +201,12 @@ impl MockExecutionLayer { None, None, None, + None, ); let payload_parameters = PayloadParameters { parent_hash, - parent_gas_limit, + parent_gas_limit: Some(parent_gas_limit), proposer_gas_limit: None, payload_attributes: &payload_attributes, forkchoice_update_params: &forkchoice_update_params, diff --git a/beacon_node/http_api/tests/tests.rs b/beacon_node/http_api/tests/tests.rs index a7fe34593a..3da0841a4e 100644 --- a/beacon_node/http_api/tests/tests.rs +++ b/beacon_node/http_api/tests/tests.rs @@ -2929,7 +2929,7 @@ impl ApiTester { proposal_slot, validator_index: validator_index as u64, fee_recipient: Address::repeat_byte(0xaa), - gas_limit: 30_000_000, + target_gas_limit: 30_000_000, }; let epoch = proposal_slot.epoch(E::slots_per_epoch()); diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 7a902649cb..3e8845f017 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4058,7 +4058,6 @@ impl NetworkBeaconProcessor { PayloadBidError::BadSignature | PayloadBidError::InvalidBuilder { .. } | PayloadBidError::InvalidFeeRecipient - | PayloadBidError::InvalidGasLimit | PayloadBidError::ExecutionPaymentNonZero { .. } | PayloadBidError::InvalidBlobKzgCommitments { .. }, ) => { @@ -4076,6 +4075,7 @@ impl NetworkBeaconProcessor { | PayloadBidError::ParentBlockRootUnknown { .. } | PayloadBidError::ParentBlockRootNotCanonical { .. } | PayloadBidError::BuilderCantCoverBid { .. } + | PayloadBidError::InvalidGasLimit | PayloadBidError::BeaconStateError(_) | PayloadBidError::InternalError(_) | PayloadBidError::InvalidBidSlot { .. } diff --git a/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml b/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml index 02bf37cb55..ced9679142 100644 --- a/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml +++ b/common/eth2_network_config/built_in_network_configs/mainnet/config.yaml @@ -93,8 +93,8 @@ SYNC_MESSAGE_DUE_BPS: 3333 CONTRIBUTION_DUE_BPS: 6667 # Gloas -# 2**6 (= 64) epochs -MIN_BUILDER_WITHDRAWABILITY_DELAY: 64 +# 2**13 (= 8192) epochs +MIN_BUILDER_WITHDRAWABILITY_DELAY: 8192 # 2500 basis points, 25% of SLOT_DURATION_MS ATTESTATION_DUE_BPS_GLOAS: 2500 # 5000 basis points, 50% of SLOT_DURATION_MS diff --git a/consensus/fork_choice/src/fork_choice.rs b/consensus/fork_choice/src/fork_choice.rs index a60859585c..2de8ce7d81 100644 --- a/consensus/fork_choice/src/fork_choice.rs +++ b/consensus/fork_choice/src/fork_choice.rs @@ -1207,7 +1207,6 @@ where fn validate_on_payload_attestation( &self, indexed_payload_attestation: &IndexedPayloadAttestation, - is_from_block: AttestationFromBlock, ) -> Result<(), InvalidPayloadAttestation> { // This check is from `is_valid_indexed_payload_attestation`, but we do it immediately to // avoid wasting time on junk attestations. @@ -1233,25 +1232,6 @@ where }); } - // PTC votes can only change the vote for their assigned beacon block, return early otherwise - if block.slot != indexed_payload_attestation.data.slot { - return Ok(()); - } - - // Gossip payload attestations must be for the current slot. - // NOTE: signature is assumed to have been verified by caller. - // https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/fork-choice.md - if matches!(is_from_block, AttestationFromBlock::False) - && indexed_payload_attestation.data.slot != self.fc_store.get_current_slot() - { - return Err( - InvalidPayloadAttestation::PayloadAttestationNotCurrentSlot { - attestation_slot: indexed_payload_attestation.data.slot, - current_slot: self.fc_store.get_current_slot(), - }, - ); - } - Ok(()) } @@ -1339,34 +1319,69 @@ where pub fn on_payload_attestation( &mut self, system_time_current_slot: Slot, - attestation: &IndexedPayloadAttestation, + payload_attestation: &IndexedPayloadAttestation, is_from_block: AttestationFromBlock, ptc: &[usize], ) -> Result<(), Error> { self.update_time(system_time_current_slot)?; - if attestation.data.beacon_block_root.is_zero() { + if payload_attestation.data.beacon_block_root.is_zero() { return Ok(()); } // TODO(gloas): Should ignore wrong-slot payload attestations at the caller, they could // have been processed at the correct slot when received on gossip, but then have the // wrong-slot by the time they make it to here (TOCTOU). - self.validate_on_payload_attestation(attestation, is_from_block)?; + // TODO(gloas): Consider inlining validate_on_payload_attestation here to look more like the spec. + self.validate_on_payload_attestation(payload_attestation)?; - // Resolve validator indices to PTC committee positions. - let ptc_indices: Vec = attestation - .attesting_indices - .iter() - .filter_map(|validator_index| ptc.iter().position(|&p| p == *validator_index as usize)) - .collect(); + // PTC votes can only change the vote for their assigned beacon block, return early otherwise. + let block = self + .proto_array + .get_block(&payload_attestation.data.beacon_block_root) + .ok_or(InvalidPayloadAttestation::UnknownHeadBlock { + beacon_block_root: payload_attestation.data.beacon_block_root, + })?; + if block.slot != payload_attestation.data.slot { + return Ok(()); + } + + // Gossip payload attestations must be for the current slot. + if matches!(is_from_block, AttestationFromBlock::False) + && payload_attestation.data.slot != self.fc_store.get_current_slot() + { + return Err( + InvalidPayloadAttestation::PayloadAttestationNotCurrentSlot { + attestation_slot: payload_attestation.data.slot, + current_slot: self.fc_store.get_current_slot(), + } + .into(), + ); + } + + // Resolve validator indices to all PTC committee positions. A validator may + // appear multiple times in the PTC committee. + let mut ptc_indices = vec![]; + let mut validators_found = 0; + for validator_index in payload_attestation.attesting_indices.iter() { + let mut found = false; + for (ptc_index, &ptc_validator_index) in ptc.iter().enumerate() { + if ptc_validator_index == *validator_index as usize { + ptc_indices.push(ptc_index); + found = true; + } + } + if found { + validators_found += 1; + } + } // Check that all the attesters are in the PTC - if ptc_indices.len() != attestation.attesting_indices.len() { + if validators_found != payload_attestation.attesting_indices.len() { return Err( InvalidPayloadAttestation::PayloadAttestationAttestersNotInPtc { - attesting_indices_len: attestation.attesting_indices.len(), - attesting_indices_in_ptc: ptc_indices.len(), + attesting_indices_len: payload_attestation.attesting_indices.len(), + attesting_indices_in_ptc: validators_found, } .into(), ); @@ -1374,10 +1389,10 @@ where for &ptc_index in &ptc_indices { self.proto_array.process_payload_attestation( - attestation.data.beacon_block_root, + payload_attestation.data.beacon_block_root, ptc_index, - attestation.data.payload_present, - attestation.data.blob_data_available, + payload_attestation.data.payload_present, + payload_attestation.data.blob_data_available, )?; } @@ -1522,6 +1537,17 @@ where && self.is_finalized_checkpoint_or_descendant(*block_root) } + /// Called by the proposer to decide whether to build on the full or empty parent. + pub fn should_build_on_full( + &self, + block_root: &Hash256, + parent_payload_status: PayloadStatus, + ) -> Result> { + self.proto_array + .should_build_on_full::(block_root, parent_payload_status) + .map_err(Error::ProtoArrayStringError) + } + /// Returns whether the proposer should extend the execution payload chain of the given block. pub fn should_extend_payload(&self, block_root: &Hash256) -> Result> { let proposer_boost_root = self.fc_store.proposer_boost_root(); diff --git a/consensus/proto_array/src/error.rs b/consensus/proto_array/src/error.rs index bb47af97d9..d185ed371c 100644 --- a/consensus/proto_array/src/error.rs +++ b/consensus/proto_array/src/error.rs @@ -1,3 +1,4 @@ +use crate::PayloadStatus; use safe_arith::ArithError; use types::{Checkpoint, Epoch, ExecutionBlockHash, Hash256, Slot}; @@ -62,6 +63,10 @@ pub enum Error { }, NoViableChildren, OnBlockRequiresProposerIndex, + InvalidPayloadStatus { + block_root: Hash256, + payload_status: PayloadStatus, + }, } impl From for Error { diff --git a/consensus/proto_array/src/fork_choice_test_definition.rs b/consensus/proto_array/src/fork_choice_test_definition.rs index d537f16bb2..43b76ec7cb 100644 --- a/consensus/proto_array/src/fork_choice_test_definition.rs +++ b/consensus/proto_array/src/fork_choice_test_definition.rs @@ -556,7 +556,11 @@ impl ForkChoiceTestDefinition { node_v29.payload_data_availability_votes = BitVector::from_bytes(smallvec::smallvec![fill; 64]) .expect("valid 512-bit bitvector"); - // Per spec, is_payload_timely/is_payload_data_available require + // Mark all PTC members as having participated. + node_v29.ptc_participation = + BitVector::from_bytes(smallvec::smallvec![0xFF; 64]) + .expect("valid 512-bit bitvector"); + // Per spec, payload_timeliness/payload_data_availability require // the payload to be in payload_states (payload_received). node_v29.payload_received = is_timely || is_data_available; } diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 78f5026689..8ac8354f06 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -155,6 +155,10 @@ pub struct ProtoNode { /// Tiebreak derived as: `num_set_bits() > ptc_size / 2`. #[superstruct(only(V29))] pub payload_data_availability_votes: BitVector, + /// Tracks which PTC members have cast a vote. + /// Bit i set means PTC member i has submitted a payload attestation. + #[superstruct(only(V29))] + pub ptc_participation: BitVector, /// Whether the execution payload for this block has been received and validated locally. /// Maps to `root in store.payload_states` in the spec. #[superstruct(only(V29), partial_getter(copy))] @@ -193,31 +197,60 @@ impl ProtoNode { } } - pub fn is_payload_timely(&self) -> bool { + /// Checks if `timely` matches our view of payload timeliness. + /// Returns whether the execution payload for the node is considered `timely` + /// (or not `timely` when `timely` is `false`), taking into consideration local + /// availability and PTC votes. + pub fn payload_timeliness(&self, timely: bool) -> Result { let Ok(node) = self.as_v29() else { - return false; + return Err(Error::InvalidNodeVariant { + block_root: self.root(), + }); }; - // Equivalent to `if root not in store.payload_states` in the spec. + // Equivalent to `if not is_payload_verified(store, root)` in the spec. if !node.payload_received { - return false; + return Ok(!timely); } - node.payload_timeliness_votes.num_set_bits() > E::payload_timely_threshold() + let matching_votes = if timely { + node.payload_timeliness_votes.num_set_bits() + } else { + // We take into consideration only participating ptc votes. An unset bit + // in `payload_timeliness_votes` could be an absent vote or a no vote. + node.ptc_participation + .num_set_bits() + .saturating_sub(node.payload_timeliness_votes.num_set_bits()) + }; + Ok(matching_votes > E::payload_timely_threshold()) } - pub fn is_payload_data_available(&self) -> bool { + /// Checks if `available` matches our view of payload data availability. + /// Return whether the blob data for the node is considered `available` + /// (or not, when `available` is `False`), taking into consideration local + /// availability and PTC votes. + pub fn payload_data_availability(&self, available: bool) -> Result { let Ok(node) = self.as_v29() else { - return false; + return Err(Error::InvalidNodeVariant { + block_root: self.root(), + }); }; - // Equivalent to `if root not in store.payload_states` in the spec. + // Equivalent to `if not is_payload_verified(store, root)` in the spec. if !node.payload_received { - return false; + return Ok(!available); } - node.payload_data_availability_votes.num_set_bits() - > E::data_availability_timely_threshold() + let matching_votes = if available { + node.payload_data_availability_votes.num_set_bits() + } else { + // We take into consideration only participating ptc votes. An unset bit + // in `payload_data_availability_votes` could be an absent vote or a no vote. + node.ptc_participation + .num_set_bits() + .saturating_sub(node.payload_data_availability_votes.num_set_bits()) + }; + Ok(matching_votes > E::data_availability_timely_threshold()) } } @@ -605,6 +638,7 @@ impl ProtoArray { execution_payload_parent_hash, payload_timeliness_votes: BitVector::default(), payload_data_availability_votes: BitVector::default(), + ptc_participation: BitVector::default(), payload_received: false, proposer_index, // Spec: `record_block_timeliness` + `get_forkchoice_store`. @@ -1501,12 +1535,46 @@ impl ProtoArray { } } + /// Called by the proposer to decide whether to build on the full or empty + /// parent pending node. Returns false if the PTC has voted the data as unavailable. + pub fn should_build_on_full( + &self, + fc_node: &IndexedForkChoiceNode, + proto_node: &ProtoNode, + ) -> Result { + if fc_node.payload_status == PayloadStatus::Pending { + return Err(Error::InvalidPayloadStatus { + block_root: proto_node.root(), + payload_status: fc_node.payload_status, + }); + } + + if fc_node.payload_status == PayloadStatus::Empty { + return Ok(false); + } + // Check that false votes have not achieved an absolute majority. This allows the payload to be + // considered available when either a majority have voted true or not enough votes have + // been cast either way. + Ok(!proto_node.payload_data_availability::(false)?) + } + pub fn should_extend_payload( &self, fc_node: &IndexedForkChoiceNode, proto_node: &ProtoNode, proposer_boost_root: Hash256, ) -> Result { + let Ok(node) = proto_node.as_v29() else { + return Err(Error::InvalidNodeVariant { + block_root: fc_node.root, + }); + }; + + // Spec equivalent to `if not is_payload_verified(store, root): return False` + if !node.payload_received { + return Ok(false); + } + // Per spec: `proposer_root == Root()` is one of the `or` conditions that // makes `should_extend_payload` return True. if proposer_boost_root.is_zero() { @@ -1531,11 +1599,10 @@ impl ProtoArray { .ok_or(Error::InvalidNodeIndex(parent_index))? .root(); - Ok( - (proto_node.is_payload_timely::() && proto_node.is_payload_data_available::()) - || proposer_boost_parent_root != fc_node.root - || proposer_boost_node.is_parent_node_full(), - ) + Ok((proto_node.payload_timeliness::(true)? + && proto_node.payload_data_availability::(true)?) + || proposer_boost_parent_root != fc_node.root + || proposer_boost_node.is_parent_node_full()) } /// Update the tree with new finalization information. The tree is only actually pruned if both diff --git a/consensus/proto_array/src/proto_array_fork_choice.rs b/consensus/proto_array/src/proto_array_fork_choice.rs index 7abba8a1f6..96d2302266 100644 --- a/consensus/proto_array/src/proto_array_fork_choice.rs +++ b/consensus/proto_array/src/proto_array_fork_choice.rs @@ -640,6 +640,9 @@ impl ProtoArrayForkChoice { .map_err(|e| { format!("process_payload_attestation: data availability set failed: {e:?}") })?; + v29.ptc_participation + .set(ptc_index, true) + .map_err(|e| format!("process_payload_attestation: participation set failed: {e:?}"))?; Ok(()) } @@ -1006,6 +1009,33 @@ impl ProtoArrayForkChoice { }) } + /// Called by the proposer to decide whether to build on the full or empty + /// parent. Returns false if the PTC has voted the data as unavailable. + pub fn should_build_on_full( + &self, + block_root: &Hash256, + parent_payload_status: PayloadStatus, + ) -> Result { + let block_index = self + .proto_array + .indices + .get(block_root) + .ok_or_else(|| format!("Unknown block root: {block_root:?}"))?; + let proto_node = self + .proto_array + .nodes + .get(*block_index) + .ok_or_else(|| format!("Missing node at index: {block_index}"))?; + let fc_node = IndexedForkChoiceNode { + root: proto_node.root(), + proto_node_index: *block_index, + payload_status: parent_payload_status, + }; + self.proto_array + .should_build_on_full::(&fc_node, proto_node) + .map_err(|e| format!("{e:?}")) + } + /// Returns whether the proposer should extend the parent's execution payload chain. /// /// This checks timeliness, data availability, and proposer boost conditions per the spec. diff --git a/consensus/state_processing/src/per_block_processing/process_operations.rs b/consensus/state_processing/src/per_block_processing/process_operations.rs index 422e0afe06..f88a325d4e 100644 --- a/consensus/state_processing/src/per_block_processing/process_operations.rs +++ b/consensus/state_processing/src/per_block_processing/process_operations.rs @@ -916,25 +916,24 @@ pub fn process_deposit_requests_post_gloas( /// Check if there is a pending deposit for a new validator with the given pubkey. // TODO(gloas): cache the deposit signature validation or remove this loop entirely if possible, // it is `O(n * m)` where `n` is max 8192 and `m` is max 128M. -fn is_pending_validator( - state: &BeaconState, +pub fn is_pending_validator<'a>( + pending_deposits: impl IntoIterator, pubkey: &PublicKeyBytes, spec: &ChainSpec, -) -> Result { - for deposit in state.pending_deposits()?.iter() { - if deposit.pubkey == *pubkey { - let deposit_data = DepositData { - pubkey: deposit.pubkey, - withdrawal_credentials: deposit.withdrawal_credentials, - amount: deposit.amount, - signature: deposit.signature.clone(), - }; - if is_valid_deposit_signature(&deposit_data, spec).is_ok() { - return Ok(true); - } - } - } - Ok(false) +) -> bool { + pending_deposits.into_iter().any(|deposit| { + deposit.pubkey == *pubkey + && is_valid_deposit_signature( + &DepositData { + pubkey: deposit.pubkey, + withdrawal_credentials: deposit.withdrawal_credentials, + amount: deposit.amount, + signature: deposit.signature.clone(), + }, + spec, + ) + .is_ok() + }) } pub fn process_deposit_request_post_gloas( @@ -964,7 +963,7 @@ pub fn process_deposit_request_post_gloas( if is_builder || (has_builder_prefix && !is_validator - && !is_pending_validator(state, &deposit_request.pubkey, spec)?) + && !is_pending_validator(state.pending_deposits()?, &deposit_request.pubkey, spec)) { // Apply builder deposits immediately apply_deposit_for_builder( @@ -1003,7 +1002,7 @@ pub fn apply_deposit_for_builder( signature: SignatureBytes, slot: Slot, spec: &ChainSpec, -) -> Result<(), BeaconStateError> { +) -> Result, BeaconStateError> { match builder_index_opt { None => { // Verify the deposit signature (proof of possession) which is not checked by the deposit contract @@ -1014,13 +1013,16 @@ pub fn apply_deposit_for_builder( signature, }; if is_valid_deposit_signature(&deposit_data, spec).is_ok() { - state.add_builder_to_registry( + let builder_index = state.add_builder_to_registry( pubkey, withdrawal_credentials, amount, slot, spec, )?; + Ok(Some(builder_index)) + } else { + Ok(None) } } Some(builder_index) => { @@ -1030,9 +1032,9 @@ pub fn apply_deposit_for_builder( .ok_or(BeaconStateError::UnknownBuilder(builder_index))? .balance .safe_add_assign(amount)?; + Ok(Some(builder_index)) } } - Ok(()) } // Make sure to build the pubkey cache before calling this function diff --git a/consensus/state_processing/src/upgrade/gloas.rs b/consensus/state_processing/src/upgrade/gloas.rs index 84cdbf22c2..c26547e304 100644 --- a/consensus/state_processing/src/upgrade/gloas.rs +++ b/consensus/state_processing/src/upgrade/gloas.rs @@ -1,18 +1,16 @@ -use crate::per_block_processing::{ - is_valid_deposit_signature, process_operations::apply_deposit_for_builder, -}; +use crate::per_block_processing::process_operations::apply_deposit_for_builder; +use crate::per_block_processing::process_operations::is_pending_validator; use milhouse::{List, Vector}; use safe_arith::SafeArith; use ssz_types::BitVector; use ssz_types::FixedVector; -use std::collections::HashSet; +use std::collections::HashMap; use std::mem; use tree_hash::TreeHash; use typenum::Unsigned; use types::{ BeaconState, BeaconStateError as Error, BeaconStateGloas, BuilderPendingPayment, ChainSpec, - DepositData, EthSpec, ExecutionPayloadBid, ExecutionRequests, Fork, - is_builder_withdrawal_credential, + EthSpec, ExecutionPayloadBid, ExecutionRequests, Fork, is_builder_withdrawal_credential, }; /// Transform a `Fulu` state into a `Gloas` state. @@ -80,6 +78,7 @@ pub fn upgrade_state_to_gloas( // Execution Bid latest_execution_payload_bid: ExecutionPayloadBid { block_hash: pre.latest_execution_payload_header.block_hash, + gas_limit: pre.latest_execution_payload_header.gas_limit, execution_requests_root: ExecutionRequests::::default().tree_hash_root(), ..Default::default() }, @@ -167,66 +166,57 @@ fn onboard_builders_from_pending_deposits( state: &mut BeaconState, spec: &ChainSpec, ) -> Result<(), Error> { - // Rather than tracking all `validator_pubkeys` in one place as the spec does, we keep a - // hashset for *just* the new validator pubkeys, and use the state's efficient - // `get_validator_index` function instead of an O(n) iteration over the full validator list. - let mut new_validator_pubkeys = HashSet::new(); - // Clone pending deposits to avoid borrow conflicts when mutating state. let current_pending_deposits = state.pending_deposits()?.clone(); let mut pending_deposits = List::empty(); + // TODO(gloas): introduce a global builder pubkey cache, see: + // https://github.com/sigp/lighthouse/issues/8783 + let mut builder_pubkey_to_index = state + .builders()? + .iter() + .enumerate() + .map(|(i, b)| (b.pubkey, i as u64)) + .collect::>(); + for deposit in ¤t_pending_deposits { // Deposits for existing validators stay in the pending queue. - if new_validator_pubkeys.contains(&deposit.pubkey) - || state.get_validator_index(&deposit.pubkey)?.is_some() - { + if state.get_validator_index(&deposit.pubkey)?.is_some() { pending_deposits.push(deposit.clone())?; continue; } - // Re-scan builder list each iteration because `apply_deposit_for_builder` may add - // new builders to the registry. - // TODO(gloas): this linear scan could be optimized, see: - // https://github.com/sigp/lighthouse/issues/8783 - let builder_index = state - .builders()? - .iter() - .position(|b| b.pubkey == deposit.pubkey); + if !builder_pubkey_to_index.contains_key(&deposit.pubkey) { + // Deposits without builder withdrawal credentials are for new validators. + if !is_builder_withdrawal_credential(deposit.withdrawal_credentials, spec) { + pending_deposits.push(deposit.clone())?; + continue; + } - let has_builder_credentials = - is_builder_withdrawal_credential(deposit.withdrawal_credentials, spec); - - if builder_index.is_some() || has_builder_credentials { - let builder_index_opt = builder_index.map(|i| i as u64); - apply_deposit_for_builder( - state, - builder_index_opt, - deposit.pubkey, - deposit.withdrawal_credentials, - deposit.amount, - deposit.signature.clone(), - deposit.slot, - spec, - )?; - continue; + // If there is a valid pending deposit for a new validator with this pubkey, + // keep this deposit in the pending queue to be applied to that validator later. + if is_pending_validator(&pending_deposits, &deposit.pubkey, spec) { + pending_deposits.push(deposit.clone())?; + continue; + } } - // If there is a pending deposit for a new validator that has a valid signature, - // track the pubkey so that subsequent builder deposits for the same pubkey stay - // in pending (applied to the validator later) rather than creating a builder. - // Deposits with invalid signatures are dropped since they would fail in - // apply_pending_deposit anyway. - let deposit_data = DepositData { - pubkey: deposit.pubkey, - withdrawal_credentials: deposit.withdrawal_credentials, - amount: deposit.amount, - signature: deposit.signature.clone(), - }; - if is_valid_deposit_signature(&deposit_data, spec).is_ok() { - new_validator_pubkeys.insert(deposit.pubkey); - pending_deposits.push(deposit.clone())?; + let builder_index = builder_pubkey_to_index.get(&deposit.pubkey).copied(); + + if let Some(new_builder_index) = apply_deposit_for_builder( + state, + builder_index, + deposit.pubkey, + deposit.withdrawal_credentials, + deposit.amount, + deposit.signature.clone(), + deposit.slot, + spec, + )? { + builder_pubkey_to_index + .entry(deposit.pubkey) + .or_insert(new_builder_index); } } diff --git a/consensus/types/configs/mainnet.yaml b/consensus/types/configs/mainnet.yaml index 25bf872a7a..743384bcc9 100644 --- a/consensus/types/configs/mainnet.yaml +++ b/consensus/types/configs/mainnet.yaml @@ -91,8 +91,8 @@ SYNC_MESSAGE_DUE_BPS: 3333 CONTRIBUTION_DUE_BPS: 6667 # Gloas -# 2**6 (= 64) epochs -MIN_BUILDER_WITHDRAWABILITY_DELAY: 64 +# 2**13 (= 8192) epochs +MIN_BUILDER_WITHDRAWABILITY_DELAY: 8192 # 2500 basis points, 25% of SLOT_DURATION_MS ATTESTATION_DUE_BPS_GLOAS: 2500 # 5000 basis points, 50% of SLOT_DURATION_MS diff --git a/consensus/types/src/builder/proposer_preferences.rs b/consensus/types/src/builder/proposer_preferences.rs index e3773e333d..4f27020105 100644 --- a/consensus/types/src/builder/proposer_preferences.rs +++ b/consensus/types/src/builder/proposer_preferences.rs @@ -16,7 +16,7 @@ pub struct ProposerPreferences { pub proposal_slot: Slot, pub validator_index: u64, pub fee_recipient: Address, - pub gas_limit: u64, + pub target_gas_limit: u64, } impl SignedRoot for ProposerPreferences {} diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index c54d032891..c42bb4b5b9 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -108,6 +108,7 @@ pub struct ChainSpec { pub proposer_reorg_cutoff_bps: u64, pub attestation_due_bps: u64, pub attestation_due_bps_gloas: u64, + pub payload_due_bps: u64, pub payload_attestation_due_bps: u64, pub aggregate_due_bps: u64, pub sync_message_due_bps: u64, @@ -118,6 +119,7 @@ pub struct ChainSpec { */ pub unaggregated_attestation_due: Duration, pub unaggregated_attestation_due_gloas: Duration, + pub payload_due: Duration, pub payload_attestation_due: Duration, pub aggregate_attestation_due: Duration, pub sync_message_due: Duration, @@ -894,6 +896,11 @@ impl ChainSpec { } } + /// Spec: `get_payload_due_ms`. + pub fn get_payload_due(&self) -> Duration { + self.payload_due + } + /// Spec: `get_payload_attestation_due_ms`. pub fn get_payload_attestation_due(&self) -> Duration { self.payload_attestation_due @@ -974,6 +981,9 @@ impl ChainSpec { self.unaggregated_attestation_due_gloas = self .compute_slot_component_duration(self.attestation_due_bps_gloas) .expect("invalid chain spec: cannot compute unaggregated_attestation_due_gloas"); + self.payload_due = self + .compute_slot_component_duration(self.payload_due_bps) + .expect("invalid chain spec: cannot compute payload_due"); self.payload_attestation_due = self .compute_slot_component_duration(self.payload_attestation_due_bps) .expect("invalid chain spec: cannot compute payload_attestation_due"); @@ -1108,6 +1118,7 @@ impl ChainSpec { proposer_reorg_cutoff_bps: 1667, attestation_due_bps: 3333, attestation_due_bps_gloas: 2500, + payload_due_bps: 7500, payload_attestation_due_bps: 7500, aggregate_due_bps: 6667, sync_message_due_bps: 3333, @@ -1118,6 +1129,7 @@ impl ChainSpec { */ unaggregated_attestation_due: Duration::from_millis(3999), unaggregated_attestation_due_gloas: Duration::from_millis(3000), + payload_due: Duration::from_millis(9000), payload_attestation_due: Duration::from_millis(9000), aggregate_attestation_due: Duration::from_millis(8000), sync_message_due: Duration::from_millis(3999), @@ -1270,7 +1282,7 @@ impl ChainSpec { gloas_fork_epoch: None, builder_payment_threshold_numerator: 6, builder_payment_threshold_denominator: 10, - min_builder_withdrawability_delay: Epoch::new(64), + min_builder_withdrawability_delay: Epoch::new(8192), churn_limit_quotient_gloas: option_wrapper(|| u64::checked_pow(2, 15)) .expect("calculation does not overflow"), consolidation_churn_limit_quotient: option_wrapper(|| u64::checked_pow(2, 16)) @@ -1440,6 +1452,7 @@ impl ChainSpec { */ unaggregated_attestation_due: Duration::from_millis(1999), unaggregated_attestation_due_gloas: Duration::from_millis(1500), + payload_due: Duration::from_millis(4500), payload_attestation_due: Duration::from_millis(4500), aggregate_attestation_due: Duration::from_millis(4000), sync_message_due: Duration::from_millis(1999), @@ -1531,6 +1544,7 @@ impl ChainSpec { proposer_reorg_cutoff_bps: 1667, attestation_due_bps: 3333, attestation_due_bps_gloas: 2500, + payload_due_bps: 7500, payload_attestation_due_bps: 7500, aggregate_due_bps: 6667, @@ -1540,6 +1554,7 @@ impl ChainSpec { */ unaggregated_attestation_due: Duration::from_millis(1666), unaggregated_attestation_due_gloas: Duration::from_millis(1250), + payload_due: Duration::from_millis(3750), payload_attestation_due: Duration::from_millis(3750), aggregate_attestation_due: Duration::from_millis(3333), sync_message_due: Duration::from_millis(1666), @@ -1693,7 +1708,7 @@ impl ChainSpec { gloas_fork_epoch: None, builder_payment_threshold_numerator: 6, builder_payment_threshold_denominator: 10, - min_builder_withdrawability_delay: Epoch::new(64), + min_builder_withdrawability_delay: Epoch::new(8192), churn_limit_quotient_gloas: option_wrapper(|| u64::checked_pow(2, 15)) .expect("calculation does not overflow"), consolidation_churn_limit_quotient: option_wrapper(|| u64::checked_pow(2, 16)) @@ -2136,6 +2151,9 @@ pub struct Config { #[serde(default = "default_attestation_due_bps_gloas")] #[serde(with = "serde_utils::quoted_u64")] attestation_due_bps_gloas: u64, + #[serde(default = "default_payload_due_bps")] + #[serde(with = "serde_utils::quoted_u64")] + payload_due_bps: u64, #[serde(default = "default_payload_attestation_due_bps")] #[serde(with = "serde_utils::quoted_u64")] payload_attestation_due_bps: u64, @@ -2379,6 +2397,10 @@ const fn default_attestation_due_bps_gloas() -> u64 { 2500 } +const fn default_payload_due_bps() -> u64 { + 7500 +} + const fn default_payload_attestation_due_bps() -> u64 { 7500 } @@ -2396,7 +2418,7 @@ const fn default_contribution_due_bps() -> u64 { } const fn default_min_builder_withdrawability_delay() -> u64 { - 64 + 8192 } const fn default_churn_limit_quotient_gloas() -> u64 { @@ -2656,6 +2678,7 @@ impl Config { proposer_reorg_cutoff_bps: spec.proposer_reorg_cutoff_bps, attestation_due_bps: spec.attestation_due_bps, attestation_due_bps_gloas: spec.attestation_due_bps_gloas, + payload_due_bps: spec.payload_due_bps, payload_attestation_due_bps: spec.payload_attestation_due_bps, aggregate_due_bps: spec.aggregate_due_bps, sync_message_due_bps: spec.sync_message_due_bps, @@ -2759,6 +2782,7 @@ impl Config { proposer_reorg_cutoff_bps, attestation_due_bps, attestation_due_bps_gloas, + payload_due_bps, payload_attestation_due_bps, aggregate_due_bps, sync_message_due_bps, @@ -2867,6 +2891,7 @@ impl Config { proposer_reorg_cutoff_bps, attestation_due_bps, attestation_due_bps_gloas, + payload_due_bps, payload_attestation_due_bps, aggregate_due_bps, sync_message_due_bps, @@ -3694,6 +3719,30 @@ mod yaml_tests { let custom_spec = custom_spec.compute_derived_values::(); let tiny_due = custom_spec.get_unaggregated_attestation_due(); assert_eq!(tiny_due, Duration::from_millis(1)); // 12000 * 1 / 10000 = 1.2 -> 1 + + // Test payload due (7500 bps = 75% of 12s = 9s) + let spec = ChainSpec::mainnet().compute_derived_values::(); + let payload_due = spec.get_payload_due(); + assert_eq!(payload_due, Duration::from_millis(9000)); // 12000 * 7500 / 10000 + + // Test payload attestation due (7500 bps = 75% of 12s = 9s) + let payload_att_due = spec.get_payload_attestation_due(); + assert_eq!(payload_att_due, Duration::from_millis(9000)); // 12000 * 7500 / 10000 + + // Test gloas attestation due (2500 bps = 25% of 12s = 3s) + assert_eq!( + spec.unaggregated_attestation_due_gloas, + Duration::from_millis(3000) + ); // 12000 * 2500 / 10000 + + // Test gloas with custom bps + let mut custom_spec = spec; + custom_spec.attestation_due_bps_gloas = 5000; + let custom_spec = custom_spec.compute_derived_values::(); + assert_eq!( + custom_spec.unaggregated_attestation_due_gloas, + Duration::from_millis(6000) + ); // 12000 * 5000 / 10000 } #[test] @@ -3715,6 +3764,19 @@ mod yaml_tests { Duration::from_millis(8000) ); + // Mainnet payload due: 12000ms slots, 7500 bps = 9000ms + assert_eq!(mainnet.get_payload_due(), Duration::from_millis(9000)); + assert_eq!( + mainnet.get_payload_attestation_due(), + Duration::from_millis(9000) + ); + + // Mainnet gloas: 12000ms slots, 2500 bps = 3000ms + assert_eq!( + mainnet.unaggregated_attestation_due_gloas, + Duration::from_millis(3000) + ); + // Minimal spec: 6000ms slots, 3333 bps = 1999ms, 6667 bps = 4000ms let minimal = ChainSpec::minimal(); assert_eq!( @@ -3730,6 +3792,18 @@ mod yaml_tests { minimal.get_contribution_message_due(), Duration::from_millis(4000) ); + // Minimal payload due: 6000ms slots, 7500 bps = 4500ms + assert_eq!(minimal.get_payload_due(), Duration::from_millis(4500)); + assert_eq!( + minimal.get_payload_attestation_due(), + Duration::from_millis(4500) + ); + + // Minimal gloas: 6000ms slots, 2500 bps = 1500ms + assert_eq!( + minimal.unaggregated_attestation_due_gloas, + Duration::from_millis(1500) + ); // Gnosis spec: 5000ms slots, 3333 bps = 1666ms, 6667 bps = 3333ms let gnosis = ChainSpec::gnosis(); @@ -3746,6 +3820,18 @@ mod yaml_tests { gnosis.get_contribution_message_due(), Duration::from_millis(3333) ); + // Gnosis payload due: 5000ms slots, 7500 bps = 3750ms + assert_eq!(gnosis.get_payload_due(), Duration::from_millis(3750)); + assert_eq!( + gnosis.get_payload_attestation_due(), + Duration::from_millis(3750) + ); + + // Gnosis gloas: 5000ms slots, 2500 bps = 1250ms + assert_eq!( + gnosis.unaggregated_attestation_due_gloas, + Duration::from_millis(1250) + ); } #[test] diff --git a/consensus/types/src/state/beacon_state.rs b/consensus/types/src/state/beacon_state.rs index 4d2c7533ca..027acfab7f 100644 --- a/consensus/types/src/state/beacon_state.rs +++ b/consensus/types/src/state/beacon_state.rs @@ -1333,6 +1333,7 @@ impl BeaconState { pub fn is_valid_proposal_slot( &self, preferences: &ProposerPreferences, + spec: &ChainSpec, ) -> Result { let current_epoch = self.current_epoch(); let proposal_epoch = preferences.proposal_slot.epoch(E::slots_per_epoch()); @@ -1341,8 +1342,7 @@ impl BeaconState { return Ok(false); } - let next_epoch = current_epoch.saturating_add(1u64); - if proposal_epoch > next_epoch { + if proposal_epoch > current_epoch.saturating_add(spec.min_seed_lookahead) { return Ok(false); } diff --git a/testing/ef_tests/Makefile b/testing/ef_tests/Makefile index 36f6684685..f566a89ded 100644 --- a/testing/ef_tests/Makefile +++ b/testing/ef_tests/Makefile @@ -1,6 +1,6 @@ # To download/extract nightly tests, run: # CONSENSUS_SPECS_TEST_VERSION=nightly make -CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.7 +CONSENSUS_SPECS_TEST_VERSION ?= v1.7.0-alpha.8 REPO_NAME := consensus-spec-tests OUTPUT_DIR := ./$(REPO_NAME) diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 8b0b74d256..69fce09505 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -1,6 +1,6 @@ use super::*; use crate::decode::{ssz_decode_file, ssz_decode_file_with, ssz_decode_state, yaml_decode_file}; -use ::fork_choice::{PayloadVerificationStatus, ProposerHeadError}; +use ::fork_choice::{AttestationFromBlock, PayloadVerificationStatus, ProposerHeadError}; use beacon_chain::beacon_proposer_cache::compute_proposer_duties_from_head; use beacon_chain::blob_verification::GossipBlobError; use beacon_chain::block_verification_types::LookupBlock; @@ -19,13 +19,16 @@ use beacon_chain::{ custody_context::NodeCustodyType, test_utils::{BeaconChainHarness, EphemeralHarnessType}, }; +use bls::AggregateSignature; use execution_layer::{ PayloadStatusV1, PayloadStatusV1Status, json_structures::JsonPayloadStatusV1Status, }; use serde::Deserialize; use ssz_derive::Decode; +use ssz_types::VariableList; use state_processing::VerifySignatures; use state_processing::envelope_processing::verify_execution_payload_envelope; +use state_processing::per_block_processing::is_valid_indexed_payload_attestation; use state_processing::state_advance::complete_state_advance; use std::future::Future; use std::sync::Arc; @@ -34,8 +37,8 @@ use types::{ Attestation, AttestationRef, AttesterSlashing, AttesterSlashingRef, BeaconBlock, BeaconState, BlobSidecar, BlobsList, BlockImportSource, Checkpoint, DataColumnSidecar, DataColumnSidecarList, DataColumnSubnetId, ExecutionBlockHash, Hash256, IndexedAttestation, - KzgProof, ProposerPreparationData, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, - Uint256, + IndexedPayloadAttestation, KzgProof, PayloadAttestationMessage, ProposerPreparationData, + SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, Uint256, }; // When set to true, cache any states fetched from the db. @@ -63,6 +66,13 @@ pub struct ShouldOverrideFcu { result: bool, } +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PayloadVoteCheck { + block_root: Hash256, + votes: Vec>, +} + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct Checks { @@ -78,6 +88,8 @@ pub struct Checks { get_proposer_head: Option, should_override_forkchoice_update: Option, head_payload_status: Option, + payload_timeliness_vote: Option, + payload_data_availability_vote: Option, } #[derive(Debug, Clone, Deserialize)] @@ -108,6 +120,7 @@ pub enum Step< TAttesterSlashing, TPowBlock, TExecutionPayload = String, + TPayloadAttestationMessage = String, > { Tick { tick: u64, @@ -146,6 +159,15 @@ pub enum Step< execution_payload: TExecutionPayload, valid: bool, }, + PayloadAttestationMessage { + payload_attestation_message: TPayloadAttestationMessage, + #[serde(default = "default_true")] + valid: bool, + }, +} + +fn default_true() -> bool { + true } #[derive(Debug, Clone, Deserialize)] @@ -170,6 +192,7 @@ pub struct ForkChoiceTest { AttesterSlashing, PowBlock, SignedExecutionPayloadEnvelope, + PayloadAttestationMessage, >, >, } @@ -184,8 +207,12 @@ impl LoadCase for ForkChoiceTest { .expect("path must be valid OsStr") .to_string(); let spec = &testing_spec::(fork_name); - let steps: Vec, String, String, String>> = - yaml_decode_file(&path.join("steps.yaml"))?; + + #[allow(clippy::type_complexity)] + let steps: Vec< + Step, String, String, String, String, String>, + > = yaml_decode_file(&path.join("steps.yaml"))?; + // Resolve the object names in `steps.yaml` into actual decoded block/attestation objects. let steps = steps .into_iter() @@ -301,6 +328,18 @@ impl LoadCase for ForkChoiceTest { valid, }) } + Step::PayloadAttestationMessage { + payload_attestation_message, + valid, + } => { + let msg: PayloadAttestationMessage = ssz_decode_file( + &path.join(format!("{payload_attestation_message}.ssz_snappy")), + )?; + Ok(Step::PayloadAttestationMessage { + payload_attestation_message: msg, + valid, + }) + } }) .collect::>()?; let anchor_state = ssz_decode_state(&path.join("anchor_state.ssz_snappy"), spec)?; @@ -381,6 +420,8 @@ impl Case for ForkChoiceTest { get_proposer_head, should_override_forkchoice_update: should_override_fcu, head_payload_status, + payload_timeliness_vote, + payload_data_availability_vote, } = checks.as_ref(); if let Some(expected_head) = head { @@ -431,6 +472,14 @@ impl Case for ForkChoiceTest { if let Some(expected_status) = head_payload_status { tester.check_head_payload_status(*expected_status)?; } + + if let Some(expected) = payload_timeliness_vote { + tester.check_payload_timeliness_vote(expected)?; + } + + if let Some(expected) = payload_data_availability_vote { + tester.check_payload_data_availability_vote(expected)?; + } } Step::MaybeValidBlockAndColumns { @@ -446,6 +495,13 @@ impl Case for ForkChoiceTest { } => { tester.process_execution_payload(execution_payload, *valid)?; } + Step::PayloadAttestationMessage { + payload_attestation_message, + valid, + } => { + tester + .process_payload_attestation_message(payload_attestation_message, *valid)?; + } } } @@ -1149,6 +1205,173 @@ impl Tester { expected_should_override_fcu.result, ) } + + pub fn process_payload_attestation_message( + &self, + msg: &PayloadAttestationMessage, + valid: bool, + ) -> Result<(), Error> { + let slot = msg.data.slot; + let block_root = msg.data.beacon_block_root; + + // Get the state at the block to compute the PTC and verify signature. + let store = &self.harness.chain.store; + let block = store + .get_blinded_block(&block_root) + .map_err(|e| Error::InternalError(format!("Failed to load block: {e:?}")))?; + + let state_opt = block.and_then(|block| { + store + .get_hot_state(&block.state_root(), CACHE_STATE_IN_TESTS) + .ok()? + }); + + // Build IndexedPayloadAttestation from the message. + let indexed = IndexedPayloadAttestation:: { + attesting_indices: VariableList::new(vec![msg.validator_index]).unwrap(), + data: msg.data.clone(), + signature: AggregateSignature::from(&msg.signature), + }; + + let result = if let Some(ref state) = state_opt { + is_valid_indexed_payload_attestation( + state, + &indexed, + VerifySignatures::True, + &self.spec, + ) + .map_err(|e| { + Error::InternalError(format!( + "payload attestation signature verification failed for validator {}: {:?}", + msg.validator_index, e + )) + }) + .and_then(|_| { + let ptc = state.get_ptc(slot, &self.spec).map_err(|e| { + Error::InternalError(format!( + "Could not compute PTC for block root {block_root:?} at slot {slot:?}: {e:?}" + )) + })?; + + self.harness + .chain + .canonical_head + .fork_choice_write_lock() + .on_payload_attestation( + self.harness.chain.slot().unwrap(), + &indexed, + AttestationFromBlock::False, + &ptc.0, + ) + .map_err(|e| { + Error::InternalError(format!( + "on_payload_attestation for validator {} failed: {:?}", + msg.validator_index, e + )) + }) + }) + } else { + Err(Error::InternalError(format!( + "Could not get state for block root {block_root:?} at slot {slot:?}" + ))) + }; + + if valid { + result?; + } else if result.is_ok() { + return Err(Error::DidntFail(format!( + "payload_attestation_message for validator {} should have failed", + msg.validator_index + ))); + } + + Ok(()) + } + + pub fn check_payload_timeliness_vote(&self, expected: &PayloadVoteCheck) -> Result<(), Error> { + let fc = self.harness.chain.canonical_head.fork_choice_read_lock(); + let proto_array = fc.proto_array().core_proto_array(); + + let node_index = proto_array + .indices + .get(&expected.block_root) + .ok_or_else(|| { + Error::InternalError(format!( + "Block root {:?} not found in proto array", + expected.block_root + )) + })?; + let node = proto_array + .nodes + .get(*node_index) + .ok_or_else(|| Error::InternalError(format!("Node index {} not found", node_index)))?; + let v29 = node + .as_v29() + .map_err(|_| Error::InternalError("Node is not V29".to_string()))?; + + let timeliness_votes = &v29.payload_timeliness_votes; + let participation = &v29.ptc_participation; + + for (i, expected_vote) in expected.votes.iter().enumerate() { + let actual = if !participation.get(i).unwrap() { + None // not yet voted + } else { + Some(timeliness_votes.get(i).unwrap()) + }; + if actual != *expected_vote { + return Err(Error::NotEqual(format!( + "payload_timeliness_vote[{}]: Got {:?} | Expected {:?}", + i, actual, expected_vote + ))); + } + } + + Ok(()) + } + + pub fn check_payload_data_availability_vote( + &self, + expected: &PayloadVoteCheck, + ) -> Result<(), Error> { + let fc = self.harness.chain.canonical_head.fork_choice_read_lock(); + let proto_array = fc.proto_array().core_proto_array(); + + let node_index = proto_array + .indices + .get(&expected.block_root) + .ok_or_else(|| { + Error::InternalError(format!( + "Block root {:?} not found in proto array", + expected.block_root + )) + })?; + let node = proto_array + .nodes + .get(*node_index) + .ok_or_else(|| Error::InternalError(format!("Node index {} not found", node_index)))?; + let v29 = node + .as_v29() + .map_err(|_| Error::InternalError("Node is not V29".to_string()))?; + + let availability_votes = &v29.payload_data_availability_votes; + let participation = &v29.ptc_participation; + + for (i, expected_vote) in expected.votes.iter().enumerate() { + let actual = if !participation.get(i).unwrap() { + None // not yet voted + } else { + Some(availability_votes.get(i).unwrap()) + }; + if actual != *expected_vote { + return Err(Error::NotEqual(format!( + "payload_data_availability_vote[{}]: Got {:?} | Expected {:?}", + i, actual, expected_vote + ))); + } + } + + Ok(()) + } } /// Checks that the `head` checkpoint from the beacon chain head matches the `fc` checkpoint gleaned diff --git a/testing/ef_tests/src/handler.rs b/testing/ef_tests/src/handler.rs index e380f51c0a..52cc5d57ae 100644 --- a/testing/ef_tests/src/handler.rs +++ b/testing/ef_tests/src/handler.rs @@ -715,10 +715,8 @@ impl Handler for ForkChoiceHandler { return false; } - // Deposit tests exist only for Electra and Fulu (not Gloas). - if self.handler_name == "deposit_with_reorg" - && (!fork_name.electra_enabled() || fork_name.gloas_enabled()) - { + // Deposit tests exist only for Electra and later. + if self.handler_name == "deposit_with_reorg" && !fork_name.electra_enabled() { return false; } @@ -727,10 +725,11 @@ impl Handler for ForkChoiceHandler { return false; } - // on_execution_payload_envelope and get_parent_payload_status tests exist only for - // Gloas and later. + // on_execution_payload_envelope, get_parent_payload_status, and + // on_payload_attestation_message tests exist only for Gloas and later. if (self.handler_name == "on_execution_payload_envelope" - || self.handler_name == "get_parent_payload_status") + || self.handler_name == "get_parent_payload_status" + || self.handler_name == "on_payload_attestation_message") && !fork_name.gloas_enabled() { return false; diff --git a/testing/ef_tests/tests/tests.rs b/testing/ef_tests/tests/tests.rs index ca383efdb0..0ff854bd21 100644 --- a/testing/ef_tests/tests/tests.rs +++ b/testing/ef_tests/tests/tests.rs @@ -1079,6 +1079,12 @@ fn fork_choice_get_parent_payload_status() { ForkChoiceHandler::::new("get_parent_payload_status").run(); } +#[test] +fn fork_choice_on_payload_attestation_message() { + ForkChoiceHandler::::new("on_payload_attestation_message").run(); + ForkChoiceHandler::::new("on_payload_attestation_message").run(); +} + #[test] fn optimistic_sync() { OptimisticSyncHandler::::default().run(); diff --git a/testing/execution_engine_integration/src/test_rig.rs b/testing/execution_engine_integration/src/test_rig.rs index ed6b5787b5..61f25e63a1 100644 --- a/testing/execution_engine_integration/src/test_rig.rs +++ b/testing/execution_engine_integration/src/test_rig.rs @@ -320,6 +320,7 @@ impl TestRig { Some(vec![]), None, None, + None, ), ) .await; @@ -366,11 +367,12 @@ impl TestRig { Some(vec![]), None, None, + None, ); let payload_parameters = PayloadParameters { parent_hash, - parent_gas_limit, + parent_gas_limit: Some(parent_gas_limit), proposer_gas_limit: None, payload_attributes: &payload_attributes, forkchoice_update_params: &forkchoice_update_params, @@ -527,11 +529,12 @@ impl TestRig { Some(vec![]), None, None, + None, ); let payload_parameters = PayloadParameters { parent_hash, - parent_gas_limit, + parent_gas_limit: Some(parent_gas_limit), proposer_gas_limit: None, payload_attributes: &payload_attributes, forkchoice_update_params: &forkchoice_update_params, @@ -588,6 +591,7 @@ impl TestRig { Some(vec![]), None, None, + None, ); let slot = Slot::new(42); let head_block_root = Hash256::repeat_byte(100); diff --git a/validator_client/validator_services/src/proposer_preferences_service.rs b/validator_client/validator_services/src/proposer_preferences_service.rs index fbefdf5d96..fc17a1bce6 100644 --- a/validator_client/validator_services/src/proposer_preferences_service.rs +++ b/validator_client/validator_services/src/proposer_preferences_service.rs @@ -136,7 +136,7 @@ impl ProposerPreferencesSer proposal_slot: duty.slot, validator_index: duty.validator_index, fee_recipient, - gas_limit: proposal_data.gas_limit, + target_gas_limit: proposal_data.gas_limit, }, )); } From 5693d860029571651ed1b497a01f56dc9fe6b6d9 Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Fri, 22 May 2026 10:50:45 -0700 Subject: [PATCH 181/189] Ensure we use the right fork when calculating payload attestation sig domain (#9342) Using `state.fork` is a bit sketchy at the fork boundary. It's safer to just use the payload attestations slot Co-Authored-By: Eitan Seri-Levi --- .../src/per_block_processing/signature_sets.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/consensus/state_processing/src/per_block_processing/signature_sets.rs b/consensus/state_processing/src/per_block_processing/signature_sets.rs index 0686c4d605..ef7109dd94 100644 --- a/consensus/state_processing/src/per_block_processing/signature_sets.rs +++ b/consensus/state_processing/src/per_block_processing/signature_sets.rs @@ -378,10 +378,11 @@ where .data .slot .epoch(E::slots_per_epoch()); + let fork = spec.fork_at_epoch(epoch); let domain = spec.get_domain( epoch, Domain::PTCAttester, - &state.fork(), + &fork, state.genesis_validators_root(), ); From 5045e8dd85cdb4fe50e65f9160a72edefaba074d Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Fri, 22 May 2026 10:50:50 -0700 Subject: [PATCH 182/189] Custody backfill sync only penalize peers once per batch (#9340) During custody backfill sync if a peer fails to serve columns for a batch don't penalize them more than once per batch Co-Authored-By: Eitan Seri-Levi --- .../network/src/sync/custody_backfill_sync/mod.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs index fe4c7dfe4c..c85610613c 100644 --- a/beacon_node/network/src/sync/custody_backfill_sync/mod.rs +++ b/beacon_node/network/src/sync/custody_backfill_sync/mod.rs @@ -593,7 +593,7 @@ impl CustodyBackFillSync { Err(err) => { debug!(batch_epoch = %batch_id, error = ?err, "Batch download failed"); - // If there are any coupling errors, penalize the appropriate peers + // If there are any coupling errors, penalize the appropriate peers. if let RpcResponseError::BlockComponentCouplingError(coupling_error) = err && let CouplingError::DataColumnPeerFailure { error, @@ -601,15 +601,19 @@ impl CustodyBackFillSync { exceeded_retries: _, } = coupling_error { + let mut failed_peers = HashSet::new(); for (column_index, faulty_peer) in faulty_peers { debug!( ?error, ?column_index, ?faulty_peer, - "Custody backfill sync penalizing peer" + "Custody backfill sync: peer failed to serve column" ); + failed_peers.insert(faulty_peer); + } + for peer in failed_peers { network.report_peer( - faulty_peer, + peer, PeerAction::LowToleranceError, "Peer failed to serve column", ); From 0565a01633fbafdb4a261058e7216b5ff6bd35af Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 24 May 2026 17:21:17 -0700 Subject: [PATCH 183/189] Gloas dont enforce peer column custody on block import (#9341) Peers that advertise that they have imported a block may not have the columns for that slot available post-Gloas. Ensure that we dont penalize them. Co-Authored-By: Eitan Seri-Levi --- beacon_node/network/src/sync/network_context/custody.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/beacon_node/network/src/sync/network_context/custody.rs b/beacon_node/network/src/sync/network_context/custody.rs index 620962b40b..2b96800e37 100644 --- a/beacon_node/network/src/sync/network_context/custody.rs +++ b/beacon_node/network/src/sync/network_context/custody.rs @@ -305,7 +305,12 @@ impl ActiveCustodyRequest { // must have its columns in custody. In that case, set `true = enforce max_requests` // 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. - lookup_peers.contains(&peer_id), + // + // 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(), ) .map_err(Error::SendFailed)?; From b9a68ad2c65f2a81e66afeadcaee9d4f9bbae4c2 Mon Sep 17 00:00:00 2001 From: Mac L Date: Mon, 25 May 2026 05:21:26 +0400 Subject: [PATCH 184/189] Add support for jemalloc memory profiling (#9326) Add a new feature flag to `lighthouse` which adds jemalloc profiling support. We could manually add this during memory profiling but it is a nice QoL to have this built-in imo Co-Authored-By: Mac L --- lighthouse/Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lighthouse/Cargo.toml b/lighthouse/Cargo.toml index 3595cf04e7..09fd6d4afe 100644 --- a/lighthouse/Cargo.toml +++ b/lighthouse/Cargo.toml @@ -37,6 +37,8 @@ beacon-node-redb = ["store/redb"] console-subscriber = ["console-subscriber/default"] # Force the use of the system memory allocator rather than jemalloc. sysmalloc = ["malloc_utils/sysmalloc"] +# Enable jemalloc heap profiling support. +jemalloc-profiling = ["malloc_utils/jemalloc-profiling"] [dependencies] account_manager = { "path" = "../account_manager" } From 89ee020330be75cc32bd8cd6752a43ada7d9b22d Mon Sep 17 00:00:00 2001 From: Mac L Date: Mon, 25 May 2026 05:29:34 +0400 Subject: [PATCH 185/189] Add macro to simplify `into_full_block` implementations (#9294) Use a macro to remove the repetitive fork variant boilerplate in `signed_beacon_block.rs` when implementing `into_full_block` for the various `SignedBeaconBlock` variants Co-Authored-By: Mac L --- consensus/types/Cargo.toml | 2 +- .../types/src/block/signed_beacon_block.rs | 350 ++++-------------- 2 files changed, 76 insertions(+), 276 deletions(-) diff --git a/consensus/types/Cargo.toml b/consensus/types/Cargo.toml index 9ee827c7b9..8d991163d2 100644 --- a/consensus/types/Cargo.toml +++ b/consensus/types/Cargo.toml @@ -44,6 +44,7 @@ merkle_proof = { workspace = true } metastruct = "0.1.0" milhouse = { workspace = true } parking_lot = { workspace = true } +paste = { workspace = true } rand = { workspace = true } rand_xorshift = { workspace = true } rayon = { workspace = true } @@ -67,7 +68,6 @@ yaml_serde = { workspace = true } [dev-dependencies] beacon_chain = { workspace = true } criterion = { workspace = true } -paste = { workspace = true } state_processing = { workspace = true } tokio = { workspace = true } types = { path = ".", features = ["arbitrary"] } diff --git a/consensus/types/src/block/signed_beacon_block.rs b/consensus/types/src/block/signed_beacon_block.rs index 11ac17dece..1a87a519d0 100644 --- a/consensus/types/src/block/signed_beacon_block.rs +++ b/consensus/types/src/block/signed_beacon_block.rs @@ -433,285 +433,85 @@ impl From>> } // Post-Bellatrix blocks can be "unblinded" by adding the full payload. -// NOTE: It might be nice to come up with a `superstruct` pattern to abstract over this before -// the first fork after Bellatrix. -impl SignedBeaconBlockBellatrix> { - pub fn into_full_block( - self, - execution_payload: ExecutionPayloadBellatrix, - ) -> SignedBeaconBlockBellatrix> { - let SignedBeaconBlockBellatrix { - message: - BeaconBlockBellatrix { - slot, - proposer_index, - parent_root, - state_root, - body: - BeaconBlockBodyBellatrix { - randao_reveal, - eth1_data, - graffiti, - proposer_slashings, - attester_slashings, - attestations, - deposits, - voluntary_exits, - sync_aggregate, - execution_payload: BlindedPayloadBellatrix { .. }, +macro_rules! impl_into_full_block { + ($fork:ident, [ $($extra_field:ident),* $(,)? ]) => { + paste::paste! { + impl []> { + pub fn into_full_block( + self, + execution_payload: [], + ) -> []> { + let [] { + message: + [] { + slot, + proposer_index, + parent_root, + state_root, + body: + [] { + randao_reveal, + eth1_data, + graffiti, + proposer_slashings, + attester_slashings, + attestations, + deposits, + voluntary_exits, + sync_aggregate, + execution_payload: [] { .. }, + $($extra_field,)* + }, + }, + signature, + } = self; + [] { + message: [] { + slot, + proposer_index, + parent_root, + state_root, + body: [] { + randao_reveal, + eth1_data, + graffiti, + proposer_slashings, + attester_slashings, + attestations, + deposits, + voluntary_exits, + sync_aggregate, + execution_payload: [] { execution_payload }, + $($extra_field,)* + }, }, - }, - signature, - } = self; - SignedBeaconBlockBellatrix { - message: BeaconBlockBellatrix { - slot, - proposer_index, - parent_root, - state_root, - body: BeaconBlockBodyBellatrix { - randao_reveal, - eth1_data, - graffiti, - proposer_slashings, - attester_slashings, - attestations, - deposits, - voluntary_exits, - sync_aggregate, - execution_payload: FullPayloadBellatrix { execution_payload }, - }, - }, - signature, + signature, + } + } + } } - } + }; } -impl SignedBeaconBlockCapella> { - pub fn into_full_block( - self, - execution_payload: ExecutionPayloadCapella, - ) -> SignedBeaconBlockCapella> { - let SignedBeaconBlockCapella { - message: - BeaconBlockCapella { - slot, - proposer_index, - parent_root, - state_root, - body: - BeaconBlockBodyCapella { - randao_reveal, - eth1_data, - graffiti, - proposer_slashings, - attester_slashings, - attestations, - deposits, - voluntary_exits, - sync_aggregate, - execution_payload: BlindedPayloadCapella { .. }, - bls_to_execution_changes, - }, - }, - signature, - } = self; - SignedBeaconBlockCapella { - message: BeaconBlockCapella { - slot, - proposer_index, - parent_root, - state_root, - body: BeaconBlockBodyCapella { - randao_reveal, - eth1_data, - graffiti, - proposer_slashings, - attester_slashings, - attestations, - deposits, - voluntary_exits, - sync_aggregate, - execution_payload: FullPayloadCapella { execution_payload }, - bls_to_execution_changes, - }, - }, - signature, - } - } -} - -impl SignedBeaconBlockDeneb> { - pub fn into_full_block( - self, - execution_payload: ExecutionPayloadDeneb, - ) -> SignedBeaconBlockDeneb> { - let SignedBeaconBlockDeneb { - message: - BeaconBlockDeneb { - slot, - proposer_index, - parent_root, - state_root, - body: - BeaconBlockBodyDeneb { - randao_reveal, - eth1_data, - graffiti, - proposer_slashings, - attester_slashings, - attestations, - deposits, - voluntary_exits, - sync_aggregate, - execution_payload: BlindedPayloadDeneb { .. }, - bls_to_execution_changes, - blob_kzg_commitments, - }, - }, - signature, - } = self; - SignedBeaconBlockDeneb { - message: BeaconBlockDeneb { - slot, - proposer_index, - parent_root, - state_root, - body: BeaconBlockBodyDeneb { - randao_reveal, - eth1_data, - graffiti, - proposer_slashings, - attester_slashings, - attestations, - deposits, - voluntary_exits, - sync_aggregate, - execution_payload: FullPayloadDeneb { execution_payload }, - bls_to_execution_changes, - blob_kzg_commitments, - }, - }, - signature, - } - } -} - -impl SignedBeaconBlockElectra> { - pub fn into_full_block( - self, - execution_payload: ExecutionPayloadElectra, - ) -> SignedBeaconBlockElectra> { - let SignedBeaconBlockElectra { - message: - BeaconBlockElectra { - slot, - proposer_index, - parent_root, - state_root, - body: - BeaconBlockBodyElectra { - randao_reveal, - eth1_data, - graffiti, - proposer_slashings, - attester_slashings, - attestations, - deposits, - voluntary_exits, - sync_aggregate, - execution_payload: BlindedPayloadElectra { .. }, - bls_to_execution_changes, - blob_kzg_commitments, - execution_requests, - }, - }, - signature, - } = self; - SignedBeaconBlockElectra { - message: BeaconBlockElectra { - slot, - proposer_index, - parent_root, - state_root, - body: BeaconBlockBodyElectra { - randao_reveal, - eth1_data, - graffiti, - proposer_slashings, - attester_slashings, - attestations, - deposits, - voluntary_exits, - sync_aggregate, - execution_payload: FullPayloadElectra { execution_payload }, - bls_to_execution_changes, - blob_kzg_commitments, - execution_requests, - }, - }, - signature, - } - } -} - -impl SignedBeaconBlockFulu> { - pub fn into_full_block( - self, - execution_payload: ExecutionPayloadFulu, - ) -> SignedBeaconBlockFulu> { - let SignedBeaconBlockFulu { - message: - BeaconBlockFulu { - slot, - proposer_index, - parent_root, - state_root, - body: - BeaconBlockBodyFulu { - randao_reveal, - eth1_data, - graffiti, - proposer_slashings, - attester_slashings, - attestations, - deposits, - voluntary_exits, - sync_aggregate, - execution_payload: BlindedPayloadFulu { .. }, - bls_to_execution_changes, - blob_kzg_commitments, - execution_requests, - }, - }, - signature, - } = self; - SignedBeaconBlockFulu { - message: BeaconBlockFulu { - slot, - proposer_index, - parent_root, - state_root, - body: BeaconBlockBodyFulu { - randao_reveal, - eth1_data, - graffiti, - proposer_slashings, - attester_slashings, - attestations, - deposits, - voluntary_exits, - sync_aggregate, - execution_payload: FullPayloadFulu { execution_payload }, - bls_to_execution_changes, - blob_kzg_commitments, - execution_requests, - }, - }, - signature, - } - } -} +impl_into_full_block!(Bellatrix, []); +impl_into_full_block!(Capella, [bls_to_execution_changes]); +impl_into_full_block!(Deneb, [bls_to_execution_changes, blob_kzg_commitments]); +impl_into_full_block!( + Electra, + [ + bls_to_execution_changes, + blob_kzg_commitments, + execution_requests + ] +); +impl_into_full_block!( + Fulu, + [ + bls_to_execution_changes, + blob_kzg_commitments, + execution_requests + ] +); // We can convert gloas blocks without payloads into blocks "with" payloads. // TODO(EIP-7732) Look into whether we can remove this in the future since no blinded blocks post-gloas From b5d44bff36b844337725ce05cdd2d6afdc102e4b Mon Sep 17 00:00:00 2001 From: Daniel Knopik <107140945+dknopik@users.noreply.github.com> Date: Mon, 25 May 2026 03:44:43 +0200 Subject: [PATCH 186/189] Enable partial data columns by default on Hoodi and Sepolia (#9343) Enable partial data columns by default on Hoodi and Sepolia. Co-Authored-By: Daniel Knopik --- beacon_node/src/cli.rs | 13 +++++++- beacon_node/src/config.rs | 11 +++++- book/src/help_bn.md | 6 +++- lighthouse/tests/beacon_node.rs | 59 ++++++++++++++++++++++++++++++++- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index 51cda0fac3..9de2edb3de 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -674,11 +674,22 @@ pub fn cli_app() -> Command { Arg::new("enable-partial-columns") .long("enable-partial-columns") .help("Enable partial messages for data columns. This can reduce the amount of \ - data sent over the network.") + data sent over the network. Enabled by default on Hoodi and Sepolia; use \ + --disable-partial-columns to opt out.") .action(ArgAction::SetTrue) .help_heading(FLAG_HEADER) .display_order(0) ) + .arg( + Arg::new("disable-partial-columns") + .long("disable-partial-columns") + .help("Disable partial messages for data columns. Use this on Hoodi or Sepolia \ + to opt out of the default-enabled behavior.") + .action(ArgAction::SetTrue) + .conflicts_with("enable-partial-columns") + .help_heading(FLAG_HEADER) + .display_order(0) + ) /* * Monitoring metrics */ diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index f10f9e3b45..1388611c3e 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -110,7 +110,16 @@ pub fn get_config( set_network_config(&mut client_config.network, cli_args, &data_dir_ref)?; - if parse_flag(cli_args, "enable-partial-columns") { + let default_partial_columns_enabled = spec + .config_name + .as_ref() + .is_some_and(|name| matches!(name.as_str(), "hoodi" | "sepolia")); + let user_disable_partial_columns = parse_flag(cli_args, "disable-partial-columns"); + let user_enable_partial_columns = parse_flag(cli_args, "enable-partial-columns"); + let enable_partial_columns = !user_disable_partial_columns + && (user_enable_partial_columns || default_partial_columns_enabled); + + if enable_partial_columns { // Partial messages assume that each subnet maps to exactly one column. // Check this here to avoid weird issues on networks where this is not the case. if spec.data_column_sidecar_subnet_count == E::number_of_columns() as u64 { diff --git a/book/src/help_bn.md b/book/src/help_bn.md index b580bcae52..7e771a2b4a 100644 --- a/book/src/help_bn.md +++ b/book/src/help_bn.md @@ -482,6 +482,9 @@ Flags: --disable-packet-filter Disables the discovery packet filter. Useful for testing in smaller networks + --disable-partial-columns + Disable partial messages for data columns. Use this on Hoodi or + Sepolia to opt out of the default-enabled behavior. --disable-proposer-reorgs Do not attempt to reorg late blocks from other validators when proposing. @@ -499,7 +502,8 @@ Flags: --listen-address and the UDP port will be --discovery-port. --enable-partial-columns Enable partial messages for data columns. This can reduce the amount - of data sent over the network. + of data sent over the network. Enabled by default on Hoodi and + Sepolia; use --disable-partial-columns to opt out. --enable-private-discovery Lighthouse by default does not discover private IP addresses. Set this flag to enable connection attempts to local addresses. diff --git a/lighthouse/tests/beacon_node.rs b/lighthouse/tests/beacon_node.rs index 0c5d9a5933..623ca1f403 100644 --- a/lighthouse/tests/beacon_node.rs +++ b/lighthouse/tests/beacon_node.rs @@ -2874,7 +2874,7 @@ fn partial_columns() { assert!(config.network.enable_partial_columns); assert!(config.chain.enable_partial_columns); }); - // And disabled by default: + // And disabled by default on mainnet: CommandLineTest::new() .run_with_zero_port() .with_config(|config| { @@ -2882,3 +2882,60 @@ fn partial_columns() { assert!(!config.chain.enable_partial_columns); }) } + +#[test] +fn partial_columns_default_hoodi() { + CommandLineTest::new() + .flag("network", Some("hoodi")) + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_partial_columns); + assert!(config.chain.enable_partial_columns); + }); +} + +#[test] +fn partial_columns_default_sepolia() { + CommandLineTest::new() + .flag("network", Some("sepolia")) + .run_with_zero_port() + .with_config(|config| { + assert!(config.network.enable_partial_columns); + assert!(config.chain.enable_partial_columns); + }); +} + +#[test] +fn partial_columns_disable_overrides_hoodi_default() { + CommandLineTest::new() + .flag("network", Some("hoodi")) + .flag("disable-partial-columns", None) + .run_with_zero_port() + .with_config(|config| { + assert!(!config.network.enable_partial_columns); + assert!(!config.chain.enable_partial_columns); + }); +} + +#[test] +fn partial_columns_disable_on_mainnet_no_op() { + CommandLineTest::new() + .flag("disable-partial-columns", None) + .run_with_zero_port() + .with_config(|config| { + assert!(!config.network.enable_partial_columns); + assert!(!config.chain.enable_partial_columns); + }); +} + +#[test] +fn partial_columns_enable_disable_conflict() { + let mut cmd = base_cmd(); + cmd.arg("--enable-partial-columns") + .arg("--disable-partial-columns"); + let output = cmd.output().expect("should run command"); + assert!( + !output.status.success(), + "expected clap to reject --enable-partial-columns and --disable-partial-columns together", + ); +} From 9b961960c49bb109d0d7363b109be13029b33d32 Mon Sep 17 00:00:00 2001 From: chonghe <44791194+chong-he@users.noreply.github.com> Date: Mon, 25 May 2026 10:11:27 +0800 Subject: [PATCH 187/189] Deprecate some `reorg`-related CLI flags and read from spec (#9177) - #9123 Co-Authored-By: Tan Chee Keong Co-Authored-By: chonghe <44791194+chong-he@users.noreply.github.com> --- Cargo.lock | 1 + beacon_node/beacon_chain/src/beacon_chain.rs | 26 ++++--- .../beacon_chain/src/block_production/mod.rs | 20 ++++-- beacon_node/beacon_chain/src/builder.rs | 21 +----- beacon_node/beacon_chain/src/chain_config.rs | 33 ++------- .../http_api/tests/interactive_tests.rs | 24 ++----- beacon_node/src/cli.rs | 14 ++-- beacon_node/src/config.rs | 68 +++++++++--------- book/src/advanced_re-orgs.md | 8 --- book/src/help_bn.md | 14 ++-- consensus/proto_array/src/proto_array.rs | 8 +-- consensus/types/src/core/chain_spec.rs | 66 +++++++++-------- lighthouse/tests/beacon_node.rs | 71 +++---------------- lighthouse/tests/exec.rs | 1 - testing/ef_tests/Cargo.toml | 1 + testing/ef_tests/src/cases/fork_choice.rs | 18 +++-- 16 files changed, 141 insertions(+), 253 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d42bcd8fc1..129be32fcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2857,6 +2857,7 @@ dependencies = [ "kzg", "logging", "milhouse", + "proto_array", "rayon", "serde", "serde_json", diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index db8f55a18a..d78e279936 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -113,7 +113,7 @@ use operation_pool::{ CompactAttestationRef, OperationPool, PersistedOperationPool, ReceivedPreCapella, }; use parking_lot::{Mutex, RwLock, RwLockWriteGuard}; -use proto_array::{DoNotReOrg, ProposerHeadError}; +use proto_array::{DoNotReOrg, ProposerHeadError, ReOrgThreshold}; use rand::RngCore; use safe_arith::SafeArith; use slasher::Slasher; @@ -5239,15 +5239,14 @@ impl BeaconChain { let _timer = metrics::start_timer(&metrics::FORK_CHOICE_OVERRIDE_FCU_TIMES); // Never override if proposer re-orgs are disabled. - let re_org_head_threshold = self - .config - .re_org_head_threshold - .ok_or(Box::new(DoNotReOrg::ReOrgsDisabled.into()))?; + if self.config.disable_proposer_reorg { + return Err(Box::new(DoNotReOrg::ReOrgsDisabled.into())); + }; - let re_org_parent_threshold = self - .config - .re_org_parent_threshold - .ok_or(Box::new(DoNotReOrg::ReOrgsDisabled.into()))?; + let re_org_head_threshold = ReOrgThreshold(self.spec.reorg_head_weight_threshold); + let re_org_parent_threshold = ReOrgThreshold(self.spec.reorg_parent_weight_threshold); + let re_org_max_epochs_since_finalization = + Epoch::new(self.spec.reorg_max_epochs_since_finalization); let head_block_root = canonical_forkchoice_params.head_root; @@ -5260,7 +5259,7 @@ impl BeaconChain { re_org_head_threshold, re_org_parent_threshold, &self.config.re_org_disallowed_offsets, - self.config.re_org_max_epochs_since_finalization, + re_org_max_epochs_since_finalization, ) .map_err(|e| e.map_inner_error(Error::ProposerHeadForkChoiceError))?; @@ -5281,7 +5280,12 @@ impl BeaconChain { .and_then(|slot_start| { let now = self.slot_clock.now_duration()?; let slot_delay = now.saturating_sub(slot_start); - Some(slot_delay <= self.config.re_org_cutoff(self.spec.get_slot_duration())) + let re_org_cutoff_duration = self + .spec + .compute_slot_component_duration(self.spec.proposer_reorg_cutoff_bps) + .ok()?; + + Some(slot_delay <= re_org_cutoff_duration) }) .unwrap_or(false) } else { diff --git a/beacon_node/beacon_chain/src/block_production/mod.rs b/beacon_node/beacon_chain/src/block_production/mod.rs index fd5e381023..a94bc697b9 100644 --- a/beacon_node/beacon_chain/src/block_production/mod.rs +++ b/beacon_node/beacon_chain/src/block_production/mod.rs @@ -1,10 +1,10 @@ use std::{sync::Arc, time::Duration}; use fork_choice::PayloadStatus; -use proto_array::ProposerHeadError; +use proto_array::{ProposerHeadError, ReOrgThreshold}; use slot_clock::SlotClock; use tracing::{debug, error, info, instrument, warn}; -use types::{BeaconState, Hash256, SignedExecutionPayloadEnvelope, Slot}; +use types::{BeaconState, Epoch, Hash256, SignedExecutionPayloadEnvelope, Slot}; use crate::{ BeaconChain, BeaconChainTypes, BlockProductionError, StateSkipConfig, @@ -174,8 +174,10 @@ impl BeaconChain { head_slot: Slot, canonical_head: Hash256, ) -> Option<(BeaconState, Hash256)> { - let re_org_head_threshold = self.config.re_org_head_threshold?; - let re_org_parent_threshold = self.config.re_org_parent_threshold?; + let re_org_head_threshold = ReOrgThreshold(self.spec.reorg_head_weight_threshold); + let re_org_parent_threshold = ReOrgThreshold(self.spec.reorg_parent_weight_threshold); + let re_org_max_epochs_since_finalization = + Epoch::new(self.spec.reorg_max_epochs_since_finalization); if self.spec.proposer_score_boost.is_none() { warn!( @@ -198,8 +200,12 @@ impl BeaconChain { // 1. It seems we have time to propagate and still receive the proposer boost. // 2. The current head block was seen late. // 3. The `get_proposer_head` conditions from fork choice pass. - let proposing_on_time = - slot_delay < self.config.re_org_cutoff(self.spec.get_slot_duration()); + let re_org_cutoff_duration = self + .spec + .compute_slot_component_duration(self.spec.proposer_reorg_cutoff_bps) + .ok()?; + + let proposing_on_time = slot_delay < re_org_cutoff_duration; if !proposing_on_time { debug!(reason = "not proposing on time", "Not attempting re-org"); return None; @@ -223,7 +229,7 @@ impl BeaconChain { re_org_head_threshold, re_org_parent_threshold, &self.config.re_org_disallowed_offsets, - self.config.re_org_max_epochs_since_finalization, + re_org_max_epochs_since_finalization, ) .map_err(|e| match e { ProposerHeadError::DoNotReOrg(reason) => { diff --git a/beacon_node/beacon_chain/src/builder.rs b/beacon_node/beacon_chain/src/builder.rs index 61c026e0a9..b8da2bcded 100644 --- a/beacon_node/beacon_chain/src/builder.rs +++ b/beacon_node/beacon_chain/src/builder.rs @@ -30,7 +30,7 @@ use kzg::Kzg; use logging::crit; use operation_pool::{OperationPool, PersistedOperationPool}; use parking_lot::{Mutex, RwLock}; -use proto_array::{DisallowedReOrgOffsets, ReOrgThreshold}; +use proto_array::DisallowedReOrgOffsets; use rand::RngCore; use rayon::prelude::*; use slasher::Slasher; @@ -47,8 +47,8 @@ use tracing::{debug, error, info, warn}; use tree_hash::TreeHash; use types::data::CustodyIndex; use types::{ - BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, Epoch, EthSpec, - Hash256, SignedBeaconBlock, Slot, + BeaconState, BlobSidecarList, ChainSpec, ColumnIndex, DataColumnSidecarList, EthSpec, Hash256, + SignedBeaconBlock, Slot, }; /// An empty struct used to "witness" all the `BeaconChainTypes` traits. It has no user-facing @@ -176,21 +176,6 @@ where self } - /// Sets the proposer re-org threshold. - pub fn proposer_re_org_head_threshold(mut self, threshold: Option) -> Self { - self.chain_config.re_org_head_threshold = threshold; - self - } - - /// Sets the proposer re-org max epochs since finalization. - pub fn proposer_re_org_max_epochs_since_finalization( - mut self, - epochs_since_finalization: Epoch, - ) -> Self { - self.chain_config.re_org_max_epochs_since_finalization = epochs_since_finalization; - self - } - /// Sets the proposer re-org disallowed offsets list. pub fn proposer_re_org_disallowed_offsets( mut self, diff --git a/beacon_node/beacon_chain/src/chain_config.rs b/beacon_node/beacon_chain/src/chain_config.rs index b2c017a469..dde09bf105 100644 --- a/beacon_node/beacon_chain/src/chain_config.rs +++ b/beacon_node/beacon_chain/src/chain_config.rs @@ -1,15 +1,10 @@ use crate::custody_context::NodeCustodyType; -pub use proto_array::{DisallowedReOrgOffsets, ReOrgThreshold}; +pub use proto_array::DisallowedReOrgOffsets; use serde::{Deserialize, Serialize}; use std::str::FromStr; use std::{collections::HashSet, sync::LazyLock, time::Duration}; -use types::{Checkpoint, Epoch, Hash256}; +use types::{Checkpoint, Hash256}; -pub const DEFAULT_RE_ORG_HEAD_THRESHOLD: ReOrgThreshold = ReOrgThreshold(20); -pub const DEFAULT_RE_ORG_PARENT_THRESHOLD: ReOrgThreshold = ReOrgThreshold(160); -pub const DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION: Epoch = Epoch::new(2); -/// Default to 1/12th of the slot, which is 1 second on mainnet. -pub const DEFAULT_RE_ORG_CUTOFF_DENOMINATOR: u32 = 12; pub const DEFAULT_FORK_CHOICE_BEFORE_PROPOSAL_TIMEOUT: u64 = 250; /// Default fraction of a slot lookahead for payload preparation (12/3 = 4 seconds on mainnet). @@ -41,14 +36,6 @@ pub struct ChainConfig { pub archive: bool, /// The max size of a message that can be sent over the network. pub max_network_size: usize, - /// Maximum percentage of the head committee weight at which to attempt re-orging the canonical head. - pub re_org_head_threshold: Option, - /// Minimum percentage of the parent committee weight at which to attempt re-orging the canonical head. - pub re_org_parent_threshold: Option, - /// Maximum number of epochs since finalization for attempting a proposer re-org. - pub re_org_max_epochs_since_finalization: Epoch, - /// Maximum delay after the start of the slot at which to propose a reorging block. - pub re_org_cutoff_millis: Option, /// Additional epoch offsets at which re-orging block proposals are not permitted. /// /// By default this list is empty, but it can be useful for reacting to network conditions, e.g. @@ -125,6 +112,8 @@ pub struct ChainConfig { pub enable_partial_columns: bool, /// The node's custody type, determining how many data columns to custody and sample. pub node_custody_type: NodeCustodyType, + /// Disable proposer re-org + pub disable_proposer_reorg: bool, } impl Default for ChainConfig { @@ -134,10 +123,6 @@ impl Default for ChainConfig { weak_subjectivity_checkpoint: None, archive: false, max_network_size: 10 * 1_048_576, // 10M - re_org_head_threshold: Some(DEFAULT_RE_ORG_HEAD_THRESHOLD), - re_org_parent_threshold: Some(DEFAULT_RE_ORG_PARENT_THRESHOLD), - re_org_max_epochs_since_finalization: DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, - re_org_cutoff_millis: None, re_org_disallowed_offsets: DisallowedReOrgOffsets::default(), fork_choice_before_proposal_timeout_ms: DEFAULT_FORK_CHOICE_BEFORE_PROPOSAL_TIMEOUT, // Builder fallback configs that are set in `clap` will override these. @@ -168,15 +153,7 @@ impl Default for ChainConfig { disable_get_blobs: false, enable_partial_columns: false, node_custody_type: NodeCustodyType::Fullnode, + disable_proposer_reorg: false, } } } - -impl ChainConfig { - /// The latest delay from the start of the slot at which to attempt a 1-slot re-org. - pub fn re_org_cutoff(&self, slot_duration: Duration) -> Duration { - self.re_org_cutoff_millis - .map(Duration::from_millis) - .unwrap_or_else(|| slot_duration / DEFAULT_RE_ORG_CUTOFF_DENOMINATOR) - } -} diff --git a/beacon_node/http_api/tests/interactive_tests.rs b/beacon_node/http_api/tests/interactive_tests.rs index b47f8e946a..7b5fb02714 100644 --- a/beacon_node/http_api/tests/interactive_tests.rs +++ b/beacon_node/http_api/tests/interactive_tests.rs @@ -2,7 +2,7 @@ use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::{ ChainConfig, - chain_config::{DisallowedReOrgOffsets, ReOrgThreshold}, + chain_config::DisallowedReOrgOffsets, test_utils::{ AttestationStrategy, BlockStrategy, LightClientStrategy, SyncCommitteeStrategy, test_spec, }, @@ -23,7 +23,7 @@ use std::sync::Arc; use std::time::Duration; use types::{ Address, Epoch, EthSpec, ExecPayload, ExecutionBlockHash, ForkName, Hash256, MainnetEthSpec, - MinimalEthSpec, ProposerPreparationData, Slot, Uint256, + MinimalEthSpec, ProposerPreparationData, Slot, }; type E = MainnetEthSpec; @@ -181,8 +181,6 @@ pub struct ReOrgTest { parent_distance: u64, /// Number of slots between head block and block proposal slot. head_distance: u64, - re_org_threshold: u64, - max_epochs_since_finalization: u64, percent_parent_votes: usize, percent_empty_votes: usize, percent_head_votes: usize, @@ -201,8 +199,6 @@ impl Default for ReOrgTest { head_slot: Slot::new(E::slots_per_epoch() - 2), parent_distance: 1, head_distance: 1, - re_org_threshold: 20, - max_epochs_since_finalization: 2, percent_parent_votes: 100, percent_empty_votes: 100, percent_head_votes: 0, @@ -388,8 +384,6 @@ pub async fn proposer_boost_re_org_test( head_slot, parent_distance, head_distance, - re_org_threshold, - max_epochs_since_finalization, percent_parent_votes, percent_empty_votes, percent_head_votes, @@ -403,8 +397,7 @@ pub async fn proposer_boost_re_org_test( // TODO(EIP-7732): extend test for Gloas — `get_validator_blocks_v3` is missing the // `Eth-Execution-Payload-Blinded` header for Gloas block production responses. - let mut spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); - spec.terminal_total_difficulty = Uint256::from(1); + let spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); // Ensure there are enough validators to have `attesters_per_slot`. let attesters_per_slot = 10; @@ -427,14 +420,9 @@ pub async fn proposer_boost_re_org_test( validator_count, None, Some(Box::new(move |builder| { - builder - .proposer_re_org_head_threshold(Some(ReOrgThreshold(re_org_threshold))) - .proposer_re_org_max_epochs_since_finalization(Epoch::new( - max_epochs_since_finalization, - )) - .proposer_re_org_disallowed_offsets( - DisallowedReOrgOffsets::new::(disallowed_offsets).unwrap(), - ) + builder.proposer_re_org_disallowed_offsets( + DisallowedReOrgOffsets::new::(disallowed_offsets).unwrap(), + ) })), Default::default(), false, diff --git a/beacon_node/src/cli.rs b/beacon_node/src/cli.rs index 9de2edb3de..647b5858cb 100644 --- a/beacon_node/src/cli.rs +++ b/beacon_node/src/cli.rs @@ -1331,8 +1331,7 @@ pub fn cli_app() -> Command { .long("proposer-reorg-threshold") .action(ArgAction::Set) .value_name("PERCENT") - .help("Percentage of head vote weight below which to attempt a proposer reorg. \ - Default: 20%") + .help("DEPRECATED. This flag has no effect.") .conflicts_with("disable-proposer-reorgs") .display_order(0) ) @@ -1340,8 +1339,7 @@ pub fn cli_app() -> Command { Arg::new("proposer-reorg-parent-threshold") .long("proposer-reorg-parent-threshold") .value_name("PERCENT") - .help("Percentage of parent vote weight above which to attempt a proposer reorg. \ - Default: 160%") + .help("DEPRECATED. This flag has no effect.") .conflicts_with("disable-proposer-reorgs") .action(ArgAction::Set) .display_order(0) @@ -1351,8 +1349,7 @@ pub fn cli_app() -> Command { .long("proposer-reorg-epochs-since-finalization") .action(ArgAction::Set) .value_name("EPOCHS") - .help("Maximum number of epochs since finalization at which proposer reorgs are \ - allowed. Default: 2") + .help("DEPRECATED. This flag has no effect.") .conflicts_with("disable-proposer-reorgs") .display_order(0) ) @@ -1361,10 +1358,7 @@ pub fn cli_app() -> Command { .long("proposer-reorg-cutoff") .value_name("MILLISECONDS") .action(ArgAction::Set) - .help("Maximum delay after the start of the slot at which to propose a reorging \ - block. Lower values can prevent failed reorgs by ensuring the block has \ - ample time to propagate and be processed by the network. The default is \ - 1/12th of a slot (1 second on mainnet)") + .help("DEPRECATED. This flag has no effect.") .conflicts_with("disable-proposer-reorgs") .display_order(0) ) diff --git a/beacon_node/src/config.rs b/beacon_node/src/config.rs index 1388611c3e..045b432dc9 100644 --- a/beacon_node/src/config.rs +++ b/beacon_node/src/config.rs @@ -1,8 +1,6 @@ use account_utils::{STDIN_INPUTS_FLAG, read_input_from_user}; use beacon_chain::chain_config::{ - DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR, DEFAULT_RE_ORG_HEAD_THRESHOLD, - DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, DEFAULT_RE_ORG_PARENT_THRESHOLD, - DisallowedReOrgOffsets, INVALID_HOLESKY_BLOCK_ROOT, ReOrgThreshold, + DEFAULT_PREPARE_PAYLOAD_LOOKAHEAD_FACTOR, DisallowedReOrgOffsets, INVALID_HOLESKY_BLOCK_ROOT, }; use beacon_chain::custody_context::NodeCustodyType; use beacon_chain::graffiti_calculator::GraffitiOrigin; @@ -753,41 +751,39 @@ pub fn get_config( .individual_tracking_threshold = count; } - if cli_args.get_flag("disable-proposer-reorgs") { - client_config.chain.re_org_head_threshold = None; - client_config.chain.re_org_parent_threshold = None; - } else { - client_config.chain.re_org_head_threshold = Some( - clap_utils::parse_optional(cli_args, "proposer-reorg-threshold")? - .map(ReOrgThreshold) - .unwrap_or(DEFAULT_RE_ORG_HEAD_THRESHOLD), - ); - client_config.chain.re_org_max_epochs_since_finalization = - clap_utils::parse_optional(cli_args, "proposer-reorg-epochs-since-finalization")? - .unwrap_or(DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION); - client_config.chain.re_org_cutoff_millis = - clap_utils::parse_optional(cli_args, "proposer-reorg-cutoff")?; + client_config.chain.disable_proposer_reorg = cli_args.get_flag("disable-proposer-reorgs"); - client_config.chain.re_org_parent_threshold = Some( - clap_utils::parse_optional(cli_args, "proposer-reorg-parent-threshold")? - .map(ReOrgThreshold) - .unwrap_or(DEFAULT_RE_ORG_PARENT_THRESHOLD), - ); + if clap_utils::parse_optional::(cli_args, "proposer-reorg-threshold")?.is_some() { + warn!("The proposer-reorg-threshold flag is deprecated"); + } - if let Some(disallowed_offsets_str) = - clap_utils::parse_optional::(cli_args, "proposer-reorg-disallowed-offsets")? - { - let disallowed_offsets = disallowed_offsets_str - .split(',') - .map(|s| { - s.parse() - .map_err(|e| format!("invalid disallowed-offsets: {e:?}")) - }) - .collect::, _>>()?; - client_config.chain.re_org_disallowed_offsets = - DisallowedReOrgOffsets::new::(disallowed_offsets) - .map_err(|e| format!("invalid disallowed-offsets: {e:?}"))?; - } + if clap_utils::parse_optional::(cli_args, "proposer-reorg-epochs-since-finalization")? + .is_some() + { + warn!("The proposer-reorg-epochs-since-finalization flag is deprecated"); + } + + if clap_utils::parse_optional::(cli_args, "proposer-reorg-cutoff")?.is_some() { + warn!("The proposer-reorg-cutoff flag is deprecated"); + } + + if clap_utils::parse_optional::(cli_args, "proposer-reorg-parent-threshold")?.is_some() { + warn!("The proposer-reorg-parent-threshold flag is deprecated"); + } + + if let Some(disallowed_offsets_str) = + clap_utils::parse_optional::(cli_args, "proposer-reorg-disallowed-offsets")? + { + let disallowed_offsets = disallowed_offsets_str + .split(',') + .map(|s| { + s.parse() + .map_err(|e| format!("invalid disallowed-offsets: {e:?}")) + }) + .collect::, _>>()?; + client_config.chain.re_org_disallowed_offsets = + DisallowedReOrgOffsets::new::(disallowed_offsets) + .map_err(|e| format!("invalid disallowed-offsets: {e:?}"))?; } client_config.chain.prepare_payload_lookahead = diff --git a/book/src/advanced_re-orgs.md b/book/src/advanced_re-orgs.md index 3a31778786..71751f354f 100644 --- a/book/src/advanced_re-orgs.md +++ b/book/src/advanced_re-orgs.md @@ -14,14 +14,6 @@ attestations and transactions that can be included. There are three flags which control the re-orging behaviour: * `--disable-proposer-reorgs`: turn re-orging off (it's on by default). -* `--proposer-reorg-threshold N`: attempt to orphan blocks with less than N% of the committee vote. If this parameter isn't set then N defaults to 20% when the feature is enabled. -* `--proposer-reorg-epochs-since-finalization N`: only attempt to re-org late blocks when the number of epochs since finalization is less than or equal to N. The default is 2 epochs, - meaning re-orgs will only be attempted when the chain is finalizing optimally. -* `--proposer-reorg-cutoff T`: only attempt to re-org late blocks when the proposal is being made - before T milliseconds into the slot. Delays between the validator client and the beacon node can - cause some blocks to be requested later than the start of the slot, which makes them more likely - to fail. The default cutoff is 1000ms on mainnet, which gives blocks 3000ms to be signed and - propagated before the attestation deadline at 4000ms. * `--proposer-reorg-disallowed-offsets N1,N2,N3...`: Prohibit Lighthouse from attempting to reorg at specific offsets in each epoch. A disallowed offset `N` prevents reorging blocks from being proposed at any `slot` such that `slot % SLOTS_PER_EPOCH == N`. The value to this flag is a diff --git a/book/src/help_bn.md b/book/src/help_bn.md index 7e771a2b4a..30163f1f0c 100644 --- a/book/src/help_bn.md +++ b/book/src/help_bn.md @@ -306,10 +306,7 @@ Options: values are useful for ensuring the EL is given ample notice. Default: 1/3 of a slot. --proposer-reorg-cutoff - Maximum delay after the start of the slot at which to propose a - reorging block. Lower values can prevent failed reorgs by ensuring the - block has ample time to propagate and be processed by the network. The - default is 1/12th of a slot (1 second on mainnet) + DEPRECATED. This flag has no effect. --proposer-reorg-disallowed-offsets Comma-separated list of integer offsets which can be used to avoid proposing reorging blocks at certain slots. An offset of N means that @@ -318,14 +315,11 @@ Options: avoided. Any offsets supplied with this flag will impose additional restrictions. --proposer-reorg-epochs-since-finalization - Maximum number of epochs since finalization at which proposer reorgs - are allowed. Default: 2 + DEPRECATED. This flag has no effect. --proposer-reorg-parent-threshold - Percentage of parent vote weight above which to attempt a proposer - reorg. Default: 160% + DEPRECATED. This flag has no effect. --proposer-reorg-threshold - Percentage of head vote weight below which to attempt a proposer - reorg. Default: 20% + DEPRECATED. This flag has no effect. --prune-blobs Prune blobs from Lighthouse's database when they are older than the data data availability boundary relative to the current epoch. diff --git a/consensus/proto_array/src/proto_array.rs b/consensus/proto_array/src/proto_array.rs index 8ac8354f06..6ff5eabb04 100644 --- a/consensus/proto_array/src/proto_array.rs +++ b/consensus/proto_array/src/proto_array.rs @@ -701,11 +701,9 @@ impl ProtoArray { justified_balances: &JustifiedBalances, spec: &ChainSpec, ) -> bool { - let reorg_threshold = calculate_committee_fraction::( - justified_balances, - spec.reorg_head_weight_threshold.unwrap_or(20), - ) - .unwrap_or(0); + let reorg_threshold = + calculate_committee_fraction::(justified_balances, spec.reorg_head_weight_threshold) + .unwrap_or(0); let head_weight = head_node .attestation_score(PayloadStatus::Pending) diff --git a/consensus/types/src/core/chain_spec.rs b/consensus/types/src/core/chain_spec.rs index c42bb4b5b9..25dcb4ba06 100644 --- a/consensus/types/src/core/chain_spec.rs +++ b/consensus/types/src/core/chain_spec.rs @@ -152,9 +152,9 @@ pub struct ChainSpec { * Fork choice */ pub proposer_score_boost: Option, - pub reorg_head_weight_threshold: Option, - pub reorg_parent_weight_threshold: Option, - pub reorg_max_epochs_since_finalization: Option, + pub reorg_head_weight_threshold: u64, + pub reorg_parent_weight_threshold: u64, + pub reorg_max_epochs_since_finalization: u64, /* * Eth1 @@ -925,7 +925,7 @@ impl ChainSpec { } /// Calculate the duration into a slot for a given slot component - fn compute_slot_component_duration( + pub fn compute_slot_component_duration( &self, component_basis_points: u64, ) -> Result { @@ -1163,9 +1163,9 @@ impl ChainSpec { * Fork choice */ proposer_score_boost: Some(40), - reorg_head_weight_threshold: Some(20), - reorg_parent_weight_threshold: Some(160), - reorg_max_epochs_since_finalization: Some(2), + reorg_head_weight_threshold: 20, + reorg_parent_weight_threshold: 160, + reorg_max_epochs_since_finalization: 2, /* * Eth1 @@ -1588,9 +1588,9 @@ impl ChainSpec { * Fork choice */ proposer_score_boost: Some(40), - reorg_head_weight_threshold: Some(20), - reorg_parent_weight_threshold: Some(160), - reorg_max_epochs_since_finalization: Some(2), + reorg_head_weight_threshold: 20, + reorg_parent_weight_threshold: 160, + reorg_max_epochs_since_finalization: 2, /* * Eth1 @@ -2028,12 +2028,15 @@ pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] proposer_score_boost: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - reorg_head_weight_threshold: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - reorg_parent_weight_threshold: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - reorg_max_epochs_since_finalization: Option>, + #[serde(default = "default_reorg_head_weight_threshold")] + #[serde(with = "serde_utils::quoted_u64")] + reorg_head_weight_threshold: u64, + #[serde(default = "default_reorg_parent_weight_threshold")] + #[serde(with = "serde_utils::quoted_u64")] + reorg_parent_weight_threshold: u64, + #[serde(default = "default_reorg_max_epochs_since_finalization")] + #[serde(with = "serde_utils::quoted_u64")] + reorg_max_epochs_since_finalization: u64, #[serde(with = "serde_utils::quoted_u64")] deposit_chain_id: u64, @@ -2433,6 +2436,18 @@ const fn default_max_per_epoch_activation_churn_limit_gloas() -> u64 { 256_000_000_000 } +const fn default_reorg_head_weight_threshold() -> u64 { + 20 +} + +const fn default_reorg_parent_weight_threshold() -> u64 { + 160 +} + +const fn default_reorg_max_epochs_since_finalization() -> u64 { + 2 +} + fn max_blocks_by_root_request_common(max_request_blocks: u64) -> usize { let max_request_blocks = max_request_blocks as usize; RuntimeVariableList::::new( @@ -2626,15 +2641,9 @@ impl Config { max_per_epoch_activation_churn_limit: spec.max_per_epoch_activation_churn_limit, proposer_score_boost: spec.proposer_score_boost.map(|value| MaybeQuoted { value }), - reorg_head_weight_threshold: spec - .reorg_head_weight_threshold - .map(|value| MaybeQuoted { value }), - reorg_parent_weight_threshold: spec - .reorg_parent_weight_threshold - .map(|value| MaybeQuoted { value }), - reorg_max_epochs_since_finalization: spec - .reorg_max_epochs_since_finalization - .map(|value| MaybeQuoted { value }), + reorg_head_weight_threshold: spec.reorg_head_weight_threshold, + reorg_parent_weight_threshold: spec.reorg_parent_weight_threshold, + reorg_max_epochs_since_finalization: spec.reorg_max_epochs_since_finalization, deposit_chain_id: spec.deposit_chain_id, deposit_network_id: spec.deposit_network_id, @@ -2846,10 +2855,9 @@ impl Config { max_per_epoch_activation_churn_limit, churn_limit_quotient, proposer_score_boost: proposer_score_boost.map(|q| q.value), - reorg_head_weight_threshold: reorg_head_weight_threshold.map(|q| q.value), - reorg_parent_weight_threshold: reorg_parent_weight_threshold.map(|q| q.value), - reorg_max_epochs_since_finalization: reorg_max_epochs_since_finalization - .map(|q| q.value), + reorg_head_weight_threshold, + reorg_parent_weight_threshold, + reorg_max_epochs_since_finalization, deposit_chain_id, deposit_network_id, deposit_contract_address, diff --git a/lighthouse/tests/beacon_node.rs b/lighthouse/tests/beacon_node.rs index 623ca1f403..38d4275a02 100644 --- a/lighthouse/tests/beacon_node.rs +++ b/lighthouse/tests/beacon_node.rs @@ -1,8 +1,6 @@ use crate::exec::{CommandLineTestExec, CompletedTest}; use beacon_node::beacon_chain::chain_config::{ - DEFAULT_RE_ORG_CUTOFF_DENOMINATOR, DEFAULT_RE_ORG_HEAD_THRESHOLD, - DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, DEFAULT_SYNC_TOLERANCE_EPOCHS, - DisallowedReOrgOffsets, + DEFAULT_SYNC_TOLERANCE_EPOCHS, DisallowedReOrgOffsets, }; use beacon_node::beacon_chain::custody_context::NodeCustodyType; use beacon_node::{ @@ -2344,19 +2342,12 @@ fn ensure_panic_on_failed_launch() { fn enable_proposer_re_orgs_default() { CommandLineTest::new() .run_with_zero_port() - .with_config(|config| { - assert_eq!( - config.chain.re_org_head_threshold, - Some(DEFAULT_RE_ORG_HEAD_THRESHOLD) - ); - assert_eq!( - config.chain.re_org_max_epochs_since_finalization, - DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, - ); - assert_eq!( - config.chain.re_org_cutoff(Duration::from_secs(12)), - Duration::from_secs(12) / DEFAULT_RE_ORG_CUTOFF_DENOMINATOR - ); + .with_config_and_spec::(|config, spec| { + assert!(!config.chain.disable_proposer_reorg); + assert_eq!(spec.reorg_head_weight_threshold, 20); + assert_eq!(spec.reorg_parent_weight_threshold, 160); + assert_eq!(spec.reorg_max_epochs_since_finalization, 2); + assert_eq!(spec.proposer_reorg_cutoff_bps, 1667); }); } @@ -2365,52 +2356,8 @@ fn disable_proposer_re_orgs() { CommandLineTest::new() .flag("disable-proposer-reorgs", None) .run_with_zero_port() - .with_config(|config| { - assert_eq!(config.chain.re_org_head_threshold, None); - assert_eq!(config.chain.re_org_parent_threshold, None) - }); -} - -#[test] -fn proposer_re_org_parent_threshold() { - CommandLineTest::new() - .flag("proposer-reorg-parent-threshold", Some("90")) - .run_with_zero_port() - .with_config(|config| assert_eq!(config.chain.re_org_parent_threshold.unwrap().0, 90)); -} - -#[test] -fn proposer_re_org_head_threshold() { - CommandLineTest::new() - .flag("proposer-reorg-threshold", Some("90")) - .run_with_zero_port() - .with_config(|config| assert_eq!(config.chain.re_org_head_threshold.unwrap().0, 90)); -} - -#[test] -fn proposer_re_org_max_epochs_since_finalization() { - CommandLineTest::new() - .flag("proposer-reorg-epochs-since-finalization", Some("8")) - .run_with_zero_port() - .with_config(|config| { - assert_eq!( - config.chain.re_org_max_epochs_since_finalization.as_u64(), - 8 - ) - }); -} - -#[test] -fn proposer_re_org_cutoff() { - CommandLineTest::new() - .flag("proposer-reorg-cutoff", Some("500")) - .run_with_zero_port() - .with_config(|config| { - assert_eq!( - config.chain.re_org_cutoff(Duration::from_secs(12)), - Duration::from_millis(500) - ) - }); + // When --disable-proposer-reorg is used, the field in ChainConfig should become true + .with_config(|config| assert!(config.chain.disable_proposer_reorg)); } #[test] diff --git a/lighthouse/tests/exec.rs b/lighthouse/tests/exec.rs index a25558bc2f..696cf2f40a 100644 --- a/lighthouse/tests/exec.rs +++ b/lighthouse/tests/exec.rs @@ -144,7 +144,6 @@ impl CompletedTest { func(&self.config, &self.dir); } - #[allow(dead_code)] pub fn with_config_and_spec(self, func: F) { let spec = ChainSpec::from_config::(&self.chain_config).unwrap(); func(&self.config, spec); diff --git a/testing/ef_tests/Cargo.toml b/testing/ef_tests/Cargo.toml index 9d09c3dfe6..ac51e827ad 100644 --- a/testing/ef_tests/Cargo.toml +++ b/testing/ef_tests/Cargo.toml @@ -28,6 +28,7 @@ hex = { workspace = true } kzg = { workspace = true } logging = { workspace = true } milhouse = { workspace = true } +proto_array = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/testing/ef_tests/src/cases/fork_choice.rs b/testing/ef_tests/src/cases/fork_choice.rs index 69fce09505..2954ee7eb4 100644 --- a/testing/ef_tests/src/cases/fork_choice.rs +++ b/testing/ef_tests/src/cases/fork_choice.rs @@ -4,10 +4,7 @@ use ::fork_choice::{AttestationFromBlock, PayloadVerificationStatus, ProposerHea use beacon_chain::beacon_proposer_cache::compute_proposer_duties_from_head; use beacon_chain::blob_verification::GossipBlobError; use beacon_chain::block_verification_types::LookupBlock; -use beacon_chain::chain_config::{ - DEFAULT_RE_ORG_HEAD_THRESHOLD, DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, - DEFAULT_RE_ORG_PARENT_THRESHOLD, DisallowedReOrgOffsets, -}; +use beacon_chain::chain_config::DisallowedReOrgOffsets; use beacon_chain::data_column_verification::GossipVerifiedDataColumn; use beacon_chain::slot_clock::SlotClock; use beacon_chain::{ @@ -23,6 +20,7 @@ use bls::AggregateSignature; use execution_layer::{ PayloadStatusV1, PayloadStatusV1Status, json_structures::JsonPayloadStatusV1Status, }; +use proto_array::ReOrgThreshold; use serde::Deserialize; use ssz_derive::Decode; use ssz_types::VariableList; @@ -36,9 +34,9 @@ use std::time::Duration; use types::{ Attestation, AttestationRef, AttesterSlashing, AttesterSlashingRef, BeaconBlock, BeaconState, BlobSidecar, BlobsList, BlockImportSource, Checkpoint, DataColumnSidecar, - DataColumnSidecarList, DataColumnSubnetId, ExecutionBlockHash, Hash256, IndexedAttestation, - IndexedPayloadAttestation, KzgProof, PayloadAttestationMessage, ProposerPreparationData, - SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, Uint256, + DataColumnSidecarList, DataColumnSubnetId, Epoch, ExecutionBlockHash, Hash256, + IndexedAttestation, IndexedPayloadAttestation, KzgProof, PayloadAttestationMessage, + ProposerPreparationData, SignedBeaconBlock, SignedExecutionPayloadEnvelope, Slot, Uint256, }; // When set to true, cache any states fetched from the db. @@ -1027,10 +1025,10 @@ impl Tester { let proposer_head_result = fc.get_proposer_head( slot, canonical_head, - DEFAULT_RE_ORG_HEAD_THRESHOLD, - DEFAULT_RE_ORG_PARENT_THRESHOLD, + ReOrgThreshold(self.spec.reorg_head_weight_threshold), + ReOrgThreshold(self.spec.reorg_parent_weight_threshold), &DisallowedReOrgOffsets::default(), - DEFAULT_RE_ORG_MAX_EPOCHS_SINCE_FINALIZATION, + Epoch::new(self.spec.reorg_max_epochs_since_finalization), ); let proposer_head = match proposer_head_result { Ok(head) => head.parent_node.root(), From 4903fff43052b048f9e28ae149f65c3faafaed69 Mon Sep 17 00:00:00 2001 From: Michael Sproul Date: Mon, 25 May 2026 15:06:27 +1000 Subject: [PATCH 188/189] Fix non-canonical payload attestation processing (#9305) Breakout from: - https://github.com/sigp/lighthouse/pull/9295 We currently do not handle the verification of payload attestations on non-canonical side chains, we always attempt to use the head. The included regression test demonstrates this, and there is _also_ a fork choice compliance test in #9295 that triggers it. This PR is a bit opinionated, but I'll explain my judgements: - We need a way to get the PTC for an arbitrary slot from an arbitrary state. This involves potential state advances, database lookups, etc. There is some fiddly logic required to check that states are in range/etc. - We _already have_ a cache with the exact same lifecycle as the PTCs, namely the attester shuffling cache. Therefore, we can de-duplicate a lot of the complexity by storing the PTCs for a given epoch (and decision block) in this cache. The other opinionated change is in the tests. The previous tests were set up kind of nicely to avoid instantiating a `BeaconChainHarness`. However they were not using mocking, which made testing the non-canonical chain case kind of infeasible. To remedy this, I've changed them to just use a beacon chain harness and create two chains using its relatively easy to use methods for doing this. The running time of the tests goes from something like 2.6s for 8 tests to 3.3s for 9 tests, which is only an increase of 0.04s/test. Negligible. Another plus to using the `BeaconChainHarness` is that it avoids a bunch of the cruft to create synthetic non-mocked beacon chain bits. At the same time, I've made some attempt to improve modularity (and fit with the `GossipVerificationContext`) by pulling out the guts of `with_committee_cache` into a new function (`with_cached_shuffling`) that clearly shows its dependency surface. Co-Authored-By: Michael Sproul Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com> --- .../src/attestation_verification.rs | 6 +- beacon_node/beacon_chain/src/beacon_chain.rs | 186 ++------ beacon_node/beacon_chain/src/errors.rs | 7 + .../gossip_verified_payload_attestation.rs | 73 +-- .../payload_attestation_verification/mod.rs | 14 +- .../payload_attestation_verification/tests.rs | 316 +++++++++---- .../beacon_chain/src/shuffling_cache.rs | 426 +++++++++++++++--- .../beacon_chain/src/state_advance_timer.rs | 39 +- beacon_node/beacon_chain/tests/store_tests.rs | 6 +- .../gossip_methods.rs | 3 +- .../per_block_processing/signature_sets.rs | 27 +- 11 files changed, 687 insertions(+), 416 deletions(-) diff --git a/beacon_node/beacon_chain/src/attestation_verification.rs b/beacon_node/beacon_chain/src/attestation_verification.rs index f35de59e1f..635ca3a2ae 100644 --- a/beacon_node/beacon_chain/src/attestation_verification.rs +++ b/beacon_node/beacon_chain/src/attestation_verification.rs @@ -1023,7 +1023,8 @@ impl<'a, T: BeaconChainTypes> VerifiedUnaggregatedAttestation<'a, T> { let (committee_opt, committees_per_slot) = chain.with_committee_cache( attestation.data.target.root, attestation.data.slot.epoch(T::EthSpec::slots_per_epoch()), - |committee_cache, _| { + |cached_shuffling, _| { + let committee_cache = cached_shuffling.committee_cache.as_ref(); let committee_opt = committee_cache .get_beacon_committee(attestation.data.slot, attestation.committee_index) .map(|beacon_committee| beacon_committee.committee.to_vec()); @@ -1574,7 +1575,8 @@ where return Err(Error::UnknownTargetRoot(target.root)); } - chain.with_committee_cache(target.root, attestation_epoch, |committee_cache, _| { + chain.with_committee_cache(target.root, attestation_epoch, |cached_shuffling, _| { + let committee_cache = cached_shuffling.committee_cache.as_ref(); let committees_per_slot = committee_cache.committees_per_slot(); Ok(committee_cache diff --git a/beacon_node/beacon_chain/src/beacon_chain.rs b/beacon_node/beacon_chain/src/beacon_chain.rs index d78e279936..b3d258a2fb 100644 --- a/beacon_node/beacon_chain/src/beacon_chain.rs +++ b/beacon_node/beacon_chain/src/beacon_chain.rs @@ -77,7 +77,7 @@ use crate::persisted_custody::persist_custody_context; use crate::persisted_fork_choice::PersistedForkChoice; use crate::pre_finalization_cache::PreFinalizationBlockCache; use crate::proposer_preferences_verification::proposer_preference_cache::GossipVerifiedProposerPreferenceCache; -use crate::shuffling_cache::{BlockShufflingIds, ShufflingCache}; +use crate::shuffling_cache::{CachedPTCs, CachedShuffling, ShufflingCache, with_cached_shuffling}; use crate::sync_committee_verification::{ Error as SyncCommitteeError, VerifiedSyncCommitteeMessage, VerifiedSyncContribution, }; @@ -472,7 +472,7 @@ pub struct BeaconChain { /// HTTP server is enabled. pub event_handler: Option>, /// Caches the attester shuffling for a given epoch and shuffling key root. - pub shuffling_cache: RwLock, + pub shuffling_cache: RwLock>, /// Caches the beacon block proposer shuffling for a given epoch and shuffling key root. pub beacon_proposer_cache: Arc>, /// Caches a map of `validator_index -> validator_pubkey`. @@ -1696,7 +1696,8 @@ impl BeaconChain { let (duties, dependent_root) = self.with_committee_cache( head_block_root, epoch, - |committee_cache, dependent_root| { + |cached_shuffling, dependent_root| { + let committee_cache = cached_shuffling.committee_cache.as_ref(); let duties = validator_indices .iter() .map(|validator_index| { @@ -4914,15 +4915,20 @@ impl BeaconChain { ) -> Result<(), BlockError> { for relative_epoch in [RelativeEpoch::Current, RelativeEpoch::Next] { let shuffling_id = AttestationShufflingId::new(block_root, state, relative_epoch)?; + let shuffling_epoch = relative_epoch.into_epoch(state.current_epoch()); - let shuffling_is_cached = self.shuffling_cache.read().contains(&shuffling_id); + if self.shuffling_cache.read().contains(&shuffling_id) { + continue; + } - if !shuffling_is_cached { - state.build_committee_cache(relative_epoch, &self.spec)?; - let committee_cache = state.committee_cache(relative_epoch)?; - self.shuffling_cache - .write() - .insert_committee_cache(shuffling_id, committee_cache); + state.build_committee_cache(relative_epoch, &self.spec)?; + let committee_cache = state.committee_cache(relative_epoch)?.clone(); + + if let Some(ptcs) = CachedPTCs::try_from_state(state, shuffling_epoch, &self.spec)? { + self.shuffling_cache.write().insert_committee_cache( + shuffling_id, + CachedShuffling::new(committee_cache, ptcs), + ); } } Ok(()) @@ -7013,11 +7019,11 @@ impl BeaconChain { ) } - /// Runs the `map_fn` with the committee cache for `shuffling_epoch` from the chain with head + /// Runs the `map_fn` with the cached shuffling for `shuffling_epoch` from the chain with head /// `head_block_root`. The `map_fn` will be supplied two values: /// - /// - `&CommitteeCache`: the committee cache that serves the given parameters. - /// - `Hash256`: the "shuffling decision root" which uniquely identifies the `CommitteeCache`. + /// - `&CachedShuffling`: the committee cache and optional PTCs that serve the given parameters. + /// - `Hash256`: the "shuffling decision root" which uniquely identifies the cached shuffling. /// /// It's not necessary that `head_block_root` matches our current view of the chain, it can be /// any block that is: @@ -7034,12 +7040,12 @@ impl BeaconChain { /// /// ## Notes /// - /// This function exists in this odd "map" pattern because efficiently obtaining a committee + /// This function exists in this odd "map" pattern because efficiently obtaining a shuffling /// can be complex. It might involve reading straight from the `beacon_chain.shuffling_cache` /// or it might involve reading it from a state from the DB. Due to the complexities of /// `RwLock`s on the shuffling cache, a simple `Cow` isn't suitable here. /// - /// If the committee for `(head_block_root, shuffling_epoch)` isn't found in the + /// If the shuffling for `(head_block_root, shuffling_epoch)` isn't found in the /// `shuffling_cache`, we will read a state from disk and then update the `shuffling_cache`. pub fn with_committee_cache( &self, @@ -7048,149 +7054,17 @@ impl BeaconChain { map_fn: F, ) -> Result where - F: Fn(&CommitteeCache, Hash256) -> Result, + F: Fn(&CachedShuffling, Hash256) -> Result, { - let head_block = self - .canonical_head - .fork_choice_read_lock() - .get_block(&head_block_root) - .ok_or(Error::MissingBeaconBlock(head_block_root))?; - - let shuffling_id = BlockShufflingIds { - current: head_block.current_epoch_shuffling_id.clone(), - next: head_block.next_epoch_shuffling_id.clone(), - previous: None, - block_root: head_block.root, - } - .id_for_epoch(shuffling_epoch) - .ok_or_else(|| Error::InvalidShufflingId { + with_cached_shuffling( + &self.canonical_head, + &self.shuffling_cache, + &self.store, + &self.spec, + head_block_root, shuffling_epoch, - head_block_epoch: head_block.slot.epoch(T::EthSpec::slots_per_epoch()), - })?; - - // Obtain the shuffling cache, timing how long we wait. - let mut shuffling_cache = { - let _ = - metrics::start_timer(&metrics::ATTESTATION_PROCESSING_SHUFFLING_CACHE_WAIT_TIMES); - self.shuffling_cache.write() - }; - - if let Some(cache_item) = shuffling_cache.get(&shuffling_id) { - // The shuffling cache is no longer required, drop the write-lock to allow concurrent - // access. - drop(shuffling_cache); - - let committee_cache = cache_item.wait()?; - map_fn(&committee_cache, shuffling_id.shuffling_decision_block) - } else { - // Create an entry in the cache that "promises" this value will eventually be computed. - // This avoids the case where multiple threads attempt to produce the same value at the - // same time. - // - // Creating the promise whilst we hold the `shuffling_cache` lock will prevent the same - // promise from being created twice. - let sender = shuffling_cache.create_promise(shuffling_id.clone())?; - - // Drop the shuffling cache to avoid holding the lock for any longer than - // required. - drop(shuffling_cache); - - debug!( - shuffling_id = ?shuffling_epoch, - head_block_root = head_block_root.to_string(), - "Committee cache miss" - ); - - // If the block's state will be so far ahead of `shuffling_epoch` that even its - // previous epoch committee cache will be too new, then error. Callers of this function - // shouldn't be requesting such old shufflings for this `head_block_root`. - let head_block_epoch = head_block.slot.epoch(T::EthSpec::slots_per_epoch()); - if head_block_epoch > shuffling_epoch + 1 { - return Err(Error::InvalidStateForShuffling { - state_epoch: head_block_epoch, - shuffling_epoch, - }); - } - - let state_read_timer = - metrics::start_timer(&metrics::ATTESTATION_PROCESSING_STATE_READ_TIMES); - - // If the head of the chain can serve this request, use it. - // - // This code is a little awkward because we need to ensure that the head we read and - // the head we copy is identical. Taking one lock to read the head values and another - // to copy the head is liable to race-conditions. - let head_state_opt = self.with_head(|head| { - if head.beacon_block_root == head_block_root { - Ok(Some((head.beacon_state.clone(), head.beacon_state_root()))) - } else { - Ok::<_, Error>(None) - } - })?; - - // Compute the `target_slot` to advance the block's state to. - // - // Since there's a one-epoch look-ahead on the attester shuffling, it suffices to - // only advance into the first slot of the epoch prior to `shuffling_epoch`. - // - // If the `head_block` is already ahead of that slot, then we should load the state - // at that slot, as we've determined above that the `shuffling_epoch` cache will - // not be too far in the past. - let target_slot = std::cmp::max( - shuffling_epoch - .saturating_sub(1_u64) - .start_slot(T::EthSpec::slots_per_epoch()), - head_block.slot, - ); - - // If the head state is useful for this request, use it. Otherwise, read a state from - // disk that is advanced as close as possible to `target_slot`. - let (mut state, state_root) = if let Some((state, state_root)) = head_state_opt { - (state, state_root) - } else { - // We assume that the `Pending` state has the same shufflings as a `Full` state - // for the same block. Analysis: https://hackmd.io/@dapplion/gloas_dependant_root - let (state_root, state) = self - .store - .get_advanced_hot_state(head_block_root, target_slot, head_block.state_root)? - .ok_or(Error::MissingBeaconState(head_block.state_root))?; - (state, state_root) - }; - - metrics::stop_timer(state_read_timer); - let state_skip_timer = - metrics::start_timer(&metrics::ATTESTATION_PROCESSING_STATE_SKIP_TIMES); - - // If the state is still in an earlier epoch, advance it to the `target_slot` so - // that its next epoch committee cache matches the `shuffling_epoch`. - if state.current_epoch() + 1 < shuffling_epoch { - // Advance the state into the required slot, using the "partial" method since the - // state roots are not relevant for the shuffling. - partial_state_advance(&mut state, Some(state_root), target_slot, &self.spec)?; - } - metrics::stop_timer(state_skip_timer); - - let committee_building_timer = - metrics::start_timer(&metrics::ATTESTATION_PROCESSING_COMMITTEE_BUILDING_TIMES); - - let relative_epoch = RelativeEpoch::from_epoch(state.current_epoch(), shuffling_epoch) - .map_err(Error::IncorrectStateForAttestation)?; - - state.build_committee_cache(relative_epoch, &self.spec)?; - - let committee_cache = state.committee_cache(relative_epoch)?.clone(); - let shuffling_decision_block = shuffling_id.shuffling_decision_block; - - self.shuffling_cache - .write() - .insert_committee_cache(shuffling_id, &committee_cache); - - metrics::stop_timer(committee_building_timer); - - sender.send(committee_cache.clone()); - - map_fn(&committee_cache, shuffling_decision_block) - } + map_fn, + ) } /// Dumps the entire canonical chain, from the head to genesis to a vector for analysis. diff --git a/beacon_node/beacon_chain/src/errors.rs b/beacon_node/beacon_chain/src/errors.rs index 9802f091e0..5efe9a3c23 100644 --- a/beacon_node/beacon_chain/src/errors.rs +++ b/beacon_node/beacon_chain/src/errors.rs @@ -251,6 +251,13 @@ pub enum BeaconChainError { request_epoch: Epoch, cache_epoch: Epoch, }, + AttesterCachePtcOutOfBounds { + slot: Slot, + epoch: Epoch, + }, + AttesterCacheNoPtcPreGloas { + slot: Slot, + }, SkipProposerPreparation, FailedColumnCustodyInfoUpdate, } diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs index c36c73b344..3e9f9e4b60 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/gossip_verified_payload_attestation.rs @@ -2,26 +2,29 @@ use super::Error; use crate::beacon_chain::BeaconStore; use crate::canonical_head::CanonicalHead; use crate::observed_attesters::ObservedPayloadAttesters; +use crate::shuffling_cache::{ShufflingCache, with_cached_shuffling}; use crate::validator_pubkey_cache::ValidatorPubkeyCache; use crate::{BeaconChain, BeaconChainError, BeaconChainTypes, metrics}; use bls::AggregateSignature; use educe::Educe; use eth2::types::{EventKind, ForkVersionedResponse}; use parking_lot::RwLock; -use safe_arith::SafeArith; use slot_clock::SlotClock; -use state_processing::per_block_processing::signature_sets::indexed_payload_attestation_signature_set; -use state_processing::state_advance::partial_state_advance; +use state_processing::per_block_processing::signature_sets::indexed_payload_attestation_signature_set_from_pubkeys; use std::borrow::Cow; -use types::{ChainSpec, EthSpec, IndexedPayloadAttestation, PTC, PayloadAttestationMessage, Slot}; +use types::{ + ChainSpec, EthSpec, Hash256, IndexedPayloadAttestation, PTC, PayloadAttestationMessage, Slot, +}; pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { pub slot_clock: &'a T::SlotClock, pub spec: &'a ChainSpec, pub observed_payload_attesters: &'a RwLock>, pub canonical_head: &'a CanonicalHead, + pub shuffling_cache: &'a RwLock>, pub validator_pubkey_cache: &'a RwLock>, pub store: &'a BeaconStore, + pub genesis_validators_root: Hash256, } /// A `PayloadAttestationMessage` that has been verified for propagation on the gossip network. @@ -76,56 +79,18 @@ impl VerifiedPayloadAttestationMessage { return Err(Error::UnknownHeadBlock { beacon_block_root }); } - // Get head state for PTC computation. If the cached head state is too stale - // (e.g. during liveness failures with many skipped slots), fall back to loading - // a more recent state from the store and advancing it if necessary. - let head = ctx.canonical_head.cached_head(); - let head_state = &head.snapshot.beacon_state; - let message_epoch = slot.epoch(T::EthSpec::slots_per_epoch()); - let state_epoch = head_state.current_epoch(); - - // get_ptc can serve epochs in [state_epoch - 1, state_epoch + min_seed_lookahead]. - // If the message epoch is beyond that range, the head state is stale. - let advanced_state = if message_epoch - > state_epoch - .safe_add(ctx.spec.min_seed_lookahead) - .map_err(BeaconChainError::from)? - { - let head_block_root = head.head_block_root(); - let target_slot = message_epoch.start_slot(T::EthSpec::slots_per_epoch()); - - let (state_root, mut state) = ctx - .store - .get_advanced_hot_state( - head_block_root, - target_slot, - head.snapshot.beacon_state_root(), - ) - .map_err(BeaconChainError::from)? - .ok_or(BeaconChainError::MissingBeaconState( - head.snapshot.beacon_state_root(), - ))?; - - if state - .current_epoch() - .safe_add(ctx.spec.min_seed_lookahead) - .map_err(BeaconChainError::from)? - < message_epoch - { - partial_state_advance(&mut state, Some(state_root), target_slot, ctx.spec) - .map_err(BeaconChainError::from)?; - } - - Some(state) - } else { - None - }; - - let state = advanced_state.as_ref().unwrap_or(head_state); + let ptc = with_cached_shuffling( + ctx.canonical_head, + ctx.shuffling_cache, + ctx.store, + ctx.spec, + beacon_block_root, + message_epoch, + |cached_shuffling, _| cached_shuffling.ptc_for_slot(slot), + )?; // [REJECT] `validator_index` is within `get_ptc(state, data.slot)`. - let ptc = state.get_ptc(slot, ctx.spec)?; if !ptc.0.contains(&(validator_index as usize)) { return Err(Error::NotInPTC { validator_index, @@ -145,11 +110,11 @@ impl VerifiedPayloadAttestationMessage { { // [REJECT] The signature is valid with respect to the `validator_index`. let pubkey_cache = ctx.validator_pubkey_cache.read(); - let signature_set = indexed_payload_attestation_signature_set( - state, + let signature_set = indexed_payload_attestation_signature_set_from_pubkeys( |validator_index| pubkey_cache.get(validator_index).map(Cow::Borrowed), &indexed_payload_attestation.signature, &indexed_payload_attestation, + ctx.genesis_validators_root, ctx.spec, ) .map_err(|_| Error::UnknownValidatorIndex(validator_index))?; @@ -204,8 +169,10 @@ impl BeaconChain { spec: &self.spec, observed_payload_attesters: &self.observed_payload_attesters, canonical_head: &self.canonical_head, + shuffling_cache: &self.shuffling_cache, validator_pubkey_cache: &self.validator_pubkey_cache, store: &self.store, + genesis_validators_root: self.genesis_validators_root, } } diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs index 477527c0aa..89ae1bbbdd 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/mod.rs @@ -9,7 +9,7 @@ use crate::BeaconChainError; use strum::AsRefStr; -use types::{BeaconStateError, Hash256, Slot}; +use types::{Hash256, Slot}; pub mod gossip_verified_payload_attestation; @@ -86,12 +86,6 @@ pub enum Error { /// We were unable to process this message due to an internal error. It's unclear if the /// message is valid. BeaconChainError(Box), - /// An error reading beacon state. - /// - /// ## Peer scoring - /// - /// We were unable to process this message due to an internal error. - BeaconStateError(BeaconStateError), } impl From for Error { @@ -100,11 +94,5 @@ impl From for Error { } } -impl From for Error { - fn from(e: BeaconStateError) -> Self { - Error::BeaconStateError(e) - } -} - #[cfg(test)] mod tests; diff --git a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs index c45df51ac8..d4b82c41fc 100644 --- a/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_attestation_verification/tests.rs @@ -1,25 +1,15 @@ use std::sync::Arc; use std::time::Duration; -use bls::{Keypair, Signature}; -use fork_choice::ForkChoice; -use genesis::{generate_deterministic_keypairs, interop_genesis_state}; -use parking_lot::RwLock; -use proto_array::PayloadStatus; +use bls::Signature; use slot_clock::{SlotClock, TestingSlotClock}; use state_processing::AllCaches; -use state_processing::genesis::genesis_block; -use store::{HotColdDB, StoreConfig}; use types::{ - ChainSpec, Checkpoint, Domain, Epoch, EthSpec, Hash256, MinimalEthSpec, PayloadAttestationData, - PayloadAttestationMessage, SignedBeaconBlock, SignedRoot, Slot, + Domain, Epoch, EthSpec, ForkName, Hash256, MinimalEthSpec, PayloadAttestationData, + PayloadAttestationMessage, SignedRoot, Slot, }; use crate::{ - beacon_fork_choice_store::BeaconForkChoiceStore, - beacon_snapshot::BeaconSnapshot, - canonical_head::CanonicalHead, - observed_attesters::ObservedPayloadAttesters, payload_attestation_verification::{ Error as PayloadAttestationError, gossip_verified_payload_attestation::{ @@ -27,7 +17,6 @@ use crate::{ }, }, test_utils::{BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, test_spec}, - validator_pubkey_cache::ValidatorPubkeyCache, }; type E = MinimalEthSpec; @@ -36,96 +25,48 @@ type T = EphemeralHarnessType; const NUM_VALIDATORS: usize = 64; struct TestContext { - canonical_head: CanonicalHead, - observed_payload_attesters: RwLock>, - validator_pubkey_cache: RwLock>, - slot_clock: TestingSlotClock, - keypairs: Vec, - spec: ChainSpec, + harness: BeaconChainHarness, genesis_block_root: Hash256, - store: Arc>, } impl TestContext { fn new() -> Self { - let spec = test_spec::(); - let store = Arc::new( - HotColdDB::open_ephemeral(StoreConfig::default(), Arc::new(spec.clone())) - .expect("should open ephemeral store"), - ); - - let keypairs = generate_deterministic_keypairs(NUM_VALIDATORS); - - let mut state = - interop_genesis_state::(&keypairs, 0, Hash256::repeat_byte(0x42), None, &spec) - .expect("should build genesis state"); - - *state.finalized_checkpoint_mut() = Checkpoint { - epoch: Epoch::new(1), - root: Hash256::ZERO, - }; - - let mut block = genesis_block(&state, &spec).expect("should build genesis block"); - *block.state_root_mut() = state - .update_tree_hash_cache() - .expect("should hash genesis state"); - let signed_block = SignedBeaconBlock::from_block(block, Signature::empty()); - let block_root = signed_block.canonical_root(); - - let snapshot = BeaconSnapshot::new( - Arc::new(signed_block.clone()), - None, - block_root, - state.clone(), - ); - - let fc_store = BeaconForkChoiceStore::get_forkchoice_store(store.clone(), snapshot.clone()) - .expect("should create fork choice store"); - let fork_choice = - ForkChoice::from_anchor(fc_store, block_root, &signed_block, &state, None, &spec) - .expect("should create fork choice"); - - let canonical_head = - CanonicalHead::new(fork_choice, Arc::new(snapshot), PayloadStatus::Pending); - + let spec = Arc::new(test_spec::()); let slot_clock = TestingSlotClock::new( Slot::new(0), Duration::from_secs(0), spec.get_slot_duration(), ); - // Advance past genesis so `now_with_past_tolerance` doesn't underflow. - slot_clock.set_current_time(spec.get_slot_duration()); + let harness = BeaconChainHarness::builder(E::default()) + .spec(spec) + .deterministic_keypairs(NUM_VALIDATORS) + .fresh_ephemeral_store() + .testing_slot_clock(slot_clock) + .build(); - let validator_pubkey_cache = - ValidatorPubkeyCache::new(&state, store.clone()).expect("should create pubkey cache"); + // Advance past genesis so `now_with_past_tolerance` doesn't underflow. + harness + .chain + .slot_clock + .set_current_time(harness.spec.get_slot_duration()); + let genesis_block_root = harness.chain.genesis_block_root; Self { - canonical_head, - observed_payload_attesters: RwLock::new(ObservedPayloadAttesters::default()), - validator_pubkey_cache: RwLock::new(validator_pubkey_cache), - slot_clock, - keypairs, - spec, - genesis_block_root: block_root, - store, + harness, + genesis_block_root, } } fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { - GossipVerificationContext { - slot_clock: &self.slot_clock, - spec: &self.spec, - observed_payload_attesters: &self.observed_payload_attesters, - canonical_head: &self.canonical_head, - validator_pubkey_cache: &self.validator_pubkey_cache, - store: &self.store, - } + self.harness.chain.payload_attestation_gossip_context() } fn ptc_members(&self, slot: Slot) -> Vec { - let head = self.canonical_head.cached_head(); + let head = self.harness.chain.canonical_head.cached_head(); let state = &head.snapshot.beacon_state; - let ptc = state.get_ptc(slot, &self.spec).expect("should get PTC"); + let ptc = state + .get_ptc(slot, &self.harness.spec) + .expect("should get PTC"); ptc.0.to_vec() } @@ -134,16 +75,18 @@ impl TestContext { data: PayloadAttestationData, validator_index: u64, ) -> PayloadAttestationMessage { - let head = self.canonical_head.cached_head(); + let head = self.harness.chain.canonical_head.cached_head(); let state = &head.snapshot.beacon_state; - let domain = self.spec.get_domain( + let domain = self.harness.spec.get_domain( data.slot.epoch(E::slots_per_epoch()), Domain::PTCAttester, &state.fork(), state.genesis_validators_root(), ); let message = data.signing_root(domain); - let signature = self.keypairs[validator_index as usize].sk.sign(message); + let signature = self.harness.validator_keypairs[validator_index as usize] + .sk + .sign(message); PayloadAttestationMessage { validator_index, data, @@ -192,7 +135,7 @@ fn past_slot() { return; } let ctx = TestContext::new(); - ctx.slot_clock.set_slot(5); + ctx.harness.chain.slot_clock.set_slot(5); let gossip = ctx.gossip_ctx(); let msg = make_payload_attestation(Slot::new(0), 0, ctx.genesis_block_root); @@ -328,20 +271,95 @@ fn duplicate_after_valid() { )); } -/// Exercises the `partial_state_advance` fallback in gossip verification when -/// the head state is too stale to compute PTC membership (e.g., during a -/// network liveness failure with many missed slots). #[tokio::test] -async fn stale_head_with_partial_advance() { +async fn ptc_cache_is_primed_at_gloas_fork_boundary() { + // Only run this test once, when FORK_NAME=gloas exactly. + let mut spec = test_spec::(); + if spec.fork_name_at_epoch(Epoch::new(0)) != ForkName::Gloas { + return; + } + + let gloas_fork_epoch = Epoch::new(2); + spec.gloas_fork_epoch = Some(gloas_fork_epoch); + assert_eq!( + spec.fork_name_at_epoch(gloas_fork_epoch - 1), + ForkName::Fulu + ); + assert_eq!(spec.fork_name_at_epoch(gloas_fork_epoch), ForkName::Gloas); + + let slots_per_epoch = E::slots_per_epoch(); + let fork_boundary_slot = gloas_fork_epoch.start_slot(slots_per_epoch); + let test_slots = (fork_boundary_slot.as_u64() + ..fork_boundary_slot.as_u64() + slots_per_epoch * 2) + .map(Slot::new); + + let harness = BeaconChainHarness::builder(E::default()) + .spec(Arc::new(spec)) + .deterministic_keypairs(NUM_VALIDATORS) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + harness.extend_to_slot(fork_boundary_slot).await; + + for slot in test_slots { + harness.chain.slot_clock.set_slot(slot.as_u64()); + assert!( + harness + .chain + .shuffling_cache + .read() + .check_gloas_ptcs_invariant(&harness.spec), + "shuffling cache should satisfy the Gloas PTC invariant" + ); + + let head = harness.chain.canonical_head.cached_head(); + let state = &head.snapshot.beacon_state; + let ptc = state.get_ptc(slot, &harness.spec).expect("should get PTC"); + let validator_index = *ptc.0.first().expect("PTC should have a member") as u64; + let data = PayloadAttestationData { + beacon_block_root: head.head_block_root(), + slot, + payload_present: true, + blob_data_available: true, + }; + let domain = harness.spec.get_domain( + data.slot.epoch(slots_per_epoch), + Domain::PTCAttester, + &state.fork(), + state.genesis_validators_root(), + ); + let signature = harness.validator_keypairs[validator_index as usize] + .sk + .sign(data.signing_root(domain)); + let msg = PayloadAttestationMessage { + validator_index, + data, + signature, + }; + + let result = harness + .chain + .verify_payload_attestation_message_for_gossip(msg); + assert!( + result.is_ok(), + "expected PTC payload attestation to verify at slot {}, got: {:?}", + slot, + result.unwrap_err() + ); + } +} + +/// Exercises payload attestation gossip verification when the message epoch is ahead of the +/// canonical head due to many missed slots. +#[tokio::test] +async fn stale_head_payload_attestation() { if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { return; } let slots_per_epoch = E::slots_per_epoch(); - // Head at epoch 1, message at epoch 5 — 4 epochs of missed slots. - // This exceeds min_seed_lookahead (1), triggering the fallback path: - // get_advanced_hot_state loads the stored state, then partial_state_advance - // advances it through epoch boundaries to populate ptc_window. + // Head at epoch 1, message at epoch 5: 4 epochs of missed slots. let head_slot = Slot::new(slots_per_epoch); let missed_epochs = 4; let target_slot = Slot::new(slots_per_epoch * (1 + missed_epochs)); @@ -360,7 +378,7 @@ async fn stale_head_with_partial_advance() { let head_epoch = head.snapshot.beacon_state.current_epoch(); assert!( target_epoch > head_epoch + harness.spec.min_seed_lookahead, - "precondition: message epoch must exceed head + min_seed_lookahead to trigger fallback" + "precondition: message epoch must exceed head + min_seed_lookahead" ); // GIVEN a slot clock advanced to epoch 5 without producing blocks @@ -385,7 +403,9 @@ async fn stale_head_with_partial_advance() { .expect("should get PTC from reference state"); let validator_index = *ptc.0.first().expect("PTC should have at least one member") as u64; - // WHEN a properly-signed payload attestation from a PTC member is verified. + // WHEN a properly-signed payload attestation from a PTC member is verified. The signature + // domain should come from the spec fork schedule and genesis validators root, not a loaded + // state in the verifier. let domain = harness.spec.get_domain( target_epoch, Domain::PTCAttester, @@ -420,3 +440,105 @@ async fn stale_head_with_partial_advance() { result.unwrap_err() ); } + +/// Exercises payload attestation gossip verification for a non-canonical block whose PTC differs +/// from the canonical chain's PTC for the same slot. +#[tokio::test] +async fn side_chain_payload_attestation_uses_side_chain_ptc() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled()) { + return; + } + + let slots_per_epoch = E::slots_per_epoch(); + let fork_slot = Slot::new(slots_per_epoch); + let target_slot = Slot::new(slots_per_epoch * 4); + let target_epoch = target_slot.epoch(slots_per_epoch); + + let harness = BeaconChainHarness::builder(E::default()) + .default_spec() + .deterministic_keypairs(NUM_VALIDATORS) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + // Build a common prefix through epoch 1. + harness.extend_to_slot(fork_slot).await; + let fork_state = harness.chain.head_snapshot().beacon_state.clone(); + + // Build two branches for several epochs. The side chain skips its first slot, giving it + // different RANDAO mixes and therefore a different PTC by the target slot. The canonical chain + // is processed second and receives sub-finality attestations, so it remains the head without + // finalizing past the side-chain fork point. + let side_slots: Vec<_> = ((fork_slot + 2).as_u64()..=target_slot.as_u64()) + .map(Slot::new) + .collect(); + let canonical_slots: Vec<_> = ((fork_slot + 1).as_u64()..=target_slot.as_u64()) + .map(Slot::new) + .collect(); + let canonical_validators = (0..NUM_VALIDATORS / 2).collect::>(); + + let results = harness + .add_blocks_on_multiple_chains(vec![ + (fork_state.clone(), side_slots, vec![]), + (fork_state, canonical_slots, canonical_validators), + ]) + .await; + + let side_head_root: Hash256 = results[0].2.into(); + let side_head_state = &results[0].3; + let canonical_head_root: Hash256 = results[1].2.into(); + let canonical_head_state = &results[1].3; + + assert_ne!(side_head_root, canonical_head_root); + assert_eq!( + harness.chain.head_snapshot().beacon_block_root, + canonical_head_root + ); + + let side_ptc = side_head_state + .get_ptc(target_slot, &harness.spec) + .expect("should get side-chain PTC"); + let canonical_ptc = canonical_head_state + .get_ptc(target_slot, &harness.spec) + .expect("should get canonical PTC"); + assert_ne!( + side_ptc, canonical_ptc, + "precondition: side-chain PTC should differ from canonical PTC" + ); + + let validator_index = side_ptc + .0 + .iter() + .copied() + .find(|validator_index| !canonical_ptc.0.contains(validator_index)) + .expect("should find a validator in the side-chain PTC only") + as u64; + + let domain = harness.spec.get_domain( + target_epoch, + Domain::PTCAttester, + &side_head_state.fork(), + side_head_state.genesis_validators_root(), + ); + let data = PayloadAttestationData { + beacon_block_root: side_head_root, + slot: target_slot, + payload_present: true, + blob_data_available: true, + }; + let message = data.signing_root(domain); + let signature = harness.validator_keypairs[validator_index as usize] + .sk + .sign(message); + let msg = PayloadAttestationMessage { + validator_index, + data, + signature, + }; + + let verified = harness + .chain + .verify_payload_attestation_message_for_gossip(msg) + .expect("side-chain payload attestation should verify"); + assert_eq!(verified.ptc(), &side_ptc); +} diff --git a/beacon_node/beacon_chain/src/shuffling_cache.rs b/beacon_node/beacon_chain/src/shuffling_cache.rs index 0377b553e3..daaede6ed1 100644 --- a/beacon_node/beacon_chain/src/shuffling_cache.rs +++ b/beacon_node/beacon_chain/src/shuffling_cache.rs @@ -3,23 +3,28 @@ use std::sync::Arc; use itertools::Itertools; use oneshot_broadcast::{Receiver, Sender, oneshot}; +use parking_lot::RwLock; +use state_processing::state_advance::partial_state_advance; use tracing::debug; use types::{ - AttestationShufflingId, BeaconState, Epoch, EthSpec, Hash256, RelativeEpoch, - state::CommitteeCache, + AttestationShufflingId, BeaconState, BeaconStateError, ChainSpec, Epoch, EthSpec, Hash256, PTC, + RelativeEpoch, Slot, state::CommitteeCache, }; -use crate::{BeaconChainError, metrics}; +use crate::{ + BeaconChainError, BeaconChainTypes, BeaconStore, canonical_head::CanonicalHead, metrics, +}; -/// The size of the cache that stores committee caches for quicker verification. +/// The size of the cache that stores shufflings for quicker verification. /// -/// Each entry should be `8 + 800,000 = 800,008` bytes in size with 100k validators. (8-byte hash + -/// 100k indices). Therefore, this cache should be approx `16 * 800,008 = 12.8 MB`. (Note: this -/// ignores a few extra bytes in the caches that should be insignificant compared to the indices). +/// Each entry should be around `8 * 2M + 128KB ~= 16 MB` in size with 2M validators +/// and 32 512-validator PTCs. Therefore, this cache should be approx +/// `16 * 16 MB ~= 256 MB`. (Note: this ignores a few extra bytes in the +/// caches that should be insignificant compared to the indices). pub const DEFAULT_CACHE_SIZE: usize = 16; -/// The maximum number of concurrent committee cache "promises" that can be issued. In effect, this -/// limits the number of concurrent states that can be loaded into memory for the committee cache. +/// The maximum number of concurrent shuffling "promises" that can be issued. In effect, this +/// limits the number of concurrent states that can be loaded into memory for the shuffling. /// This prevents excessive memory usage at the cost of rejecting some attestations. /// /// We set this value to 2 since states can be quite large and have a significant impact on memory @@ -30,19 +35,82 @@ pub const DEFAULT_CACHE_SIZE: usize = 16; const MAX_CONCURRENT_PROMISES: usize = 2; #[derive(Clone)] -pub enum CacheItem { - /// A committee. - Committee(Arc), - /// A promise for a future committee. - Promise(Receiver>), +pub struct CachedShuffling { + pub committee_cache: Arc, + pub ptcs: CachedPTCs, } -impl CacheItem { +#[derive(Clone)] +pub enum CachedPTCs { + PreGloas, + PostGloas(Vec>, Epoch), +} + +impl CachedPTCs { + /// Returns `None` at the Gloas fork boundary (pre-Gloas state, Gloas shuffling epoch); the + /// on-demand miss path in `with_cached_shuffling` handles those. + pub fn try_from_state( + state: &BeaconState, + epoch: Epoch, + spec: &ChainSpec, + ) -> Result, BeaconChainError> { + if shuffling_requires_ptcs(epoch, spec) { + if !state.fork_name_unchecked().gloas_enabled() { + return Ok(None); + } + let ptcs = epoch + .slot_iter(E::slots_per_epoch()) + .map(|slot| state.get_ptc(slot, spec)) + .collect::, _>>()?; + Ok(Some(Self::PostGloas(ptcs, epoch))) + } else { + Ok(Some(Self::PreGloas)) + } + } +} + +impl CachedShuffling { + pub fn new(committee_cache: Arc, ptcs: CachedPTCs) -> Self { + Self { + committee_cache, + ptcs, + } + } + + pub fn ptc_for_slot(&self, slot: Slot) -> Result, BeaconChainError> { + match &self.ptcs { + CachedPTCs::PreGloas => Err(BeaconChainError::AttesterCacheNoPtcPreGloas { slot }), + &CachedPTCs::PostGloas(ref ptcs, epoch) => { + if slot.epoch(E::slots_per_epoch()) != epoch { + Err(BeaconChainError::AttesterCachePtcOutOfBounds { slot, epoch }) + } else { + ptcs.get(slot.as_usize() % E::slots_per_epoch() as usize) + .cloned() + .ok_or(BeaconChainError::AttesterCachePtcOutOfBounds { slot, epoch }) + } + } + } + } +} + +fn shuffling_requires_ptcs(shuffling_epoch: Epoch, spec: &ChainSpec) -> bool { + spec.fork_name_at_epoch(shuffling_epoch).gloas_enabled() +} + +#[derive(Clone)] +pub enum CacheItem { + /// A cached shuffling. + Committee(CachedShuffling), + /// A promise for a future cached shuffling. + Promise(Receiver>), +} + +impl CacheItem { pub fn is_promise(&self) -> bool { matches!(self, CacheItem::Promise(_)) } - pub fn wait(self) -> Result, BeaconChainError> { + pub fn wait(self) -> Result, BeaconChainError> { match self { CacheItem::Committee(cache) => Ok(cache), CacheItem::Promise(receiver) => receiver @@ -52,17 +120,17 @@ impl CacheItem { } } -/// Provides a cache for `CommitteeCache`. +/// Provides a cache for `CommitteeCache` and the associated optional PTCs. /// /// It has been named `ShufflingCache` because `CommitteeCacheCache` is a bit weird and looks like /// a find/replace error. -pub struct ShufflingCache { - cache: HashMap, +pub struct ShufflingCache { + cache: HashMap>, cache_size: usize, head_shuffling_ids: BlockShufflingIds, } -impl ShufflingCache { +impl ShufflingCache { pub fn new(cache_size: usize, head_shuffling_ids: BlockShufflingIds) -> Self { Self { cache: HashMap::new(), @@ -71,22 +139,22 @@ impl ShufflingCache { } } - pub fn get(&mut self, key: &AttestationShufflingId) -> Option { + pub fn get(&mut self, key: &AttestationShufflingId) -> Option> { match self.cache.get(key) { - // The cache contained the committee cache, return it. + // The cache contained the shuffling, return it. item @ Some(CacheItem::Committee(_)) => { metrics::inc_counter(&metrics::SHUFFLING_CACHE_HITS); item.cloned() } - // The cache contains a promise for the committee cache. Check to see if the promise has + // The cache contains a promise for the shuffling. Check to see if the promise has // already been resolved, without waiting for it. item @ Some(CacheItem::Promise(receiver)) => match receiver.try_recv() { // The promise has already been resolved. Replace the entry in the cache with a - // `Committee` entry and then return the committee. - Ok(Some(committee)) => { + // `Committee` entry and then return the cached shuffling. + Ok(Some(cached_shuffling)) => { metrics::inc_counter(&metrics::SHUFFLING_CACHE_PROMISE_HITS); metrics::inc_counter(&metrics::SHUFFLING_CACHE_HITS); - let ready = CacheItem::Committee(committee); + let ready = CacheItem::Committee(cached_shuffling); self.insert_cache_item(key.clone(), ready.clone()); Some(ready) } @@ -97,8 +165,8 @@ impl ShufflingCache { metrics::inc_counter(&metrics::SHUFFLING_CACHE_HITS); item.cloned() } - // The sender has been dropped without sending a committee. There was most likely an - // error computing the committee cache. Drop the key from the cache and return + // The sender has been dropped without sending a shuffling. There was most likely an + // error computing the shuffling. Drop the key from the cache and return // `None` so the caller can recompute the committee. // // It's worth noting that this is the only place where we removed unresolved @@ -113,7 +181,7 @@ impl ShufflingCache { None } }, - // The cache does not have this committee and it's not already promised to be computed. + // The cache does not have this shuffling and it's not already promised to be computed. None => { metrics::inc_counter(&metrics::SHUFFLING_CACHE_MISSES); None @@ -125,27 +193,41 @@ impl ShufflingCache { self.cache.contains_key(key) } - pub fn insert_committee_cache( + /// Check that all entries for Gloas epochs have PTCs. + #[cfg(test)] + pub fn check_gloas_ptcs_invariant(&self, spec: &ChainSpec) -> bool { + self.cache.iter().all(|(key, item)| { + if shuffling_requires_ptcs(key.shuffling_epoch, spec) { + match item { + CacheItem::Committee(cached_shuffling) => { + matches!(cached_shuffling.ptcs, CachedPTCs::PostGloas(..)) + } + CacheItem::Promise(_) => true, + } + } else { + true + } + }) + } + + pub fn insert_committee_cache( &mut self, key: AttestationShufflingId, - committee_cache: &C, + cached_shuffling: CachedShuffling, ) { - if self - .cache - .get(&key) - // Replace the committee if it's not present or if it's a promise. A bird in the hand is - // worth two in the promise-bush! - .is_none_or(CacheItem::is_promise) - { - self.insert_cache_item( - key, - CacheItem::Committee(committee_cache.to_arc_committee_cache()), - ); + match self.cache.get(&key) { + Some(CacheItem::Committee(_)) => { + // Calculation is deterministic, so no need to replace the existing entry. + } + // A bird in the hand is worth two in the promise-bush! + Some(CacheItem::Promise(_)) | None => { + self.insert_cache_item(key, CacheItem::Committee(cached_shuffling)); + } } } /// Prunes the cache first before inserting a new cache item. - fn insert_cache_item(&mut self, key: AttestationShufflingId, cache_item: CacheItem) { + fn insert_cache_item(&mut self, key: AttestationShufflingId, cache_item: CacheItem) { self.prune_cache(); self.cache.insert(key, cache_item); } @@ -188,7 +270,7 @@ impl ShufflingCache { pub fn create_promise( &mut self, key: AttestationShufflingId, - ) -> Result>, BeaconChainError> { + ) -> Result>, BeaconChainError> { let num_active_promises = self .cache .iter() @@ -212,20 +294,170 @@ impl ShufflingCache { } } -/// A helper trait to allow lazy-cloning of the committee cache when inserting into the cache. -pub trait ToArcCommitteeCache { - fn to_arc_committee_cache(&self) -> Arc; -} +pub fn with_cached_shuffling( + canonical_head: &CanonicalHead, + shuffling_cache_lock: &RwLock>, + store: &BeaconStore, + spec: &ChainSpec, + head_block_root: Hash256, + shuffling_epoch: Epoch, + map_fn: F, +) -> Result +where + T: BeaconChainTypes, + F: Fn(&CachedShuffling, Hash256) -> Result, + Error: From, +{ + let head_block = canonical_head + .fork_choice_read_lock() + .get_block(&head_block_root) + .ok_or(BeaconChainError::MissingBeaconBlock(head_block_root))?; -impl ToArcCommitteeCache for CommitteeCache { - fn to_arc_committee_cache(&self) -> Arc { - Arc::new(self.clone()) + let shuffling_id = BlockShufflingIds { + current: head_block.current_epoch_shuffling_id.clone(), + next: head_block.next_epoch_shuffling_id.clone(), + previous: None, + block_root: head_block.root, } -} + .id_for_epoch(shuffling_epoch) + .ok_or_else(|| BeaconChainError::InvalidShufflingId { + shuffling_epoch, + head_block_epoch: head_block.slot.epoch(T::EthSpec::slots_per_epoch()), + })?; -impl ToArcCommitteeCache for Arc { - fn to_arc_committee_cache(&self) -> Arc { - self.clone() + let mut shuffling_cache = { + let _ = metrics::start_timer(&metrics::ATTESTATION_PROCESSING_SHUFFLING_CACHE_WAIT_TIMES); + shuffling_cache_lock.write() + }; + + if let Some(cache_item) = shuffling_cache.get(&shuffling_id) { + drop(shuffling_cache); + + let cached_shuffling = cache_item.wait()?; + map_fn(&cached_shuffling, shuffling_id.shuffling_decision_block) + } else { + // Create an entry in the cache that "promises" this value will eventually be computed. + // This avoids the case where multiple threads attempt to produce the same value at the + // same time. + // + // Creating the promise whilst we hold the `shuffling_cache` lock will prevent the same + // promise from being created twice. + let sender = shuffling_cache.create_promise(shuffling_id.clone())?; + + // Drop the shuffling cache to avoid holding the lock for any longer than required. + drop(shuffling_cache); + + debug!( + shuffling_id = ?shuffling_epoch, + head_block_root = head_block_root.to_string(), + "Committee cache miss" + ); + + // If the block's state will be so far ahead of `shuffling_epoch` that even its previous + // epoch committee cache will be too new, then error. Callers of this function shouldn't be + // requesting such old shufflings for this `head_block_root`. + let head_block_epoch = head_block.slot.epoch(T::EthSpec::slots_per_epoch()); + if head_block_epoch > shuffling_epoch + 1 { + return Err(BeaconChainError::InvalidStateForShuffling { + state_epoch: head_block_epoch, + shuffling_epoch, + } + .into()); + } + + let state_read_timer = + metrics::start_timer(&metrics::ATTESTATION_PROCESSING_STATE_READ_TIMES); + + let cached_head = canonical_head.cached_head(); + let head_state_opt = if cached_head.head_block_root() == head_block_root { + Some(( + cached_head.snapshot.beacon_state.clone(), + cached_head.head_state_root(), + )) + } else { + None + }; + + // Compute the `target_slot` to advance the block's state to. + // + // Since there's a one-epoch look-ahead on the attester shuffling, it suffices to only + // advance into the first slot of the epoch prior to `shuffling_epoch`. + // + // If the `head_block` is already ahead of that slot, then we should load the state at that + // slot, as we've determined above that the `shuffling_epoch` cache will not be too far in + // the past. + let mut target_slot = std::cmp::max( + shuffling_epoch + .saturating_sub(1_u64) + .start_slot(T::EthSpec::slots_per_epoch()), + head_block.slot, + ); + if spec.gloas_fork_epoch == Some(shuffling_epoch) { + target_slot = std::cmp::max( + target_slot, + shuffling_epoch.start_slot(T::EthSpec::slots_per_epoch()), + ); + } + + // If the head state is useful for this request, use it. Otherwise, read a state from disk + // that is advanced as close as possible to `target_slot`. + let (mut state, state_root) = if let Some((state, state_root)) = head_state_opt { + (state, state_root) + } else { + let (state_root, state) = store + .get_advanced_hot_state(head_block_root, target_slot, head_block.state_root) + .map_err(BeaconChainError::DBError)? + .ok_or(BeaconChainError::MissingBeaconState(head_block.state_root))?; + (state, state_root) + }; + + metrics::stop_timer(state_read_timer); + let state_skip_timer = + metrics::start_timer(&metrics::ATTESTATION_PROCESSING_STATE_SKIP_TIMES); + + // If the state is still in an earlier epoch, advance it to the `target_slot` so that its + // next epoch committee cache matches the `shuffling_epoch`. + let advance_to_gloas_fork = spec.gloas_fork_epoch == Some(shuffling_epoch) + && state.current_epoch() < shuffling_epoch; + if state.current_epoch() + 1 < shuffling_epoch || advance_to_gloas_fork { + // Advance the state into the required slot, using the "partial" method since the state + // roots are not relevant for the shuffling. + partial_state_advance(&mut state, Some(state_root), target_slot, spec) + .map_err(BeaconChainError::from)?; + } + metrics::stop_timer(state_skip_timer); + + let committee_building_timer = + metrics::start_timer(&metrics::ATTESTATION_PROCESSING_COMMITTEE_BUILDING_TIMES); + + let relative_epoch = RelativeEpoch::from_epoch(state.current_epoch(), shuffling_epoch) + .map_err(BeaconChainError::IncorrectStateForAttestation)?; + + state + .build_committee_cache(relative_epoch, spec) + .map_err(BeaconChainError::from)?; + + let committee_cache = state + .committee_cache(relative_epoch) + .map_err(BeaconChainError::from)? + .clone(); + // The state has been advanced through the upgrade if needed, so `try_from_state` + // cannot return None here. + let ptcs = CachedPTCs::try_from_state(&state, shuffling_epoch, spec)?.ok_or( + BeaconChainError::BeaconStateError(BeaconStateError::IncorrectStateVariant), + )?; + let shuffling_decision_block = shuffling_id.shuffling_decision_block; + let cached_shuffling = CachedShuffling::new(committee_cache, ptcs); + + shuffling_cache_lock + .write() + .insert_committee_cache(shuffling_id, cached_shuffling.clone()); + + metrics::stop_timer(committee_building_timer); + + sender.send(cached_shuffling.clone()); + + map_fn(&cached_shuffling, shuffling_decision_block) } } @@ -304,7 +536,7 @@ mod test { const TEST_CACHE_SIZE: usize = 5; // Creates a new shuffling cache for testing - fn new_shuffling_cache() -> ShufflingCache { + fn new_shuffling_cache() -> ShufflingCache { create_test_tracing_subscriber(); let current_epoch = 8; @@ -318,6 +550,10 @@ mod test { ShufflingCache::new(TEST_CACHE_SIZE, head_shuffling_ids) } + fn cached_shuffling(committee_cache: Arc) -> CachedShuffling { + CachedShuffling::new(committee_cache, CachedPTCs::PreGloas) + } + /// Returns two different committee caches for testing. fn committee_caches() -> (Arc, Arc) { let harness = BeaconChainHarness::builder(MinimalEthSpec) @@ -366,12 +602,12 @@ mod test { ); // Resolve the promise. - sender.send(committee_a.clone()); + sender.send(cached_shuffling(committee_a.clone())); // Ensure the promise has been resolved. let item = cache.get(&id_a).unwrap(); assert!( - matches!(item, CacheItem::Committee(committee) if committee == committee_a), + matches!(item, CacheItem::Committee(cached_shuffling) if cached_shuffling.committee_cache == committee_a), "the promise should be resolved" ); assert_eq!(cache.cache.len(), 1, "the cache should have one entry"); @@ -428,30 +664,30 @@ mod test { ); // Resolve promise A. - sender_a.send(committee_a.clone()); + sender_a.send(cached_shuffling(committee_a.clone())); // Ensure promise A has been resolved. let item = cache.get(&id_a).unwrap(); assert!( - matches!(item, CacheItem::Committee(committee) if committee == committee_a), + matches!(item, CacheItem::Committee(cached_shuffling) if cached_shuffling.committee_cache == committee_a), "promise A should be resolved" ); // Resolve promise B. - sender_b.send(committee_b.clone()); + sender_b.send(cached_shuffling(committee_b.clone())); // Ensure promise B has been resolved. let item = cache.get(&id_b).unwrap(); assert!( - matches!(item, CacheItem::Committee(committee) if committee == committee_b), + matches!(item, CacheItem::Committee(cached_shuffling) if cached_shuffling.committee_cache == committee_b), "promise B should be resolved" ); // Check both entries again. assert!( - matches!(cache.get(&id_a).unwrap(), CacheItem::Committee(committee) if committee == committee_a), + matches!(cache.get(&id_a).unwrap(), CacheItem::Committee(cached_shuffling) if cached_shuffling.committee_cache == committee_a), "promise A should remain resolved" ); assert!( - matches!(cache.get(&id_b).unwrap(), CacheItem::Committee(committee) if committee == committee_b), + matches!(cache.get(&id_b).unwrap(), CacheItem::Committee(cached_shuffling) if cached_shuffling.committee_cache == committee_b), "promise B should remain resolved" ); assert_eq!(cache.cache.len(), 2, "the cache should have two entries"); @@ -485,9 +721,9 @@ mod test { let mut cache = new_shuffling_cache(); let id_a = shuffling_id(1); let committee_cache_a = Arc::new(CommitteeCache::default()); - cache.insert_committee_cache(id_a.clone(), &committee_cache_a); + cache.insert_committee_cache(id_a.clone(), cached_shuffling(committee_cache_a.clone())); assert!( - matches!(cache.get(&id_a).unwrap(), CacheItem::Committee(committee_cache) if committee_cache == committee_cache_a), + matches!(cache.get(&id_a).unwrap(), CacheItem::Committee(cached_shuffling) if cached_shuffling.committee_cache == committee_cache_a), "should insert committee cache" ); } @@ -500,7 +736,10 @@ mod test { .collect::>(); for (shuffling_id, committee_cache) in shuffling_id_and_committee_caches.iter() { - cache.insert_committee_cache(shuffling_id.clone(), committee_cache); + cache.insert_committee_cache( + shuffling_id.clone(), + cached_shuffling(committee_cache.clone()), + ); } for i in 1..(TEST_CACHE_SIZE + 1) { @@ -533,7 +772,7 @@ mod test { shuffling_epoch: (current_epoch + 1).into(), shuffling_decision_block: Hash256::from_low_u64_be(current_epoch + i as u64), }; - cache.insert_committee_cache(shuffling_id, &committee_cache); + cache.insert_committee_cache(shuffling_id, cached_shuffling(committee_cache.clone())); } // Now, update the head shuffling ids @@ -546,11 +785,17 @@ mod test { cache.update_head_shuffling_ids(head_shuffling_ids.clone()); // Insert head state shuffling ids. Should not be overridden by other shuffling ids. - cache.insert_committee_cache(head_shuffling_ids.current.clone(), &committee_cache); - cache.insert_committee_cache(head_shuffling_ids.next.clone(), &committee_cache); + cache.insert_committee_cache( + head_shuffling_ids.current.clone(), + cached_shuffling(committee_cache.clone()), + ); + cache.insert_committee_cache( + head_shuffling_ids.next.clone(), + cached_shuffling(committee_cache.clone()), + ); cache.insert_committee_cache( head_shuffling_ids.previous.clone().unwrap(), - &committee_cache, + cached_shuffling(committee_cache.clone()), ); // Insert a few entries for older epochs. @@ -559,7 +804,7 @@ mod test { shuffling_epoch: Epoch::from(i), shuffling_decision_block: Hash256::from_low_u64_be(i as u64), }; - cache.insert_committee_cache(shuffling_id, &committee_cache); + cache.insert_committee_cache(shuffling_id, cached_shuffling(committee_cache.clone())); } assert!( @@ -580,4 +825,41 @@ mod test { "should limit cache size" ); } + + /// Pre-Gloas state across the Gloas fork: epoch G-1 returns `Some(PreGloas)`, epoch G and + /// G+1 return `None` (the boundary skip). + #[test] + fn try_from_state_skips_at_gloas_boundary() { + create_test_tracing_subscriber(); + + let mut spec = ForkName::Fulu.make_genesis_spec(E::default_spec()); + let gloas_fork_epoch = Epoch::new(2); + spec.gloas_fork_epoch = Some(gloas_fork_epoch); + + let harness = BeaconChainHarness::builder(MinimalEthSpec) + .spec(Arc::new(spec.clone())) + .deterministic_keypairs(8) + .fresh_ephemeral_store() + .build(); + let state = harness.get_current_state(); + assert!(!state.fork_name_unchecked().gloas_enabled()); + + for (epoch, expect_pre_gloas) in [ + (gloas_fork_epoch - 1, true), + (gloas_fork_epoch, false), + (gloas_fork_epoch + 1, false), + ] { + let result = CachedPTCs::::try_from_state(&state, epoch, &spec) + .expect("must not error at the boundary"); + if expect_pre_gloas { + assert!( + matches!(result, Some(CachedPTCs::PreGloas)), + "epoch {}: expected Some(PreGloas)", + epoch + ); + } else { + assert!(result.is_none(), "epoch {}: expected None", epoch); + } + } + } } diff --git a/beacon_node/beacon_chain/src/state_advance_timer.rs b/beacon_node/beacon_chain/src/state_advance_timer.rs index cb916cb514..6408f861f8 100644 --- a/beacon_node/beacon_chain/src/state_advance_timer.rs +++ b/beacon_node/beacon_chain/src/state_advance_timer.rs @@ -15,7 +15,9 @@ //! 2. There's a possibility that the head block is never built upon, causing wasted CPU cycles. use crate::validator_monitor::HISTORIC_EPOCHS as VALIDATOR_MONITOR_HISTORIC_EPOCHS; use crate::{ - BeaconChain, BeaconChainError, BeaconChainTypes, chain_config::FORK_CHOICE_LOOKAHEAD_FACTOR, + BeaconChain, BeaconChainError, BeaconChainTypes, + chain_config::FORK_CHOICE_LOOKAHEAD_FACTOR, + shuffling_cache::{CachedPTCs, CachedShuffling}, }; use slot_clock::SlotClock; use state_processing::per_slot_processing; @@ -394,19 +396,30 @@ fn advance_head(beacon_chain: &Arc>) -> Resu .map_err(BeaconChainError::from)?; let committee_cache = state .committee_cache(RelativeEpoch::Next) - .map_err(BeaconChainError::from)?; - beacon_chain - .shuffling_cache - .write() - .insert_committee_cache(shuffling_id.clone(), committee_cache); + .map_err(BeaconChainError::from)? + .clone(); + let shuffling_epoch = RelativeEpoch::Next.into_epoch(state.current_epoch()); - debug!( - ?head_block_root, - next_epoch_shuffling_root = ?shuffling_id.shuffling_decision_block, - state_epoch = %state.current_epoch(), - current_epoch = %current_slot.epoch(T::EthSpec::slots_per_epoch()), - "Primed proposer and attester caches" - ); + if let Some(ptcs) = CachedPTCs::try_from_state(&state, shuffling_epoch, &beacon_chain.spec)? + { + beacon_chain.shuffling_cache.write().insert_committee_cache( + shuffling_id.clone(), + CachedShuffling::new(committee_cache, ptcs), + ); + + debug!( + ?head_block_root, + next_epoch_shuffling_root = ?shuffling_id.shuffling_decision_block, + state_epoch = %state.current_epoch(), + current_epoch = %current_slot.epoch(T::EthSpec::slots_per_epoch()), + "Primed proposer and attester caches" + ); + } else { + debug!( + %shuffling_epoch, + "Skipping priming of attester cache for Gloas boundary epoch" + ); + } } let final_slot = state.slot(); diff --git a/beacon_node/beacon_chain/tests/store_tests.rs b/beacon_node/beacon_chain/tests/store_tests.rs index 7e50f4e5ac..0ac77dcfaa 100644 --- a/beacon_node/beacon_chain/tests/store_tests.rs +++ b/beacon_node/beacon_chain/tests/store_tests.rs @@ -1209,7 +1209,8 @@ fn check_shuffling_compatible( .with_committee_cache( block_root, head_state.current_epoch(), - |committee_cache, _| { + |cached_shuffling, _| { + let committee_cache = cached_shuffling.committee_cache.as_ref(); let state_cache = head_state.committee_cache(RelativeEpoch::Current).unwrap(); // We used to check for false negatives here, but had to remove that check // because `shuffling_is_compatible` does not guarantee their absence. @@ -1247,7 +1248,8 @@ fn check_shuffling_compatible( .with_committee_cache( block_root, head_state.previous_epoch(), - |committee_cache, _| { + |cached_shuffling, _| { + let committee_cache = cached_shuffling.committee_cache.as_ref(); let state_cache = head_state.committee_cache(RelativeEpoch::Previous).unwrap(); if previous_epoch_shuffling_is_compatible { assert_eq!(committee_cache, state_cache.as_ref()); diff --git a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs index 3e8845f017..14cda1b483 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4258,8 +4258,7 @@ impl NetworkBeaconProcessor { "payload_attn_invalid_sig", ); } - PayloadAttestationError::BeaconChainError(_) - | PayloadAttestationError::BeaconStateError(_) => { + PayloadAttestationError::BeaconChainError(_) => { debug!( %peer_id, %message_slot, diff --git a/consensus/state_processing/src/per_block_processing/signature_sets.rs b/consensus/state_processing/src/per_block_processing/signature_sets.rs index ef7109dd94..f56cb17554 100644 --- a/consensus/state_processing/src/per_block_processing/signature_sets.rs +++ b/consensus/state_processing/src/per_block_processing/signature_sets.rs @@ -363,6 +363,26 @@ pub fn indexed_payload_attestation_signature_set<'a, 'b, E, F>( indexed_payload_attestation: &'b IndexedPayloadAttestation, spec: &'a ChainSpec, ) -> Result> +where + E: EthSpec, + F: Fn(usize) -> Option>, +{ + indexed_payload_attestation_signature_set_from_pubkeys( + get_pubkey, + signature, + indexed_payload_attestation, + state.genesis_validators_root(), + spec, + ) +} + +pub fn indexed_payload_attestation_signature_set_from_pubkeys<'a, 'b, E, F>( + get_pubkey: F, + signature: &'a AggregateSignature, + indexed_payload_attestation: &'b IndexedPayloadAttestation, + genesis_validators_root: Hash256, + spec: &'a ChainSpec, +) -> Result> where E: EthSpec, F: Fn(usize) -> Option>, @@ -379,12 +399,7 @@ where .slot .epoch(E::slots_per_epoch()); let fork = spec.fork_at_epoch(epoch); - let domain = spec.get_domain( - epoch, - Domain::PTCAttester, - &fork, - state.genesis_validators_root(), - ); + let domain = spec.get_domain(epoch, Domain::PTCAttester, &fork, genesis_validators_root); let message = indexed_payload_attestation.data.signing_root(domain); From dfb259171a65cacd6db57b8874af8f543cabcb7a Mon Sep 17 00:00:00 2001 From: Eitan Seri-Levi Date: Sun, 24 May 2026 22:09:38 -0700 Subject: [PATCH 189/189] Ensure we can serve blocks and columns after `head` event is emitted (#9338) See related issue: https://github.com/ethpandaops/dora/pull/713 When LH emits a `head` event the block isn't written to disk yet. Some upstream consumers may expect that after a `head` event that the block should be queryable via the beacon api. This PR falls back to fetching the block from the early attester cache if it wasn't found in the store. This should ensure that a block is always queryable immediately after a `head` event is emitted. Additionally I noticed that when serving columns we always default to using the store. We already have `get_data_columns_checking_all_caches ` which tries the da cache, then the store and finally the early attester cache. Co-Authored-By: Eitan Seri-Levi Co-Authored-By: Michael Sproul --- beacon_node/http_api/src/block_id.rs | 236 ++++++++++++++++++++++++--- 1 file changed, 216 insertions(+), 20 deletions(-) diff --git a/beacon_node/http_api/src/block_id.rs b/beacon_node/http_api/src/block_id.rs index e6b1ed0879..ca980b96a4 100644 --- a/beacon_node/http_api/src/block_id.rs +++ b/beacon_node/http_api/src/block_id.rs @@ -129,6 +129,15 @@ impl BlockId { .is_finalized_block(root, block_slot) .map_err(warp_utils::reject::unhandled_error)?; Ok((*root, execution_optimistic, finalized)) + } else if chain.early_attester_cache.get_block(*root).is_some() { + // Fall back to the early attester cache for blocks that are in fork choice + // but haven't been written to disk yet. + let execution_optimistic = chain + .canonical_head + .fork_choice_read_lock() + .is_optimistic_or_invalid_block(root) + .unwrap_or(false); + Ok((*root, execution_optimistic, false)) } else { Err(warp_utils::reject::custom_not_found(format!( "beacon block with root {}", @@ -143,9 +152,18 @@ impl BlockId { root: &Hash256, chain: &BeaconChain, ) -> Result>, warp::Rejection> { - chain + if let Some(block) = chain .get_blinded_block(root) - .map_err(warp_utils::reject::unhandled_error) + .map_err(warp_utils::reject::unhandled_error)? + { + return Ok(Some(block)); + } + // Fall back to the early attester cache for blocks that are in fork choice + // but haven't been written to disk yet. + Ok(chain + .early_attester_cache + .get_block(*root) + .map(|b| b.clone_as_blinded())) } /// Return the `SignedBeaconBlock` identified by `self`. @@ -253,20 +271,20 @@ impl BlockId { } _ => { let (root, execution_optimistic, finalized) = self.root(chain)?; - chain + let block_opt = chain .get_block(&root) .await - .map_err(warp_utils::reject::unhandled_error) - .and_then(|block_opt| { - block_opt - .map(|block| (Arc::new(block), execution_optimistic, finalized)) - .ok_or_else(|| { - warp_utils::reject::custom_not_found(format!( - "beacon block with root {}", - root - )) - }) - }) + .map_err(warp_utils::reject::unhandled_error)?; + let block = block_opt + .map(Arc::new) + .or_else(|| chain.early_attester_cache.get_block(root)) + .ok_or_else(|| { + warp_utils::reject::custom_not_found(format!( + "beacon block with root {}", + root + )) + })?; + Ok((block, execution_optimistic, finalized)) } } } @@ -290,16 +308,20 @@ impl BlockId { } let data_column_sidecars = if let Some(indices) = query.indices { - indices - .iter() - .filter_map(|index| chain.get_data_column(&root, index, fork_name).transpose()) - .collect::, _>>() + chain + .get_data_columns_checking_all_caches(root, &indices) .map_err(warp_utils::reject::unhandled_error)? } else { chain - .get_data_columns(&root, fork_name) + .early_attester_cache + .get_data_columns(root) + .map(Ok) + .unwrap_or_else(|| { + chain + .get_data_columns(&root, fork_name) + .map(|opt| opt.unwrap_or_default()) + }) .map_err(warp_utils::reject::unhandled_error)? - .unwrap_or_default() }; let fork_name = block @@ -507,3 +529,177 @@ impl fmt::Display for BlockId { write!(f, "{}", self.0) } } + +#[cfg(test)] +mod tests { + use super::*; + use beacon_chain::{ + PayloadVerificationStatus, + block_verification_types::{AvailableBlockData, RangeSyncBlock}, + test_utils::{ + BeaconChainHarness, EphemeralHarnessType, fork_name_from_env, + generate_data_column_sidecars_from_block, + }, + }; + use std::time::Duration; + use types::MinimalEthSpec; + + type TestHarness = BeaconChainHarness>; + + fn harness() -> TestHarness { + BeaconChainHarness::builder(MinimalEthSpec) + .default_spec() + .deterministic_keypairs(8) + .fresh_ephemeral_store() + .mock_execution_layer() + .build() + } + + #[tokio::test] + async fn root_uses_early_attester_cache_for_unpersisted_block() { + let Some(fork_name) = fork_name_from_env().filter(|fork_name| fork_name.fulu_enabled()) + else { + return; + }; + let harness = harness(); + let chain = &harness.chain; + + harness.execution_block_generator().set_min_blob_count(1); + harness.advance_slot(); + + let (block_contents, post_state) = harness + .make_block(harness.get_current_state(), harness.get_current_slot()) + .await; + let (block, _) = block_contents; + let block_root = block.canonical_root(); + let block_fork_name = chain.spec.fork_name_at_epoch(block.epoch()); + + assert_eq!( + block_fork_name, fork_name, + "precondition: test block must be produced at {fork_name:?}" + ); + assert!( + block.num_expected_blobs() > 0, + "precondition: {fork_name:?} test block must have blobs that can be converted to data columns" + ); + + assert!( + !chain.store.block_exists(&block_root).unwrap(), + "precondition: test block must not be persisted" + ); + assert!( + chain.get_blinded_block(&block_root).unwrap().is_none(), + "precondition: test block must not be retrievable from the store" + ); + assert!( + chain + .get_data_columns(&block_root, block_fork_name) + .unwrap() + .is_none(), + "precondition: test data columns must not be retrievable from the store" + ); + assert!( + !chain.block_is_known_to_fork_choice(&block_root), + "precondition: test block must not be imported into fork choice yet" + ); + + let sampling_columns = chain.sampling_columns_for_epoch(block.epoch()); + let data_columns = generate_data_column_sidecars_from_block(&block, &chain.spec) + .into_iter() + .filter(|column| sampling_columns.contains(column.index())) + .collect::>(); + assert!( + !data_columns.is_empty(), + "precondition: {fork_name:?} test block must produce data columns" + ); + + let available_block = RangeSyncBlock::new( + block.clone(), + AvailableBlockData::new_with_data_columns(data_columns), + &chain.data_availability_checker, + chain.spec.clone(), + ) + .unwrap() + .into_available_block(); + + let current_slot = harness.get_current_slot(); + let cached_head = chain.canonical_head.cached_head(); + let canonical_head_proposer_index = chain + .canonical_head_proposer_index(current_slot, &cached_head) + .unwrap(); + + chain + .canonical_head + .fork_choice_write_lock() + .on_block( + current_slot, + block.message(), + block_root, + Duration::ZERO, + &post_state, + PayloadVerificationStatus::Verified, + canonical_head_proposer_index, + &chain.spec, + ) + .unwrap(); + + assert!( + chain.block_is_known_to_fork_choice(&block_root), + "precondition: test block must be imported into fork choice" + ); + assert!( + !chain.store.block_exists(&block_root).unwrap(), + "precondition: fork choice insertion must not persist the block" + ); + + let proto_block = chain + .canonical_head + .fork_choice_read_lock() + .get_block(&block_root) + .unwrap(); + + chain + .early_attester_cache + .add_head_block(block_root, &available_block, proto_block, &post_state) + .unwrap(); + + let cached_data_columns = chain + .early_attester_cache + .get_data_columns(block_root) + .expect("precondition: data columns must be cached"); + assert!( + !cached_data_columns.is_empty(), + "precondition: cached data columns must be non-empty" + ); + + assert_eq!( + BlockId(CoreBlockId::Root(block_root)).root(chain).unwrap(), + (block_root, false, false) + ); + + let (blinded_block, execution_optimistic, finalized) = + BlockId(CoreBlockId::Root(block_root)) + .blinded_block(chain) + .unwrap(); + assert_eq!(blinded_block.canonical_root(), block_root); + assert_eq!(blinded_block.slot(), block.slot()); + assert!(!execution_optimistic); + assert!(!finalized); + + let (data_columns, data_columns_fork_name, execution_optimistic, finalized) = + BlockId(CoreBlockId::Root(block_root)) + .get_data_columns(DataColumnIndicesQuery { indices: None }, chain) + .unwrap(); + assert_eq!(data_columns, cached_data_columns); + assert_eq!(data_columns_fork_name, fork_name); + assert!(!execution_optimistic); + assert!(!finalized); + + chain.early_attester_cache.clear(); + + assert!( + BlockId(CoreBlockId::Root(block_root)).root(chain).is_err(), + "root lookup should fail once the unpersisted block leaves the early attester cache" + ); + } +}