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 <eserilev@gmail.com>
This commit is contained in:
Eitan Seri-Levi
2026-04-15 01:39:59 +09:00
committed by GitHub
parent 8c8facd0cd
commit b40a178111
19 changed files with 2267 additions and 79 deletions

View File

@@ -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<E: EthSpec>(
bid: &ExecutionPayloadBid<E>,
current_slot: Slot,
proposer_preferences: &SignedProposerPreferences,
head_state: &BeaconState<E>,
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<T>,
pub gossip_verified_payload_bid_cache: &'a GossipVerifiedPayloadBidCache<T>,
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<T: BeaconChainTypes> {
pub signed_bid: Arc<SignedExecutionPayloadBid<T::EthSpec>>,
}
impl<T: BeaconChainTypes> GossipVerifiedPayloadBid<T> {
pub fn new(
signed_bid: Arc<SignedExecutionPayloadBid<T::EthSpec>>,
ctx: &GossipVerificationContext<'_, T>,
) -> Result<Self, PayloadBidError> {
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<T: BeaconChainTypes> BeaconChain<T> {
/// 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<SignedExecutionPayloadBid<T::EthSpec>>,
) -> Result<GossipVerifiedPayloadBid<T>, 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<E> {
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<E>, 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::<E>(&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::<E>(&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::<E>(&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::<E>(&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<KzgCommitment> = (0..=max_blobs)
.map(|_| KzgCommitment::empty_for_testing())
.collect();
bid.blob_kzg_commitments = VariableList::new(commitments).unwrap();
let result = verify_bid_consistency::<E>(&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::<E>(&bid, current_slot, &prefs, &state, &spec);
assert!(matches!(result, Err(PayloadBidError::InvalidGasLimit)));
}
}

View File

@@ -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<BeaconStateError> for PayloadBidError {
fn from(e: BeaconStateError) -> Self {
PayloadBidError::BeaconStateError(e)
}
}

View File

@@ -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<T> =
BTreeMap<Slot, HashMap<(ExecutionBlockHash, Hash256), GossipVerifiedPayloadBid<T>>>;
pub struct GossipVerifiedPayloadBidCache<T: BeaconChainTypes> {
highest_bid: RwLock<HighestBidMap<T>>,
seen_builder: RwLock<BTreeMap<Slot, HashSet<BuilderIndex>>>,
}
impl<T: BeaconChainTypes> Default for GossipVerifiedPayloadBidCache<T> {
fn default() -> Self {
Self {
highest_bid: RwLock::new(BTreeMap::new()),
seen_builder: RwLock::new(BTreeMap::new()),
}
}
}
impl<T: BeaconChainTypes> GossipVerifiedPayloadBidCache<T> {
/// 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<Arc<SignedExecutionPayloadBid<T::EthSpec>>> {
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<T>) {
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<T>) {
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<E>;
fn make_gossip_verified(
slot: Slot,
builder_index: u64,
parent_block_hash: ExecutionBlockHash,
parent_block_root: Hash256,
value: u64,
) -> GossipVerifiedPayloadBid<T> {
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::<T>::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));
}
}
}

View File

@@ -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<E>;
/// 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<T>,
bid_cache: GossipVerifiedPayloadBidCache<T>,
preferences_cache: GossipVerifiedProposerPreferenceCache,
slot_clock: TestingSlotClock,
keypairs: Vec<Keypair>,
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::<E>();
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::<E>(&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<E>) -> Arc<SignedExecutionPayloadBid<E>> {
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::<E>(
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<SignedExecutionPayloadBid<E>> {
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<SignedProposerPreferences> {
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<KzgCommitment> = (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,
})
));
}