From 1acdb7aab106c612ca9adce83950c019db2425f2 Mon Sep 17 00:00:00 2001 From: conache Date: Thu, 24 Sep 2026 18:58:21 +0300 Subject: [PATCH 1/5] Handle inclusion list bits in the block production --- .../src/block_production/gloas.rs | 83 ++++++++++++--- .../beacon_chain/src/inclusion_list_store.rs | 25 +++-- .../direct_verified_bid.rs | 100 ++++++++++++++++-- .../gossip_verified_bid.rs | 32 +++++- .../src/payload_bid_verification/mod.rs | 2 + .../gossip_methods.rs | 3 +- .../types/src/execution/inclusion_list.rs | 5 +- consensus/types/src/execution/mod.rs | 2 +- 8 files changed, 219 insertions(+), 33 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 87ef3b6bbbd..60c847d5741 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -10,7 +10,7 @@ use execution_layer::{ PayloadParameters, }; use operation_pool::CompactAttestationRef; -use ssz::{BitVector, Encode, ProgressiveBitList}; +use ssz::{Encode, ProgressiveBitList}; use ssz_types::ProgressiveVariableList; use state_processing::common::{get_attesting_indices_from_state, get_indexed_payload_attestation}; use state_processing::envelope_processing::verify_execution_payload_envelope; @@ -33,8 +33,8 @@ use types::{ BeaconBlockBodyGloas, BeaconBlockBodyHeze, BeaconBlockGloas, BeaconBlockHeze, BeaconState, BeaconStateError, BlobsList, BuilderIndex, Deposit, Eth1Data, EthSpec, ExecutionBlockHash, ExecutionPayloadBidGloas, ExecutionPayloadBidHeze, ExecutionPayloadEnvelope, - ExecutionRequestsGloas, FullPayload, Graffiti, Hash256, IndexedAttestation, KzgProofs, - PayloadAttestation, ProgressiveTransactions, ProposerSlashing, RelativeEpoch, + ExecutionRequestsGloas, FullPayload, Graffiti, Hash256, InclusionListBits, IndexedAttestation, + KzgProofs, PayloadAttestation, ProgressiveTransactions, ProposerSlashing, RelativeEpoch, SignedBeaconBlock, SignedBlsToExecutionChange, SignedExecutionPayloadBid, SignedExecutionPayloadBidGloas, SignedExecutionPayloadBidHeze, SignedExecutionPayloadEnvelope, SignedProposerPreferences, SignedVoluntaryExit, Slot, SyncAggregate, Uint256, Withdrawal, @@ -49,7 +49,7 @@ use crate::block_production::bid_selection::{self, BidCandidate, BidSource, Exec use crate::payload_bid_verification::PayloadBidError; use crate::payload_bid_verification::direct_verified_bid::verify_direct_bid; use crate::payload_bid_verification::gossip_verified_bid::{ - builder_exit_requested, verify_bid_state_conditions, + builder_exit_requested, verify_bid_inclusion_list_bits, verify_bid_state_conditions, }; use crate::payload_bid_verification::payload_bid_cache::BidParent; use crate::pending_payload_envelopes::PendingEnvelopeData; @@ -283,6 +283,11 @@ impl BeaconChain { .gossip_verified_proposer_preferences_cache .get_preferences(&produce_at_slot, dependent_root); + // The node's own inclusion list bits for the slot before the proposal + // This contains every stored list, both timely and untimely. + // The self-bid claims them and every external bid must cover them + let local_inclusion_list_bits = self.local_inclusion_list_bits(&ctx).await; + // Fire the direct builder fan-out concurrently with the local EL payload build: both only // read `state`, so they race without contention. A local EL failure is not fatal — we fall // back to an external bid when one is available; only a total absence of viable bids fails @@ -291,6 +296,7 @@ impl BeaconChain { ctx, &builder_config, proposer_preferences.as_deref(), + &local_inclusion_list_bits, &state, &parent_execution_requests, ); @@ -301,6 +307,7 @@ impl BeaconChain { BID_VALUE_SELF_BUILD, BUILDER_INDEX_SELF_BUILD, executed_ancestor_hash, + &local_inclusion_list_bits, ); let (mut candidates, local_result) = tokio::join!(acquire_fut, local_fut); @@ -935,6 +942,7 @@ impl BeaconChain { bid_value: u64, builder_index: BuilderIndex, executed_ancestor_hash: ExecutionBlockHash, + inclusion_list_bits: &InclusionListBits, ) -> Result< ( SignedExecutionPayloadBid, @@ -1048,9 +1056,7 @@ impl BeaconChain { execution_payment: EXECUTION_PAYMENT_TRUSTLESS_BUILD, blob_kzg_commitments, execution_requests_root: execution_requests.tree_hash_root(), - // TODO(heze): set the bits based on the IL committee members whose - // inclusion lists were considered when building the payload - inclusion_list_bits: BitVector::new(), + inclusion_list_bits: inclusion_list_bits.clone(), _phantom: PhantomData, }; SignedExecutionPayloadBid::Heze(SignedExecutionPayloadBidHeze { @@ -1089,14 +1095,17 @@ impl BeaconChain { /// /// Direct bids are requested only when there are configured builders to contact and the proposer /// submitted preferences to validate against (`proposer_preferences`, needed for a direct bid's - /// gas limit and fee recipient). Acquisition is best-effort: any direct failure — including a - /// missing builder service — is logged and skipped, never aborting block production, which can - /// still proceed on the local build and gossip bids. + /// gas limit and fee recipient). From Heze, every external bid's `inclusion_list_bits` must also + /// cover `local_inclusion_list_bits`, the node's own view of the previous slot's inclusion lists. + /// Acquisition is best-effort: any direct + /// failure — including a missing builder service — is logged and skipped, never aborting block + /// production, which can still proceed on the local build and gossip bids. async fn acquire_external_bid_candidates( self: &Arc, ctx: BidRequestContext, builder_config: &BuilderConfig, proposer_preferences: Option<&SignedProposerPreferences>, + local_inclusion_list_bits: &InclusionListBits, state: &BeaconState, parent_execution_requests: &ExecutionRequestsGloas, ) -> Vec> { @@ -1111,6 +1120,7 @@ impl BeaconChain { &ctx, builder_config, proposer_preferences, + local_inclusion_list_bits, state, ) .await, @@ -1132,11 +1142,18 @@ impl BeaconChain { parent_block_root: ctx.parent_root, }, ) { - // The gossip bid was validated against the head state at gossip time; its builder's - // eligibility or coverage can go stale before production. Re-check against the production - // state and drop it if it would now fail `per_block_processing`, so a stale gossip bid - // can't outrank a viable candidate and sink the whole proposal. - match verify_bid_state_conditions(gossip_bid.message(), state, &self.spec) { + // The gossip bid was validated against the head state at gossip time, with its inclusion + // list bits checked only against the timely lists; its builder's eligibility or coverage + // can go stale before production, and more inclusion lists can arrive. Re-check against the + // production state, drop the bid if it would now fail `per_block_processing`, and require + // its bits to cover every inclusion list the node holds now, so a stale gossip bid can't + // outrank a viable candidate and sink the whole proposal. + let result = verify_bid_state_conditions(gossip_bid.message(), state, &self.spec) + .and_then(|_| { + verify_bid_inclusion_list_bits(gossip_bid.message(), local_inclusion_list_bits) + }); + + match result { Ok(_) => { externals.push(BidCandidate::gossip( gossip_bid, @@ -1147,7 +1164,7 @@ impl BeaconChain { Err(error) => { warn!( ?error, - "Skipping gossip bid that no longer passes state validation" + "Skipping gossip bid that fails validation at production" ); } } @@ -1172,6 +1189,36 @@ impl BeaconChain { externals } + /// The node's own inclusion list bits for the `ctx.slot - 1` slot. Every inclusion list is + /// considered, both timely and untimely. The bits are empty before Heze, and empty when the + /// view cannot be read + async fn local_inclusion_list_bits( + self: &Arc, + ctx: &BidRequestContext, + ) -> InclusionListBits { + if !ctx.fork_name.heze_enabled() { + return Default::default(); + } + + let chain = self.clone(); + let parent_root = ctx.parent_root; + let inclusion_list_slot = ctx.slot.saturating_sub(1u64); + let result = self + .spawn_blocking_handle( + move || chain.get_inclusion_list_bits(parent_root, inclusion_list_slot, false), + "local_inclusion_list_bits", + ) + .await; + + match result.and_then(|bits| bits) { + Ok(bits) => bits, + Err(error) => { + warn!(?error, "Unable to read inclusion list bits"); + Default::default() + } + } + } + /// Request direct bids from the configured builders and return each valid one as a selection /// candidate. /// @@ -1185,6 +1232,7 @@ impl BeaconChain { ctx: &BidRequestContext, builder_config: &BuilderConfig, proposer_preferences: &SignedProposerPreferences, + local_inclusion_list_bits: &InclusionListBits, state: &BeaconState, ) -> Vec> { let Some(builders) = self.builders.as_ref() else { @@ -1207,6 +1255,7 @@ impl BeaconChain { let state = Arc::new(state.clone()); let spec = self.spec.clone(); let proposer_preferences = Arc::new(proposer_preferences.clone()); + let local_inclusion_list_bits = local_inclusion_list_bits.clone(); let executor = self.task_executor.clone(); // The gas limit a direct bid must adjust from is that of the execution payload at the // selected parent, which is the right baseline under either a FULL or EMPTY parent view. @@ -1225,6 +1274,7 @@ impl BeaconChain { let state = state.clone(); let spec = spec.clone(); let proposer_preferences = proposer_preferences.clone(); + let local_inclusion_list_bits = local_inclusion_list_bits.clone(); let executor = executor.clone(); async move { // The bid's BLS signature check is CPU-bound; run the whole verification on a @@ -1251,6 +1301,7 @@ impl BeaconChain { executed_ancestor_gas_limit, &expected_builder_pubkeys, &proposer_preferences, + &local_inclusion_list_bits, &state, &spec, ) diff --git a/beacon_node/beacon_chain/src/inclusion_list_store.rs b/beacon_node/beacon_chain/src/inclusion_list_store.rs index 97d29912a87..2068e80ddc0 100644 --- a/beacon_node/beacon_chain/src/inclusion_list_store.rs +++ b/beacon_node/beacon_chain/src/inclusion_list_store.rs @@ -10,7 +10,10 @@ use ssz_types::{BitVector, ProgressiveVariableList}; use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; use tree_hash::TreeHash; -use types::{ChainSpec, EthSpec, Hash256, InclusionListCommittee, SignedInclusionList, Slot}; +use types::{ + ChainSpec, EthSpec, Hash256, InclusionListBits, InclusionListCommittee, SignedInclusionList, + Slot, +}; /// The shuffling `dependent_root` an inclusion list was produced against. pub type DependentRoot = Hash256; @@ -63,6 +66,19 @@ pub struct InclusionListStore { _phantom: PhantomData, } +/// Returns whether `bits` is inclusive of `local`: every bit set in `local` is also set in `bits`. +pub fn inclusion_list_bits_are_inclusive( + local: &InclusionListBits, + bits: &InclusionListBits, +) -> Result { + for i in 0..local.len() { + if local.get(i)? && !bits.get(i)? { + return Ok(false); + } + } + Ok(true) +} + impl InclusionListStore { pub fn new(spec: &ChainSpec) -> Self { // `heze_fork_epoch` holds the far future sentinel when Heze is unscheduled. @@ -235,12 +251,7 @@ impl InclusionListStore { ) -> Result { let local = self.get_inclusion_list_bits(slot, dependent_root, il_committee, only_timely)?; - for i in 0..local.len() { - if local.get(i)? && !bits.get(i)? { - return Ok(false); - } - } - Ok(true) + inclusion_list_bits_are_inclusive::(&local, bits) } /// The stored signed inclusion lists for the given `validators`, used to serve diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs index 4459be30feb..aea347aa79a 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs @@ -1,14 +1,17 @@ use crate::payload_bid_verification::{ PayloadBidError, - gossip_verified_bid::{is_gas_limit_target_compatible, verify_direct_bid_consistency}, + gossip_verified_bid::{ + is_gas_limit_target_compatible, verify_bid_inclusion_list_bits, + verify_direct_bid_consistency, + }, }; use eth2::types::BuilderPubkeys; use state_processing::signature_sets::{ execution_payload_bid_signature_set, get_builder_pubkey_from_state, }; use types::{ - BeaconState, ChainSpec, EthSpec, ExecutionBlockHash, Hash256, SignedExecutionPayloadBid, - SignedProposerPreferences, Slot, + BeaconState, ChainSpec, EthSpec, ExecutionBlockHash, Hash256, InclusionListBits, + SignedExecutionPayloadBid, SignedProposerPreferences, Slot, }; /// Fully validate a bid fetched directly from a builder, for inclusion in a block being produced. @@ -20,7 +23,10 @@ use types::{ /// covers the bid value), /// - that the bid matches the block being produced — the exact `proposal_slot`, the selected /// FULL/EMPTY parent (`parent_block_hash` / `parent_block_root`), the state's RANDAO mix, and a -/// gas limit compatible with the parent's under the proposer's target, and +/// gas limit compatible with the parent's under the proposer's target, +/// - starting with Heze slots, that its `inclusion_list_bits` cover `local_inclusion_list_bits`, +/// the node's own view of the inclusion lists for the slot before `proposal_slot`, considering +/// both timely and untimely inclusion lists, and /// - a valid builder signature. /// /// Unlike gossip bids, direct bids may carry an execution payment; the `execution_payment == 0` @@ -42,6 +48,7 @@ pub fn verify_direct_bid( executed_ancestor_gas_limit: u64, expected_builder_pubkeys: &BuilderPubkeys, proposer_preferences: &SignedProposerPreferences, + local_inclusion_list_bits: &InclusionListBits, state: &BeaconState, spec: &ChainSpec, ) -> Result<(), PayloadBidError> { @@ -83,6 +90,9 @@ pub fn verify_direct_bid( return Err(PayloadBidError::InvalidGasLimit); } + // The bid must cover every inclusion list this node holds for the slot before the proposal. + verify_bid_inclusion_list_bits(bid, local_inclusion_list_bits)?; + // Consensus-consistency checks shared with the gossip verifier. verify_direct_bid_consistency(bid, proposal_slot, proposer_preferences, state, spec)?; @@ -124,8 +134,8 @@ mod tests { use super::*; use bls::Signature; use types::{ - Address, ExecutionPayloadBidGloas, MinimalEthSpec, ProposerPreferences, - SignedExecutionPayloadBidGloas, + Address, ExecutionPayloadBidGloas, ExecutionPayloadBidHeze, MinimalEthSpec, + ProposerPreferences, SignedExecutionPayloadBidGloas, SignedExecutionPayloadBidHeze, }; type E = MinimalEthSpec; @@ -203,6 +213,7 @@ mod tests { EXECUTED_ANCESTOR_GAS_LIMIT, &BuilderPubkeys::default(), &preferences(), + &inclusion_list_bits(&[]), &state, &spec, ); @@ -229,6 +240,7 @@ mod tests { EXECUTED_ANCESTOR_GAS_LIMIT, &BuilderPubkeys::default(), &preferences(), + &inclusion_list_bits(&[]), &state, &spec, ); @@ -255,6 +267,7 @@ mod tests { EXECUTED_ANCESTOR_GAS_LIMIT, &BuilderPubkeys::default(), &preferences(), + &inclusion_list_bits(&[]), &state, &spec, ); @@ -282,6 +295,7 @@ mod tests { EXECUTED_ANCESTOR_GAS_LIMIT, &BuilderPubkeys::default(), &preferences(), + &inclusion_list_bits(&[]), &state, &spec, ); @@ -315,6 +329,7 @@ mod tests { EXECUTED_ANCESTOR_GAS_LIMIT, &BuilderPubkeys::default(), &preferences(), + &inclusion_list_bits(&[]), &state, &spec, ); @@ -345,6 +360,7 @@ mod tests { EXECUTED_ANCESTOR_GAS_LIMIT, &BuilderPubkeys::default(), &preferences(), + &inclusion_list_bits(&[]), &state, &spec, ); @@ -375,6 +391,7 @@ mod tests { EXECUTED_ANCESTOR_GAS_LIMIT, &BuilderPubkeys::default(), &preferences(), + &inclusion_list_bits(&[]), &state, &spec, ); @@ -383,4 +400,75 @@ mod tests { "expected to fail after the gas check, got {result:?}" ); } + + fn inclusion_list_bits(positions: &[usize]) -> InclusionListBits { + let mut bits = InclusionListBits::::new(); + for position in positions { + bits.set(*position, true) + .expect("position within committee size"); + } + bits + } + + /// A Heze bid at slot 1 that passes every check ahead of the inclusion list one. + fn signed_heze_bid(inclusion_list_bits: InclusionListBits) -> SignedExecutionPayloadBid { + SignedExecutionPayloadBid::Heze(SignedExecutionPayloadBidHeze { + message: ExecutionPayloadBidHeze { + slot: Slot::new(1), + gas_limit: EXECUTED_ANCESTOR_GAS_LIMIT, + // A default (zero) `block_hash` would equal the zero parent hash and trip the + // block-hash-equals-parent rejection before the checks these tests target. + block_hash: ExecutionBlockHash::repeat_byte(1), + inclusion_list_bits, + ..ExecutionPayloadBidHeze::default() + }, + signature: Signature::empty(), + }) + } + + #[test] + fn rejects_inclusion_list_bits_not_covering_local_view() { + let (state, spec) = state_and_spec(); + // The node holds lists from committee positions 0, 1 and 2; the bid only claims a subset of it + let bid = signed_heze_bid(inclusion_list_bits(&[0, 1])); + let result = verify_direct_bid( + &bid, + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + EXECUTED_ANCESTOR_GAS_LIMIT, + &BuilderPubkeys::default(), + &preferences(), + &inclusion_list_bits(&[0, 1, 2]), + &state, + &spec, + ); + assert!(matches!( + result, + Err(PayloadBidError::InclusionListBitsNotInclusive { slot }) if slot == Slot::new(1) + )); + } + + #[test] + fn inclusion_list_bits_covering_local_view_pass_inclusivity_check() { + let (state, spec) = state_and_spec(); + // The bid claims a superset of the node's view + let bid = signed_heze_bid(inclusion_list_bits(&[0, 1, 2, 5])); + let result = verify_direct_bid( + &bid, + Slot::new(1), + ExecutionBlockHash::zero(), + Hash256::ZERO, + EXECUTED_ANCESTOR_GAS_LIMIT, + &BuilderPubkeys::default(), + &preferences(), + &inclusion_list_bits(&[0, 1, 2]), + &state, + &spec, + ); + assert!( + matches!(result, Err(PayloadBidError::InvalidBuilder { .. })), + "expected to fail after the inclusion list check, got {result:?}" + ); + } } 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 0de4ba93778..4915fa1b4f9 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 @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::inclusion_list_store::inclusion_list_bits_are_inclusive; use crate::{ BeaconChain, BeaconChainTypes, BeaconStore, CachedHead, CanonicalHead, canonical_head::ForkChoiceReadGuard, @@ -20,7 +21,7 @@ use state_processing::signature_sets::{ use tracing::debug; use types::{ BeaconState, Builder, ChainSpec, EthSpec, ExecutionPayloadBidRef, ExecutionRequestsGloas, - SignedExecutionPayloadBid, SignedProposerPreferences, Slot, + InclusionListBits, SignedExecutionPayloadBid, SignedProposerPreferences, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, }; @@ -83,6 +84,35 @@ fn verify_bid_blobs( Ok(()) } +/// Reject a bid whose `inclusion_list_bits` do not cover `local_inclusion_list_bits`, the node's +/// own view of the inclusion lists for the slot before the bid's. Pre-Heze bids carry no bits +/// and pass. +/// +/// Shared by both intakes, each resolves the local bits under its own timeliness rule: +/// timely inclusion lists only on gossip, and all (timely and untimely) inclusion lists on the +/// proposer's block production path. +pub(crate) fn verify_bid_inclusion_list_bits( + bid: ExecutionPayloadBidRef, + local_inclusion_list_bits: &InclusionListBits, +) -> Result<(), PayloadBidError> { + let ExecutionPayloadBidRef::Heze(bid) = bid else { + return Ok(()); + }; + + let inclusion_list_inclusive = + inclusion_list_bits_are_inclusive::(local_inclusion_list_bits, &bid.inclusion_list_bits) + .map_err(|e| { + PayloadBidError::InternalError(format!( + "inclusion list bits inclusivity check error: {e:?}" + )) + })?; + + if !inclusion_list_inclusive { + return Err(PayloadBidError::InclusionListBitsNotInclusive { slot: bid.slot }); + } + Ok(()) +} + /// Verify that a direct (builder-API) bid is consistent with the current chain state /// and proposer preferences: the direct path's bundle of the shared bid checks. /// 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 98ad661adf8..a501bd5234a 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -92,6 +92,8 @@ pub enum PayloadBidError { }, /// The bids prev randao value is invalid InvalidPrevRandao { slot: Slot }, + /// The bid's `inclusion_list_bits` do not cover every inclusion list the node holds for `slot - 1` + InclusionListBitsNotInclusive { slot: Slot }, /// Some Beacon State error BeaconStateError(BeaconStateError), /// Internal error 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 2b1f2fba613..53c224f8998 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4285,7 +4285,8 @@ impl NetworkBeaconProcessor { | PayloadBidError::BeaconStateError(_) | PayloadBidError::InternalError(_) | PayloadBidError::InvalidBidSlot { .. } - | PayloadBidError::UnableToReadSlot, + | PayloadBidError::UnableToReadSlot + | PayloadBidError::InclusionListBitsNotInclusive { .. }, ) => { self.propagate_validation_result(message_id, peer_id, MessageAcceptance::Ignore); } diff --git a/consensus/types/src/execution/inclusion_list.rs b/consensus/types/src/execution/inclusion_list.rs index ed6739a2460..fd585d1d42a 100644 --- a/consensus/types/src/execution/inclusion_list.rs +++ b/consensus/types/src/execution/inclusion_list.rs @@ -3,12 +3,15 @@ use context_deserialize::context_deserialize; use educe::Educe; use serde::{Deserialize, Serialize}; use ssz_derive::{Decode, Encode}; -use ssz_types::FixedVector; +use ssz_types::{BitVector, FixedVector}; use tree_hash_derive::TreeHash; /// The inclusion list committee, ordered by committee position. pub type InclusionListCommittee = FixedVector::InclusionListCommitteeSize>; +/// One bit per inclusion list committee member, in committee position order. +pub type InclusionListBits = BitVector<::InclusionListCommitteeSize>; + #[derive(Default, Debug, Clone, Serialize, Encode, Decode, Deserialize, TreeHash, Educe)] #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[educe(PartialEq, Hash)] diff --git a/consensus/types/src/execution/mod.rs b/consensus/types/src/execution/mod.rs index 4b08020cda0..4297871e43e 100644 --- a/consensus/types/src/execution/mod.rs +++ b/consensus/types/src/execution/mod.rs @@ -44,7 +44,7 @@ pub use execution_requests::{ ExecutionRequests, ExecutionRequestsElectra, ExecutionRequestsGloas, ExecutionRequestsRef, RequestType, WithdrawalRequests, }; -pub use inclusion_list::{InclusionList, InclusionListCommittee}; +pub use inclusion_list::{InclusionList, InclusionListBits, InclusionListCommittee}; pub use payload::{ AbstractExecPayload, BlindedPayload, BlindedPayloadBellatrix, BlindedPayloadCapella, BlindedPayloadDeneb, BlindedPayloadElectra, BlindedPayloadFulu, BlindedPayloadRef, From a13ef10010ace0774f27535c341630e7f7871a2a Mon Sep 17 00:00:00 2001 From: conache Date: Thu, 8 Oct 2026 14:01:29 +0300 Subject: [PATCH 2/5] Update the code to align with the shared PR#10263's code --- beacon_node/beacon_chain/src/inclusion_list_store.rs | 8 ++++---- .../src/payload_bid_verification/gossip_verified_bid.rs | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/beacon_node/beacon_chain/src/inclusion_list_store.rs b/beacon_node/beacon_chain/src/inclusion_list_store.rs index e331437eef9..c0fdd3eea24 100644 --- a/beacon_node/beacon_chain/src/inclusion_list_store.rs +++ b/beacon_node/beacon_chain/src/inclusion_list_store.rs @@ -6,7 +6,7 @@ //! keyed by `(slot, dependent_root)`, which pins an inclusion list to the committee it was produced //! against. -use ssz_types::{BitVector, ProgressiveVariableList}; +use ssz_types::ProgressiveVariableList; use std::collections::{HashMap, HashSet}; use std::marker::PhantomData; use tree_hash::TreeHash; @@ -227,10 +227,10 @@ impl InclusionListStore { dependent_root: DependentRoot, il_committee: &InclusionListCommittee, only_timely: bool, - ) -> Result, Error> { + ) -> Result, Error> { let submitted = self.submitted_validators(slot, dependent_root, only_timely); - let mut bits = BitVector::new(); + let mut bits = InclusionListBits::::new(); for (i, validator) in il_committee.iter().enumerate() { if submitted.contains(validator) { bits.set(i, true)?; @@ -246,7 +246,7 @@ impl InclusionListStore { slot: Slot, dependent_root: DependentRoot, il_committee: &InclusionListCommittee, - bits: &BitVector, + bits: &InclusionListBits, only_timely: bool, ) -> Result { let local = 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 e1fc64bf3e1..2b131e3aae9 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 @@ -88,9 +88,9 @@ fn verify_bid_blobs( /// own view of the inclusion lists for the slot before the bid's. Pre-Heze bids carry no bits /// and pass. /// -/// Shared by both intakes, each resolves the local bits under its own timeliness rule: -/// timely inclusion lists only on gossip, and all (timely and untimely) inclusion lists on the -/// proposer's block production path. +/// Callers resolve `local_inclusion_list_bits` under their own timeliness rule. +/// On gossip only timely inclusion lists count, while for the proposer's block production path +/// both timely and untimely inclusion lists count. pub(crate) fn verify_bid_inclusion_list_bits( bid: ExecutionPayloadBidRef, local_inclusion_list_bits: &InclusionListBits, From 38ecd418f8b2388898417777188c381c5c34c777 Mon Sep 17 00:00:00 2001 From: conache Date: Thu, 8 Oct 2026 14:05:07 +0300 Subject: [PATCH 3/5] Add test to verify if the self built bid claims the inclusion lists stored locally --- .../beacon_chain/tests/block_production.rs | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/beacon_node/beacon_chain/tests/block_production.rs b/beacon_node/beacon_chain/tests/block_production.rs index a5e92f5cb23..906887da5a8 100644 --- a/beacon_node/beacon_chain/tests/block_production.rs +++ b/beacon_node/beacon_chain/tests/block_production.rs @@ -2,10 +2,12 @@ use beacon_chain::{ observed_operations::ObservationOutcome, test_utils::{BeaconChainHarness, test_spec}, }; +use bls::Signature; use std::sync::Arc; use types::{ - Address, Epoch, ExecutionRequests, ExecutionRequestsGloas, Hash256, MinimalEthSpec, Slot, - WithdrawalRequest, + Address, Epoch, ExecutionRequests, ExecutionRequestsGloas, Hash256, InclusionList, + MinimalEthSpec, ProgressiveTransactions, SignedExecutionPayloadBidRef, SignedInclusionList, + Slot, WithdrawalRequest, consts::gloas::BUILDER_INDEX_SELF_BUILD, }; type E = MinimalEthSpec; @@ -130,3 +132,79 @@ async fn gloas_block_production_filters_exits_with_parent_partial_withdrawals() spec.far_future_epoch ); } + +/// The self-built bid claims every inclusion list the node holds for the slot before the proposal, +/// both timely and untimely. +#[tokio::test] +async fn heze_self_build_bid_claims_held_inclusion_lists() { + let spec = test_spec::(); + if !spec.fork_name_at_slot::(Slot::new(0)).heze_enabled() { + return; + } + + let harness = BeaconChainHarness::builder(E::default()) + .spec(Arc::new(spec)) + .deterministic_keypairs(64) + .fresh_ephemeral_store() + .mock_execution_layer() + .build(); + + let inclusion_list_slot = Slot::new(0); + let parent_root = harness.head_block_root(); + let (committee, dependent_root) = harness + .chain + .inclusion_list_committee(parent_root, inclusion_list_slot) + .unwrap(); + + let timely_submitter = committee[0]; + let late_submitter = *committee + .iter() + .find(|index| **index != timely_submitter) + .unwrap(); + + let mut store = harness.chain.inclusion_list_store.write(); + for (validator_index, is_timely) in [(timely_submitter, true), (late_submitter, false)] { + store.process_inclusion_list( + SignedInclusionList { + message: InclusionList { + slot: inclusion_list_slot, + validator_index, + dependent_root, + transactions: ProgressiveTransactions::default(), + }, + signature: Signature::empty(), + }, + is_timely, + ); + } + drop(store); + + harness.advance_slot(); + let state = harness.get_current_state(); + let (block_contents, _, _) = harness.make_block_with_envelope(state, Slot::new(1)).await; + + let SignedExecutionPayloadBidRef::Heze(signed_bid) = block_contents + .0 + .message() + .body() + .signed_execution_payload_bid() + .unwrap() + else { + panic!("a Heze block should carry a Heze bid"); + }; + assert_eq!(signed_bid.message.builder_index, BUILDER_INDEX_SELF_BUILD); + + // A validator holding several committee positions has all of them set + for (position, validator_index) in committee.iter().enumerate() { + let expected = *validator_index == timely_submitter || *validator_index == late_submitter; + assert_eq!( + signed_bid + .message + .inclusion_list_bits + .get(position) + .unwrap(), + expected, + "unexpected bit at committee position {position}" + ); + } +} From 4d00fe2dd9d2bfb8eb9bad5ec40c3087285d2e43 Mon Sep 17 00:00:00 2001 From: conache Date: Thu, 8 Oct 2026 14:14:15 +0300 Subject: [PATCH 4/5] Fix formatting --- .../src/block_production/gloas.rs | 29 ++++++++++--------- .../direct_verified_bid.rs | 3 +- .../gossip_verified_bid.rs | 4 +-- .../src/payload_bid_verification/mod.rs | 3 +- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/beacon_node/beacon_chain/src/block_production/gloas.rs b/beacon_node/beacon_chain/src/block_production/gloas.rs index 58ca5830033..7e054ddaac7 100644 --- a/beacon_node/beacon_chain/src/block_production/gloas.rs +++ b/beacon_node/beacon_chain/src/block_production/gloas.rs @@ -282,9 +282,9 @@ impl BeaconChain { .gossip_verified_proposer_preferences_cache .get_preferences(&produce_at_slot, dependent_root); - // The node's own inclusion list bits for the slot before the proposal + // The node's own inclusion list bits for the slot before the proposal. // This contains every stored list, both timely and untimely. - // The self-bid claims them and every external bid must cover them + // The self-bid claims them and every external bid must cover them. let local_inclusion_list_bits = self.local_inclusion_list_bits(&ctx).await; // Fire the direct builder fan-out concurrently with the local EL payload build: both only @@ -1121,11 +1121,11 @@ impl BeaconChain { /// /// Direct bids are requested only when there are configured builders to contact and the proposer /// submitted preferences to validate against (`proposer_preferences`, needed for a direct bid's - /// gas limit and fee recipient). From Heze, every external bid's `inclusion_list_bits` must also - /// cover `local_inclusion_list_bits`, the node's own view of the previous slot's inclusion lists. - /// Acquisition is best-effort: any direct - /// failure — including a missing builder service — is logged and skipped, never aborting block - /// production, which can still proceed on the local build and gossip bids. + /// gas limit and fee recipient). From Heze, every external bid's `inclusion_list_bits` must + /// also cover `local_inclusion_list_bits`, the node's own view of the previous slot's inclusion + /// lists. Acquisition is best-effort: any direct failure — including a missing builder service + /// — is logged and skipped, never aborting block production, which can still proceed on the + /// local build and gossip bids. async fn acquire_external_bid_candidates( self: &Arc, ctx: BidRequestContext, @@ -1168,12 +1168,13 @@ impl BeaconChain { parent_block_root: ctx.parent_root, }, ) { - // The gossip bid was validated against the head state at gossip time, with its inclusion - // list bits checked only against the timely lists; its builder's eligibility or coverage - // can go stale before production, and more inclusion lists can arrive. Re-check against the - // production state, drop the bid if it would now fail `per_block_processing`, and require - // its bits to cover every inclusion list the node holds now, so a stale gossip bid can't - // outrank a viable candidate and sink the whole proposal. + // The gossip bid was validated against the head state at gossip time, with its + // inclusion list bits checked only against the timely lists; its builder's eligibility + // or coverage can go stale before production, and more inclusion lists can arrive. + // Re-check against the production state, drop the bid if it would now fail + // `per_block_processing`, and require its bits to cover every inclusion list the node + // holds now, so a stale gossip bid can't outrank a viable candidate and sink the whole + // proposal. let result = verify_bid_state_conditions(gossip_bid.message(), state, &self.spec) .and_then(|_| { verify_bid_inclusion_list_bits(gossip_bid.message(), local_inclusion_list_bits) @@ -1230,7 +1231,7 @@ impl BeaconChain { /// The node's own inclusion list bits for the `ctx.slot - 1` slot. Every inclusion list is /// considered, both timely and untimely. The bits are empty before Heze, and empty when the - /// view cannot be read + /// view cannot be read. async fn local_inclusion_list_bits( self: &Arc, ctx: &BidRequestContext, diff --git a/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs index deac0e30d6c..295177450f8 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/direct_verified_bid.rs @@ -436,7 +436,8 @@ mod tests { fn rejects_inclusion_list_bits_not_covering_local_view() { let (state, mut spec) = state_and_spec(); spec.heze_fork_epoch = Some(Epoch::new(0)); - // The node holds lists from committee positions 0, 1 and 2; the bid only claims a subset of it + // The node holds lists from committee positions 0, 1 and 2; + // the bid only claims a subset of it let bid = signed_heze_bid(inclusion_list_bits(&[0, 1])); let result = verify_direct_bid( &bid, 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 2b131e3aae9..99fc6150050 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 @@ -85,8 +85,8 @@ fn verify_bid_blobs( } /// Reject a bid whose `inclusion_list_bits` do not cover `local_inclusion_list_bits`, the node's -/// own view of the inclusion lists for the slot before the bid's. Pre-Heze bids carry no bits -/// and pass. +/// own view of the inclusion lists for the slot before the bid's. +/// Pre-Heze bids carry no bits and pass. /// /// Callers resolve `local_inclusion_list_bits` under their own timeliness rule. /// On gossip only timely inclusion lists count, while for the proposer's block production path 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 7c9a5e8a63e..1086f66be30 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/mod.rs @@ -92,7 +92,8 @@ pub enum PayloadBidError { }, /// The bids prev randao value is invalid InvalidPrevRandao { slot: Slot }, - /// The bid's `inclusion_list_bits` do not cover every inclusion list the node holds for `slot - 1` + /// The bid's `inclusion_list_bits` do not cover every inclusion list the node holds + /// for `slot - 1` InclusionListBitsNotInclusive { slot: Slot }, /// Some Beacon State error BeaconStateError(BeaconStateError), From 88d8182718882e71c781413c2c250eeb7ea8750e Mon Sep 17 00:00:00 2001 From: conache Date: Thu, 8 Oct 2026 14:56:46 +0300 Subject: [PATCH 5/5] Fix lint errors around the IL store lock --- .../beacon_chain/tests/block_production.rs | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/beacon_node/beacon_chain/tests/block_production.rs b/beacon_node/beacon_chain/tests/block_production.rs index 906887da5a8..56ae6eaa947 100644 --- a/beacon_node/beacon_chain/tests/block_production.rs +++ b/beacon_node/beacon_chain/tests/block_production.rs @@ -162,22 +162,24 @@ async fn heze_self_build_bid_claims_held_inclusion_lists() { .find(|index| **index != timely_submitter) .unwrap(); - let mut store = harness.chain.inclusion_list_store.write(); for (validator_index, is_timely) in [(timely_submitter, true), (late_submitter, false)] { - store.process_inclusion_list( - SignedInclusionList { - message: InclusionList { - slot: inclusion_list_slot, - validator_index, - dependent_root, - transactions: ProgressiveTransactions::default(), + harness + .chain + .inclusion_list_store + .write() + .process_inclusion_list( + SignedInclusionList { + message: InclusionList { + slot: inclusion_list_slot, + validator_index, + dependent_root, + transactions: ProgressiveTransactions::default(), + }, + signature: Signature::empty(), }, - signature: Signature::empty(), - }, - is_timely, - ); + is_timely, + ); } - drop(store); harness.advance_slot(); let state = harness.get_current_state();