diff --git a/beacon_node/beacon_chain/src/inclusion_list_store.rs b/beacon_node/beacon_chain/src/inclusion_list_store.rs index f585724beca..c0fdd3eea24 100644 --- a/beacon_node/beacon_chain/src/inclusion_list_store.rs +++ b/beacon_node/beacon_chain/src/inclusion_list_store.rs @@ -6,11 +6,14 @@ //! 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; -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. @@ -211,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)?; @@ -230,17 +246,12 @@ impl InclusionListStore { slot: Slot, dependent_root: DependentRoot, il_committee: &InclusionListCommittee, - bits: &BitVector, + bits: &InclusionListBits, only_timely: bool, ) -> 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/gossip_verified_bid.rs b/beacon_node/beacon_chain/src/payload_bid_verification/gossip_verified_bid.rs index 74cfb08c79c..14384d18ada 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::{InclusionListStore, inclusion_list_bits_are_inclusive}; use crate::{ BeaconChain, BeaconChainTypes, BeaconStore, CachedHead, CanonicalHead, canonical_head::ForkChoiceReadGuard, @@ -12,6 +13,7 @@ use crate::{ }; use educe::Educe; use eth2::types::{EventKind, ForkVersionedResponse}; +use parking_lot::RwLock; use proto_array::{Block as ProtoBlock, PayloadBlockHash}; use slot_clock::SlotClock; use state_processing::signature_sets::{ @@ -20,7 +22,7 @@ use state_processing::signature_sets::{ use tracing::debug; use types::{ BeaconState, Builder, ChainSpec, EthSpec, ExecutionPayloadBidRef, ExecutionRequestsGloas, - SignedExecutionPayloadBid, SignedProposerPreferences, Slot, + InclusionListBits, RelativeEpoch, SignedExecutionPayloadBid, SignedProposerPreferences, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, }; @@ -83,6 +85,34 @@ 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. +/// +/// 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, +) -> 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. /// @@ -265,6 +295,7 @@ pub struct GossipVerificationContext<'a, T: BeaconChainTypes> { pub observed_execution_payloads: &'a ObservedExecutionPayloads, pub gossip_verified_payload_bid_cache: &'a GossipVerifiedPayloadBidCache, pub gossip_verified_proposer_preferences_cache: &'a GossipVerifiedProposerPreferenceCache, + pub inclusion_list_store: &'a RwLock>, pub slot_clock: &'a T::SlotClock, pub spec: &'a ChainSpec, pub store: &'a BeaconStore, @@ -459,6 +490,48 @@ impl GossipVerifiedPayloadBid { }); } + // [IGNORE] `bid.inclusion_list_bits` is inclusive of the node's view of the inclusion + // lists for the slot preceding the bid's slot. + if ctx + .spec + .fork_name_at_slot::(bid_slot) + .heze_enabled() + { + let inclusion_list_slot = bid_slot.saturating_sub(1u64); + let inclusion_list_epoch = inclusion_list_slot.epoch(T::EthSpec::slots_per_epoch()); + let relative_epoch = + RelativeEpoch::from_epoch(head_state.current_epoch(), inclusion_list_epoch) + .map_err(|e| { + PayloadBidError::InternalError(format!( + "inclusion list epoch {inclusion_list_epoch} out of range: {e:?}" + )) + })?; + + // determine inclusion list dependent root and committee necessary for determining + // the inclusion list bits for the inclusivity check + let inclusion_list_dependent_root = head_state.attester_shuffling_decision_root( + signed_bid.message().parent_block_root(), + relative_epoch, + )?; + let inclusion_list_committee = + head_state.get_inclusion_list_committee(inclusion_list_slot)?; + + // Only the timely inclusion lists count on gossip + let local_inclusion_list_bits = ctx + .inclusion_list_store + .read() + .get_inclusion_list_bits( + inclusion_list_slot, + inclusion_list_dependent_root, + &inclusion_list_committee, + true, + ) + .map_err(|e| { + PayloadBidError::InternalError(format!("inclusion list store: {e:?}")) + })?; + verify_bid_inclusion_list_bits(signed_bid.message(), &local_inclusion_list_bits)?; + } + execution_payload_bid_signature_set( head_state, |i| get_builder_pubkey_from_state(head_state, i), @@ -489,6 +562,7 @@ impl BeaconChain { gossip_verified_payload_bid_cache: &self.gossip_verified_payload_bid_cache, gossip_verified_proposer_preferences_cache: &self .gossip_verified_proposer_preferences_cache, + inclusion_list_store: &self.inclusion_list_store, slot_clock: &self.slot_clock, spec: &self.spec, store: &self.store, 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 935db42a15b..26fa924b6a4 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,9 @@ pub enum PayloadBidError { }, /// The bids prev randao value is invalid InvalidPrevRandao { slot: Slot }, + /// The bid's `inclusion_list_bits` do not cover every timely inclusion list + /// the node holds for `slot - 1` + InclusionListBitsNotInclusive { slot: Slot }, /// Some Beacon State error BeaconStateError(BeaconStateError), /// The bid's variant does not match the fork at its 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 index a11ba912b15..7972d38ca46 100644 --- a/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs +++ b/beacon_node/beacon_chain/src/payload_bid_verification/tests.rs @@ -13,16 +13,16 @@ use state_processing::genesis::genesis_block; use store::{HotColdDB, StoreConfig, StoreOp}; use types::{ Address, BuilderExitRequest, ChainSpec, Checkpoint, Domain, Epoch, EthSpec, ExecutionBlockHash, - ExecutionPayloadBidGloas, ExecutionPayloadBidHeze, ExecutionPayloadBidRef, + ExecutionPayloadBid, ExecutionPayloadBidGloas, ExecutionPayloadBidHeze, ExecutionPayloadBidRef, ExecutionPayloadEnvelope, ExecutionPayloadHeader, ExecutionPayloadHeaderFulu, ForkName, - Hash256, MinimalEthSpec, ProposerPreferences, SignedBeaconBlock, SignedExecutionPayloadBid, - SignedExecutionPayloadBidGloas, SignedExecutionPayloadBidHeze, SignedExecutionPayloadEnvelope, - SignedProposerPreferences, SignedRoot, Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, + Hash256, InclusionList, InclusionListBits, InclusionListCommittee, MinimalEthSpec, + ProgressiveTransactions, ProposerPreferences, RelativeEpoch, SignedBeaconBlock, + SignedExecutionPayloadBid, SignedExecutionPayloadBidGloas, SignedExecutionPayloadBidHeze, + SignedExecutionPayloadEnvelope, SignedInclusionList, SignedProposerPreferences, SignedRoot, + Slot, consts::gloas::PAYLOAD_BUILDER_VERSION, }; -use proto_array::{Block as ProtoBlock, ExecutionStatus}; -use types::AttestationShufflingId; - +use crate::inclusion_list_store::InclusionListStore; use crate::{ beacon_fork_choice_store::BeaconForkChoiceStore, beacon_snapshot::BeaconSnapshot, @@ -43,6 +43,10 @@ use crate::{ }, test_utils::{EphemeralHarnessType, fork_name_from_env, test_spec}, }; +use parking_lot::RwLock; +use proto_array::{Block as ProtoBlock, ExecutionStatus}; +use state_processing::AllCaches; +use types::AttestationShufflingId; type E = MinimalEthSpec; type T = EphemeralHarnessType; @@ -65,6 +69,7 @@ struct TestContext { genesis_block_root: Hash256, inactive_builder_index: u64, store: crate::BeaconStore, + inclusion_list_store: RwLock>, } fn builder_withdrawal_credentials(pubkey: &bls::PublicKey, spec: &ChainSpec) -> Hash256 { @@ -143,6 +148,10 @@ impl TestContext { let signed_block = SignedBeaconBlock::from_block(block, Signature::empty()); let block_root = signed_block.canonical_root(); + state + .build_all_caches(&spec) + .expect("should build state caches"); + let snapshot = BeaconSnapshot::new( Arc::new(signed_block.clone()), None, @@ -187,6 +196,8 @@ impl TestContext { spec.get_slot_duration(), ); + let inclusion_list_store = RwLock::new(InclusionListStore::new(&spec)); + Self { canonical_head, observed_execution_payloads, @@ -198,26 +209,38 @@ impl TestContext { genesis_block_root: block_root, inactive_builder_index, store, + inclusion_list_store, } } - fn sign_bid(&self, bid: ExecutionPayloadBidGloas) -> Arc> { + fn sign_bid(&self, bid: ExecutionPayloadBid) -> Arc> { let head = self.canonical_head.cached_head(); let state = &head.snapshot.beacon_state; + let bid_epoch = bid.slot().epoch(E::slots_per_epoch()); let domain = self.spec.get_domain( - bid.slot.epoch(E::slots_per_epoch()), + bid_epoch, Domain::BeaconBuilder, - &state.fork(), + &self.spec.fork_at_epoch(bid_epoch), state.genesis_validators_root(), ); - let message = ExecutionPayloadBidRef::Gloas(&bid).signing_root(domain); - let signature = self.keypairs[bid.builder_index as usize].sk.sign(message); - Arc::new(SignedExecutionPayloadBid::Gloas( - SignedExecutionPayloadBidGloas { - message: bid, - signature, - }, - )) + + let message = bid.signing_root(domain); + let signature = self.keypairs[bid.builder_index() as usize].sk.sign(message); + + match bid { + ExecutionPayloadBid::Gloas(bid) => Arc::new(SignedExecutionPayloadBid::Gloas( + SignedExecutionPayloadBidGloas { + message: bid, + signature, + }, + )), + ExecutionPayloadBid::Heze(bid) => Arc::new(SignedExecutionPayloadBid::Heze( + SignedExecutionPayloadBidHeze { + message: bid, + signature, + }, + )), + } } fn gossip_ctx(&self) -> GossipVerificationContext<'_, T> { @@ -229,6 +252,7 @@ impl TestContext { slot_clock: &self.slot_clock, spec: &self.spec, store: &self.store, + inclusion_list_store: &self.inclusion_list_store, } } @@ -242,6 +266,14 @@ impl TestContext { .expect("should read current epoch randao mix") } + fn inclusion_list_committee(&self, slot: Slot) -> InclusionListCommittee { + let head = self.canonical_head.cached_head(); + head.snapshot + .beacon_state + .get_inclusion_list_committee(slot) + .expect("should read the inclusion list committee for the slot") + } + fn execution_parent_hash(&self) -> ExecutionBlockHash { let head = self.canonical_head.cached_head(); *head @@ -251,6 +283,44 @@ impl TestContext { .expect("should have a Gloas execution block hash") } + fn make_bid( + &self, + slot: Slot, + builder_index: u64, + fee_recipient: Address, + gas_limit: u64, + value: u64, + parent_block_root: Hash256, + ) -> ExecutionPayloadBid { + let parent_block_hash = self.execution_parent_hash(); + let prev_randao = self.expected_prev_randao(); + if self.spec.fork_name_at_slot::(slot).heze_enabled() { + ExecutionPayloadBid::Heze(ExecutionPayloadBidHeze { + slot, + builder_index, + fee_recipient, + gas_limit, + value, + parent_block_root, + parent_block_hash, + prev_randao, + ..ExecutionPayloadBidHeze::default() + }) + } else { + ExecutionPayloadBid::Gloas(ExecutionPayloadBidGloas { + slot, + builder_index, + fee_recipient, + gas_limit, + value, + parent_block_root, + parent_block_hash, + prev_randao, + ..ExecutionPayloadBidGloas::default() + }) + } + } + fn make_signed_bid( &self, slot: Slot, @@ -260,22 +330,30 @@ impl TestContext { value: u64, parent_block_root: Hash256, ) -> Arc> { - Arc::new(SignedExecutionPayloadBid::Gloas( - SignedExecutionPayloadBidGloas { - message: ExecutionPayloadBidGloas { - slot, - builder_index, - fee_recipient, - gas_limit, - value, - parent_block_root, - parent_block_hash: self.execution_parent_hash(), - prev_randao: self.expected_prev_randao(), - ..ExecutionPayloadBidGloas::default() - }, - signature: Signature::empty(), + let signature = Signature::empty(); + Arc::new( + match self.make_bid( + slot, + builder_index, + fee_recipient, + gas_limit, + value, + parent_block_root, + ) { + ExecutionPayloadBid::Gloas(message) => { + SignedExecutionPayloadBid::Gloas(SignedExecutionPayloadBidGloas { + message, + signature, + }) + } + ExecutionPayloadBid::Heze(message) => { + SignedExecutionPayloadBid::Heze(SignedExecutionPayloadBidHeze { + message, + signature, + }) + } }, - )) + ) } fn slot_1_proto_block( @@ -370,6 +448,51 @@ fn make_signed_preferences( }) } +fn inclusion_list_bits_for_validators( + inclusion_list_committee: &InclusionListCommittee, + validator_indices: &[u64], +) -> InclusionListBits { + let mut inclusion_list_bits = InclusionListBits::::new(); + + for (position, validator_index) in inclusion_list_committee.iter().enumerate() { + if validator_indices.contains(validator_index) { + inclusion_list_bits + .set(position, true) + .expect("position within committee size"); + } + } + + inclusion_list_bits +} + +fn seed_inclusion_list(ctx: &TestContext, slot: Slot, validator_indices: &[u64], is_timely: bool) { + let cached_head = ctx.canonical_head.cached_head(); + let head_state = &cached_head.snapshot.beacon_state; + let relative_epoch = + RelativeEpoch::from_epoch(head_state.current_epoch(), slot.epoch(E::slots_per_epoch())) + .expect("inclusion list slot should be within one epoch of the head"); + let dependent_root = head_state + .attester_shuffling_decision_root(cached_head.head_block_root(), relative_epoch) + .expect("should compute attester shuffling decision root"); + + for index in validator_indices { + let signed_inclusion_list = SignedInclusionList { + message: InclusionList { + slot, + validator_index: *index, + dependent_root, + // we're seeding empty inclusion lists since the execution payload bid verifications + // focus only on the inclusion list bits + transactions: ProgressiveTransactions::default(), + }, + signature: Signature::empty(), + }; + ctx.inclusion_list_store + .write() + .process_inclusion_list(signed_inclusion_list, is_timely); + } +} + fn seed_preferences(ctx: &TestContext, slot: Slot, fee_recipient: Address, gas_limit: u64) { // Key the preferences by the same dependent root that gossip verification will compute from // the head state, otherwise the lookup misses and verification returns `NoProposerPreferences`. @@ -453,17 +576,14 @@ fn same_builder_new_parent_tuple_not_blocked() { let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); - let bid = ctx.sign_bid(ExecutionPayloadBidGloas { + let bid = ctx.sign_bid(ctx.make_bid( slot, - builder_index: 0, - fee_recipient: Address::ZERO, - gas_limit: 30_000_000, - value: 0, - parent_block_root: ctx.genesis_block_root, - parent_block_hash: ctx.execution_parent_hash(), - prev_randao: ctx.expected_prev_randao(), - ..ExecutionPayloadBidGloas::default() - }); + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + )); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!( result.is_ok(), @@ -566,14 +686,15 @@ fn inconsistent_fork_at_heze_boundary() { ctx.spec.heze_fork_epoch = Some(heze_fork_epoch); let gossip = ctx.gossip_ctx(); - let bid = ctx.make_signed_bid( - heze_fork_epoch.start_slot(E::slots_per_epoch()), - 0, - Address::ZERO, - 30_000_000, - 100, - ctx.genesis_block_root, - ); + let bid = ctx.sign_bid(ExecutionPayloadBid::Gloas(ExecutionPayloadBidGloas { + slot: heze_fork_epoch.start_slot(E::slots_per_epoch()), + gas_limit: 30_000_000, + value: 100, + parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), + prev_randao: ctx.expected_prev_randao(), + ..ExecutionPayloadBidGloas::default() + })); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!(matches!(result, Err(PayloadBidError::InconsistentFork(_)))); } @@ -610,17 +731,14 @@ fn gas_limit_mismatch() { let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); - let bid = ctx.sign_bid(ExecutionPayloadBidGloas { + let bid = ctx.sign_bid(ctx.make_bid( slot, - builder_index: 0, - fee_recipient: Address::ZERO, - gas_limit: 50_000_000, - value: 100, - parent_block_root: ctx.genesis_block_root, - parent_block_hash: ctx.execution_parent_hash(), - prev_randao: ctx.expected_prev_randao(), - ..ExecutionPayloadBidGloas::default() - }); + 0, + Address::ZERO, + 50_000_000, + 100, + ctx.genesis_block_root, + )); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit))); assert_eq!( @@ -667,22 +785,16 @@ fn block_hash_equals_parent_block_hash() { let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); - let parent_block_hash = ctx.execution_parent_hash(); - let bid = Arc::new(SignedExecutionPayloadBid::Gloas( - SignedExecutionPayloadBidGloas { - message: ExecutionPayloadBidGloas { - slot, - gas_limit: 30_000_000, - parent_block_root: ctx.genesis_block_root, - parent_block_hash, - block_hash: parent_block_hash, - prev_randao: ctx.expected_prev_randao(), - ..ExecutionPayloadBidGloas::default() - }, - signature: Signature::empty(), - }, - )); - let result = GossipVerifiedPayloadBid::new(bid, &gossip); + let mut bid = ctx.make_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + ); + *bid.block_hash_mut() = ctx.execution_parent_hash(); + let result = GossipVerifiedPayloadBid::new(ctx.sign_bid(bid), &gossip); assert!(matches!( result, Err(PayloadBidError::BlockHashEqualsParentBlockHash { .. }) @@ -856,21 +968,16 @@ fn execution_payment_nonzero() { let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); - let bid = Arc::new(SignedExecutionPayloadBid::Gloas( - SignedExecutionPayloadBidGloas { - message: ExecutionPayloadBidGloas { - slot, - gas_limit: 30_000_000, - execution_payment: 42, - parent_block_root: ctx.genesis_block_root, - parent_block_hash: ctx.execution_parent_hash(), - prev_randao: ctx.expected_prev_randao(), - ..ExecutionPayloadBidGloas::default() - }, - signature: Signature::empty(), - }, - )); - let result = GossipVerifiedPayloadBid::new(bid, &gossip); + let mut bid = ctx.make_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + ); + *bid.execution_payment_mut() = 42; + let result = GossipVerifiedPayloadBid::new(ctx.sign_bid(bid), &gossip); assert!(matches!( result, Err(PayloadBidError::ExecutionPaymentNonZero { .. }) @@ -1145,23 +1252,16 @@ fn invalid_blob_kzg_commitments() { .map(|_| KzgCommitment::empty_for_testing()) .collect(); - let bid = Arc::new(SignedExecutionPayloadBid::Gloas( - SignedExecutionPayloadBidGloas { - message: ExecutionPayloadBidGloas { - slot, - builder_index: 0, - fee_recipient: Address::ZERO, - gas_limit: 30_000_000, - value: 0, - parent_block_root: ctx.genesis_block_root, - parent_block_hash: ctx.execution_parent_hash(), - prev_randao: ctx.expected_prev_randao(), - blob_kzg_commitments: ProgressiveVariableList::new(commitments).unwrap(), - ..ExecutionPayloadBidGloas::default() - }, - signature: Signature::empty(), - }, - )); + let mut bid = ctx.make_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + ); + *bid.blob_kzg_commitments_mut() = ProgressiveVariableList::new(commitments).unwrap(); + let bid = ctx.sign_bid(bid); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!(matches!( result, @@ -1208,17 +1308,14 @@ fn valid_bid_after_empty_genesis_uses_parent_payload_gas_limit() { let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); - let bid = ctx.sign_bid(ExecutionPayloadBidGloas { + let bid = ctx.sign_bid(ctx.make_bid( slot, - builder_index: 0, - fee_recipient: Address::ZERO, - gas_limit: 30_000_000, - value: 0, - parent_block_root: ctx.genesis_block_root, - parent_block_hash: ctx.execution_parent_hash(), - prev_randao: ctx.expected_prev_randao(), - ..ExecutionPayloadBidGloas::default() - }); + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + )); let result = GossipVerifiedPayloadBid::new(bid, &gossip); assert!( result.is_ok(), @@ -1243,18 +1340,33 @@ fn valid_bid_with_parent_in_previous_epoch() { let slot = Slot::new(bid_slot); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); let parent_state = &ctx.canonical_head.cached_head().snapshot.beacon_state; - let bid = ctx.sign_bid(ExecutionPayloadBidGloas { - slot, - builder_index: 0, - fee_recipient: Address::ZERO, - gas_limit: 30_000_000, - parent_block_root: ctx.genesis_block_root, - parent_block_hash: ctx.execution_parent_hash(), - prev_randao: *parent_state - .get_randao_mix(parent_state.current_epoch()) - .unwrap(), - ..ExecutionPayloadBidGloas::default() - }); + let prev_randao = *parent_state + .get_randao_mix(parent_state.current_epoch()) + .unwrap(); + let bid = if ctx.spec.fork_name_at_slot::(slot).heze_enabled() { + ExecutionPayloadBid::Heze(ExecutionPayloadBidHeze { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), + prev_randao, + ..ExecutionPayloadBidHeze::default() + }) + } else { + ExecutionPayloadBid::Gloas(ExecutionPayloadBidGloas { + slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), + prev_randao, + ..ExecutionPayloadBidGloas::default() + }) + }; + let bid = ctx.sign_bid(bid); let result = GossipVerifiedPayloadBid::new(bid, &ctx.gossip_ctx()); assert!( result.is_ok(), @@ -1273,17 +1385,14 @@ fn two_builders_coexist_in_cache() { let slot = Slot::new(1); seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); - let bid_0 = ctx.sign_bid(ExecutionPayloadBidGloas { + let bid_0 = ctx.sign_bid(ctx.make_bid( slot, - builder_index: 0, - fee_recipient: Address::ZERO, - gas_limit: 30_000_000, - value: 0, - parent_block_root: ctx.genesis_block_root, - parent_block_hash: ctx.execution_parent_hash(), - prev_randao: ctx.expected_prev_randao(), - ..ExecutionPayloadBidGloas::default() - }); + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + )); let result_0 = GossipVerifiedPayloadBid::new(bid_0, &gossip); assert!( result_0.is_ok(), @@ -1292,17 +1401,14 @@ fn two_builders_coexist_in_cache() { ); // Builder 1 must bid strictly higher than builder 0's cached value. - let bid_1 = ctx.sign_bid(ExecutionPayloadBidGloas { + let bid_1 = ctx.sign_bid(ctx.make_bid( slot, - builder_index: 1, - fee_recipient: Address::ZERO, - gas_limit: 30_000_000, - value: 1, - parent_block_root: ctx.genesis_block_root, - parent_block_hash: ctx.execution_parent_hash(), - prev_randao: ctx.expected_prev_randao(), - ..ExecutionPayloadBidGloas::default() - }); + 1, + Address::ZERO, + 30_000_000, + 1, + ctx.genesis_block_root, + )); let result_1 = GossipVerifiedPayloadBid::new(bid_1, &gossip); assert!( result_1.is_ok(), @@ -1373,3 +1479,323 @@ fn bid_equal_to_cached_value_rejected() { }) )); } + +#[test] +fn il_bits_inclusivity_checks_are_not_applied_for_gloas_bids() { + if !fork_name_from_env().is_some_and(|f| f.gloas_enabled() && !f.heze_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + let slot = Slot::new(1); + seed_preferences(&ctx, slot, Address::ZERO, 30_000_000); + + // a gloas bid carries no inclusion list bits, so it could never claim the stored list + // the check would reject it at Heze, so `Ok` proves the check was not applied + let inclusion_list_committee = ctx.inclusion_list_committee(slot - 1); + seed_inclusion_list(&ctx, slot - 1, &[inclusion_list_committee[0]], true); + + let bid = ctx.sign_bid(ctx.make_bid( + slot, + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + )); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn bid_il_bits_not_inclusive() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let bid_slot = Slot::new(1); + let inclusion_list_slot = bid_slot - 1; + let inclusion_list_committee = ctx.inclusion_list_committee(inclusion_list_slot); + + seed_inclusion_list( + &ctx, + inclusion_list_slot, + &inclusion_list_committee[0..3], + true, + ); + seed_preferences(&ctx, bid_slot, Address::ZERO, 30_000_000); + + let bid = ctx.sign_bid(ExecutionPayloadBid::Heze(ExecutionPayloadBidHeze { + slot: bid_slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), + prev_randao: ctx.expected_prev_randao(), + // inclusion list bits vector only claims the IL committee members with indices 0 and 1 + inclusion_list_bits: inclusion_list_bits_for_validators( + &inclusion_list_committee, + &inclusion_list_committee[0..2], + ), + ..ExecutionPayloadBidHeze::default() + })); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + matches!( + result, + Err(PayloadBidError::InclusionListBitsNotInclusive { slot }) if slot == bid_slot + ), + "unexpected result: {result:?}" + ); +} + +#[test] +fn bid_il_bits_are_inclusive() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let bid_slot = Slot::new(1); + let inclusion_list_slot = bid_slot - 1; + let inclusion_list_committee = ctx.inclusion_list_committee(inclusion_list_slot); + + seed_inclusion_list( + &ctx, + inclusion_list_slot, + &inclusion_list_committee[0..2], + true, + ); + seed_preferences(&ctx, bid_slot, Address::ZERO, 30_000_000); + + let bid = ctx.sign_bid(ExecutionPayloadBid::Heze(ExecutionPayloadBidHeze { + slot: bid_slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), + prev_randao: ctx.expected_prev_randao(), + inclusion_list_bits: inclusion_list_bits_for_validators( + &inclusion_list_committee, + &inclusion_list_committee[0..2], + ), + ..ExecutionPayloadBidHeze::default() + })); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn bid_il_bits_checked_against_slot_before_bid() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let bid_slot = Slot::new(1); + seed_preferences(&ctx, bid_slot, Address::ZERO, 30_000_000); + + // lists stored for the bid's own slot must not be consulted + // the check looks at the slot preceding the bid's slot, which holds nothing in our case + let inclusion_list_committee = ctx.inclusion_list_committee(bid_slot); + seed_inclusion_list(&ctx, bid_slot, &inclusion_list_committee[..3], true); + + // the bid claims nothing, so it would fail against the seeded slot if that were consulted + let bid = ctx.sign_bid(ExecutionPayloadBid::Heze(ExecutionPayloadBidHeze { + slot: bid_slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), + prev_randao: ctx.expected_prev_randao(), + ..ExecutionPayloadBidHeze::default() + })); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + result.is_ok(), + "bid should pass, only the prior slot's lists are checked: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn bid_il_bits_checks_consider_only_timely_inclusion_lists() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let bid_slot = Slot::new(1); + let inclusion_list_slot = bid_slot - 1; + let inclusion_list_committee = ctx.inclusion_list_committee(inclusion_list_slot); + + seed_inclusion_list( + &ctx, + inclusion_list_slot, + &inclusion_list_committee[0..2], + true, + ); + seed_inclusion_list( + &ctx, + inclusion_list_slot, + &inclusion_list_committee[2..4], + false, + ); + seed_preferences(&ctx, bid_slot, Address::ZERO, 30_000_000); + + let bid = ctx.sign_bid(ExecutionPayloadBid::Heze(ExecutionPayloadBidHeze { + slot: bid_slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), + prev_randao: ctx.expected_prev_randao(), + // the bid only claims the bits matching the inclusion list committee indices + // of the validators that sent timely inclusion lists + inclusion_list_bits: inclusion_list_bits_for_validators( + &inclusion_list_committee, + &inclusion_list_committee[0..2], + ), + ..ExecutionPayloadBidHeze::default() + })); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn bid_il_bits_empty_claim_passes_against_empty_store() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let bid_slot = Slot::new(1); + seed_preferences(&ctx, bid_slot, Address::ZERO, 30_000_000); + + // nothing stored for the slot preceding the bid's slot and nothing claimed by the bid; + // this is the situation of the first Heze bid at the fork boundary + let bid = ctx.sign_bid(ExecutionPayloadBid::Heze(ExecutionPayloadBidHeze { + slot: bid_slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), + prev_randao: ctx.expected_prev_randao(), + ..ExecutionPayloadBidHeze::default() + })); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn bid_il_bits_claiming_more_than_stored_are_inclusive() { + if !fork_name_from_env().is_some_and(|f| f.heze_enabled()) { + return; + } + let ctx = TestContext::new(); + let gossip = ctx.gossip_ctx(); + + let bid_slot = Slot::new(1); + let inclusion_list_slot = bid_slot - 1; + let inclusion_list_committee = ctx.inclusion_list_committee(inclusion_list_slot); + + seed_inclusion_list( + &ctx, + inclusion_list_slot, + &inclusion_list_committee[0..2], + true, + ); + seed_preferences(&ctx, bid_slot, Address::ZERO, 30_000_000); + + // inclusivity is one-directional: the bid may claim members the node holds no list from + let bid = ctx.sign_bid(ExecutionPayloadBid::Heze(ExecutionPayloadBidHeze { + slot: bid_slot, + builder_index: 0, + fee_recipient: Address::ZERO, + gas_limit: 30_000_000, + value: 0, + parent_block_root: ctx.genesis_block_root, + parent_block_hash: ctx.execution_parent_hash(), + prev_randao: ctx.expected_prev_randao(), + inclusion_list_bits: inclusion_list_bits_for_validators( + &inclusion_list_committee, + &inclusion_list_committee, + ), + ..ExecutionPayloadBidHeze::default() + })); + + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + result.unwrap_err() + ); +} + +#[test] +fn bid_il_bits_check_at_heze_boundary() { + // A bid for the first Heze slot, received during the last Gloas slot + // The inclusion list slot is a Gloas slot, so the node holds no lists for it and the check passes + if fork_name_from_env() != Some(ForkName::Gloas) { + return; + } + let mut ctx = TestContext::new(); + let heze_fork_epoch = Epoch::new(1); + ctx.spec.heze_fork_epoch = Some(heze_fork_epoch); + let bid_slot = heze_fork_epoch.start_slot(E::slots_per_epoch()); + ctx.slot_clock.set_slot((bid_slot - 1).as_u64()); + seed_preferences(&ctx, bid_slot, Address::ZERO, 30_000_000); + let gossip = ctx.gossip_ctx(); + + let bid = ctx.sign_bid(ctx.make_bid( + bid_slot, + 0, + Address::ZERO, + 30_000_000, + 0, + ctx.genesis_block_root, + )); + let result = GossipVerifiedPayloadBid::new(bid, &gossip); + assert!( + result.is_ok(), + "expected Ok, got: {:?}", + 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 30b9b83d74b..470945440f1 100644 --- a/beacon_node/network/src/network_beacon_processor/gossip_methods.rs +++ b/beacon_node/network/src/network_beacon_processor/gossip_methods.rs @@ -4272,7 +4272,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 f0d5bfb4e83..edbb76dbb3d 100644 --- a/consensus/types/src/execution/mod.rs +++ b/consensus/types/src/execution/mod.rs @@ -45,7 +45,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,